From 8e0e71f6a432fc9315c7707f05f9b2eab2ad8ca3 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Tue, 30 Jun 2026 09:15:06 +0200 Subject: [PATCH 01/56] feat(truapi): add testing API and versioned wiring Adds the canonical testing module (api/testing.rs) and its v01/v02/versioned wiring used by the Rust host runtime and generated clients. --- js/packages/truapi/src/client.test.ts | 2 - js/packages/truapi/src/scale.ts | 56 +++++------------ rust/crates/truapi-codegen/src/rustdoc.rs | 15 ++++- rust/crates/truapi-codegen/src/ts.rs | 12 ++++ rust/crates/truapi/src/{api/mod.rs => api.rs} | 52 +++++++++++++++ rust/crates/truapi/src/api/statement_store.rs | 10 ++- rust/crates/truapi/src/api/testing.rs | 63 +++++++++++++++++++ rust/crates/truapi/src/lib.rs | 57 +++++++++++++++-- rust/crates/truapi/src/{v01/mod.rs => v01.rs} | 4 ++ rust/crates/truapi/src/v01/testing.rs | 28 +++++++++ rust/crates/truapi/src/v02.rs | 5 ++ rust/crates/truapi/src/v02/testing.rs | 22 +++++++ .../src/{versioned/mod.rs => versioned.rs} | 2 + .../truapi/src/versioned/statement_store.rs | 1 + rust/crates/truapi/src/versioned/testing.rs | 18 ++++++ 15 files changed, 292 insertions(+), 55 deletions(-) rename rust/crates/truapi/src/{api/mod.rs => api.rs} (62%) create mode 100644 rust/crates/truapi/src/api/testing.rs rename rust/crates/truapi/src/{v01/mod.rs => v01.rs} (89%) create mode 100644 rust/crates/truapi/src/v01/testing.rs create mode 100644 rust/crates/truapi/src/v02.rs create mode 100644 rust/crates/truapi/src/v02/testing.rs rename rust/crates/truapi/src/{versioned/mod.rs => versioned.rs} (98%) create mode 100644 rust/crates/truapi/src/versioned/testing.rs diff --git a/js/packages/truapi/src/client.test.ts b/js/packages/truapi/src/client.test.ts index 2f8d2023..3ab213ba 100644 --- a/js/packages/truapi/src/client.test.ts +++ b/js/packages/truapi/src/client.test.ts @@ -86,8 +86,6 @@ describe("generated client transport", () => { expectedFrame.set(expectedPayload, str.enc("p:1").length + 1); expect(toHex(fixture.sent[0])).toBe(toHex(expectedFrame)); - expect(transport.truapiVersion).toBe(1); - expect(transport.codecVersion).toBe(1); }); it("uses the transport codec version for generated handshake calls", () => { diff --git a/js/packages/truapi/src/scale.ts b/js/packages/truapi/src/scale.ts index f5670ffa..c9a09a1c 100644 --- a/js/packages/truapi/src/scale.ts +++ b/js/packages/truapi/src/scale.ts @@ -9,12 +9,12 @@ import { Bytes, Enum, Struct, - _void, createCodec, createDecoder, enhanceCodec, - str as scaleStr, + str, u8, + _void, type Codec, } from "scale-ts"; import { @@ -123,49 +123,23 @@ export function TaggedUnion( return Enum(inner) as unknown as Codec>; } -/** - * Wire codec for Rust `CallError`, projected to the public domain error `D`. - * - * Generated TypeScript APIs expose only the domain error union in - * `ResultAsync`. The Rust host still wraps that value in - * `CallError::Domain` on the wire so framework errors can share the response - * channel. Encoding always emits `Domain`; decoding returns the inner domain - * value and throws for framework-level failures that have no public `D` shape. - */ -export function CallError(domain: Codec): Codec { - type WireCallError = - | { tag: "Domain"; value: D } - | { tag: "Denied"; value?: undefined } - | { tag: "Unsupported"; value?: undefined } - | { tag: "MalformedFrame"; value: { reason: string } } - | { tag: "HostFailure"; value: { reason: string } }; +/** Public TS value for Rust's derived `CallError` enum. */ +export type CallErrorValue = + | { tag: "Domain"; value: D } + | { tag: "Denied"; value?: undefined } + | { tag: "Unsupported"; value?: undefined } + | { tag: "MalformedFrame"; value: { reason: string } } + | { tag: "HostFailure"; value: { reason: string } }; - const wire = Enum({ +/** SCALE codec for Rust's derived `CallError` enum. */ +export function CallError(domain: Codec): Codec> { + return TaggedUnion({ Domain: domain, Denied: _void, Unsupported: _void, - MalformedFrame: Struct({ reason: scaleStr }), - HostFailure: Struct({ reason: scaleStr }), - }) as unknown as Codec; - - return enhanceCodec( - wire, - (value: D): WireCallError => ({ tag: "Domain", value }), - (value: WireCallError): D => { - switch (value.tag) { - case "Domain": - return value.value; - case "Denied": - throw new Error("Host denied the request"); - case "Unsupported": - throw new Error("Host does not support this request"); - case "MalformedFrame": - throw new Error(`Malformed request frame: ${value.value.reason}`); - case "HostFailure": - throw new Error(`Host failure: ${value.value.reason}`); - } - }, - ); + MalformedFrame: Struct({ reason: str }), + HostFailure: Struct({ reason: str }), + }) as Codec>; } type TaggedUnionCodecs = { diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index 2e8aa459..5cc8c5a9 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -294,6 +294,9 @@ pub fn extract_api(krate: &Crate) -> Result { } for candidate in candidates { + if should_skip_type_candidate(&name, &candidate) { + continue; + } let item = krate .index .get(&candidate.item_id) @@ -393,18 +396,24 @@ fn should_skip_type_name(name: &str) -> bool { | "CancellationToken" | "FrameworkOnlyError" | "Infallible" + | "LatestOf" | "RequestId" | "RuntimeFailure" | "RuntimeFailureKind" ) } +fn should_skip_type_candidate(name: &str, candidate: &ItemCandidate) -> bool { + should_skip_type_name(name) || candidate.path.iter().any(|segment| segment == "latest") +} + fn build_name_context(type_candidates: &BTreeMap>) -> NameContext { let mut ctx = NameContext::default(); for (simple_name, candidates) in type_candidates { - if should_skip_type_name(simple_name) { - continue; - } + let candidates = candidates + .iter() + .filter(|candidate| !should_skip_type_candidate(simple_name, candidate)) + .collect::>(); let has_conflict = candidates.len() > 1; for candidate in candidates { let output_name = if has_conflict { diff --git a/rust/crates/truapi-codegen/src/ts.rs b/rust/crates/truapi-codegen/src/ts.rs index eeec62a1..d833b542 100644 --- a/rust/crates/truapi-codegen/src/ts.rs +++ b/rust/crates/truapi-codegen/src/ts.rs @@ -1901,6 +1901,12 @@ fn codec_expr_mode( _ => bail!("Unsupported primitive type `{name}` in TypeScript codec generation"), }, TypeRef::Named { name, args } => { + if name == "CallError" && args.len() == 1 { + return Ok(format!( + "S.CallError({})", + codec_expr_mode(&args[0], qualified, ctx, mode)? + )); + } let resolved = resolve_named(name, mode); let target = if qualified { qualify_named(&resolved, mode) @@ -1975,6 +1981,12 @@ fn ts_type_with_named(ty: &TypeRef, qualified: bool, mode: NameMode) -> Result bail!("Unsupported primitive type `{name}` in TypeScript type generation"), }, TypeRef::Named { name, args } => { + if name == "CallError" && args.len() == 1 { + return Ok(format!( + "S.CallErrorValue<{}>", + ts_type_with_named(&args[0], qualified, mode)? + )); + } let resolved = resolve_named(name, mode); let target = if qualified { qualify_named(&resolved, mode) diff --git a/rust/crates/truapi/src/api/mod.rs b/rust/crates/truapi/src/api.rs similarity index 62% rename from rust/crates/truapi/src/api/mod.rs rename to rust/crates/truapi/src/api.rs index 957509e4..3e59e2c5 100644 --- a/rust/crates/truapi/src/api/mod.rs +++ b/rust/crates/truapi/src/api.rs @@ -14,6 +14,8 @@ pub mod resource_allocation; pub mod signing; pub mod statement_store; pub mod system; +#[cfg(debug_assertions)] +pub mod testing; pub mod theme; pub use account::Account; @@ -30,9 +32,12 @@ pub use resource_allocation::ResourceAllocation; pub use signing::Signing; pub use statement_store::StatementStore; pub use system::System; +#[cfg(debug_assertions)] +pub use testing::Testing; pub use theme::Theme; /// The unified TrUAPI contract. +#[cfg(debug_assertions)] pub trait TrUApi: Account + Chain @@ -48,12 +53,59 @@ pub trait TrUApi: + Signing + StatementStore + System + + Testing + Theme + Send + Sync { } +#[cfg(not(debug_assertions))] +pub trait TrUApi: + Account + + Chain + + Chat + + CoinPayment + + Entropy + + LocalStorage + + Notifications + + Payment + + Permissions + + Preimage + + ResourceAllocation + + Signing + + StatementStore + + System + + Theme + + Send + + Sync +{ +} + +#[cfg(debug_assertions)] +impl TrUApi for T where + T: Account + + Chain + + Chat + + CoinPayment + + Entropy + + LocalStorage + + Notifications + + Payment + + Permissions + + Preimage + + ResourceAllocation + + Signing + + StatementStore + + System + + Testing + + Theme + + Send + + Sync +{ +} + +#[cfg(not(debug_assertions))] impl TrUApi for T where T: Account + Chain diff --git a/rust/crates/truapi/src/api/statement_store.rs b/rust/crates/truapi/src/api/statement_store.rs index 5addc9b7..16cfc8cd 100644 --- a/rust/crates/truapi/src/api/statement_store.rs +++ b/rust/crates/truapi/src/api/statement_store.rs @@ -6,7 +6,8 @@ use crate::versioned::statement_store::{ RemoteStatementStoreCreateProofAuthorizedResponse, RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubmitError, RemoteStatementStoreSubmitRequest, - RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, + RemoteStatementStoreSubscribeError, RemoteStatementStoreSubscribeItem, + RemoteStatementStoreSubscribeRequest, }; use crate::wire; use crate::{CallContext, CallError, Subscription}; @@ -56,8 +57,11 @@ pub trait StatementStore: Send + Sync { &self, _cx: &CallContext, _request: RemoteStatementStoreSubscribeRequest, - ) -> Subscription { - Subscription::empty() + ) -> Result< + Subscription, + CallError, + > { + Err(CallError::unavailable()) } /// Create a proof for a statement. diff --git a/rust/crates/truapi/src/api/testing.rs b/rust/crates/truapi/src/api/testing.rs new file mode 100644 index 00000000..f8d41a2f --- /dev/null +++ b/rust/crates/truapi/src/api/testing.rs @@ -0,0 +1,63 @@ +//! Debug-only API used to verify wire-version and framework-error handling. + +use crate::v01; +use crate::v02; +use crate::versioned::testing::{ + TestingVersionProbeError, TestingVersionProbeRequest, TestingVersionProbeResponse, +}; +use crate::wire; +use crate::{CallContext, CallError}; + +/// Development-only probes for generated client/runtime compatibility. +pub trait Testing: Send + Sync { + /// Echo the request version back to the caller. + /// + /// ```ts + /// const result = await truapi.testing.versionProbe({ + /// message: "hello from V2", + /// marker: 42, + /// }); + /// assert(result.isOk(), "testing version probe failed:", result); + /// console.log("testing version probe:", result.value); + /// ``` + #[wire(request_id = 164)] + async fn version_probe( + &self, + _cx: &CallContext, + request: TestingVersionProbeRequest, + ) -> Result> { + match request { + TestingVersionProbeRequest::V1(inner) => Ok(TestingVersionProbeResponse::V1( + v01::TestingVersionProbeResponse { + received_version: 1, + message: inner.message, + }, + )), + TestingVersionProbeRequest::V2(inner) => Ok(TestingVersionProbeResponse::V2( + v02::TestingVersionProbeResponse { + received_version: 2, + message: inner.message, + marker: inner.marker, + }, + )), + } + } + + /// Echo a framework/domain error on the public response channel. + /// + /// ```ts + /// const result = await truapi.testing.echoError({ + /// error: { tag: "HostFailure", value: { reason: "forced by test" } }, + /// }); + /// assert(result.isErr(), "expected host failure"); + /// console.log("echo error:", result.error); + /// ``` + #[wire(request_id = 166)] + async fn echo_error( + &self, + _cx: &CallContext, + request: v01::EchoErrorRequest, + ) -> Result<(), CallError> { + Err(request.error) + } +} diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 7637a67b..6395b51e 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -1,30 +1,75 @@ //! TrUAPI trait and type definitions for the host product SDK. //! -//! Concrete wire types live in per-version modules (currently [`v01`]). -//! Versioned envelopes are in [`versioned`]. +//! Concrete wire types live in per-version modules. Versioned envelopes are in +//! [`versioned`]. #![forbid(unsafe_code)] #![allow(async_fn_in_trait)] -use std::convert::Infallible; -use std::pin::Pin; +use core::convert::Infallible; +use core::pin::Pin; +use core::task::{Context, Poll}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use std::task::{Context, Poll}; use futures::Stream; +use parity_scale_codec::{Decode, Encode}; pub mod api; pub mod v01; +#[cfg(debug_assertions)] +pub mod v02; pub mod versioned; +pub mod latest { + use crate::versioned::{self, Versioned}; + + pub use crate::v01::{ + AllocatableResource, GenericError, HostSignPayloadData, NotificationId, ProductAccountId, + RawPayload, RemotePermission, ThemeVariant, + }; + + pub type LatestOf = ::Latest; + + pub type HostAccountGetAliasResponse = + LatestOf; + pub type HostDevicePermissionRequest = + LatestOf; + pub type HostDevicePermissionResponse = + LatestOf; + pub type HostFeatureSupportedRequest = LatestOf; + pub type HostFeatureSupportedResponse = + LatestOf; + pub type HostLocalStorageReadError = + LatestOf; + pub type HostNavigateToError = LatestOf; + pub type HostPushNotificationRequest = + LatestOf; + pub type HostPushNotificationResponse = + LatestOf; + pub type HostRequestResourceAllocationRequest = + LatestOf; + pub type HostSignPayloadRequest = LatestOf; + pub type HostSignPayloadWithLegacyAccountRequest = + LatestOf; + pub type HostSignRawRequest = LatestOf; + pub type HostSignRawWithLegacyAccountRequest = + LatestOf; + pub type LegacyAccountTxPayload = + LatestOf; + pub type PreimageSubmitError = LatestOf; + pub type ProductAccountTxPayload = LatestOf; + pub type RemotePermissionRequest = LatestOf; + pub type RemotePermissionResponse = LatestOf; +} + pub use truapi_macros::wire; /// Per-message id carried from the transport frame. pub type RequestId = String; /// Framework-level outcomes shared by API methods. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum CallError { /// Method-specific failure. Domain(D), diff --git a/rust/crates/truapi/src/v01/mod.rs b/rust/crates/truapi/src/v01.rs similarity index 89% rename from rust/crates/truapi/src/v01/mod.rs rename to rust/crates/truapi/src/v01.rs index 8b34df5a..e3691b0e 100644 --- a/rust/crates/truapi/src/v01/mod.rs +++ b/rust/crates/truapi/src/v01.rs @@ -15,6 +15,8 @@ mod resource_allocation; mod signing; mod statement_store; mod system; +#[cfg(debug_assertions)] +mod testing; mod theme; mod transaction; @@ -33,5 +35,7 @@ pub use resource_allocation::*; pub use signing::*; pub use statement_store::*; pub use system::*; +#[cfg(debug_assertions)] +pub use testing::*; pub use theme::*; pub use transaction::*; diff --git a/rust/crates/truapi/src/v01/testing.rs b/rust/crates/truapi/src/v01/testing.rs new file mode 100644 index 00000000..297553e9 --- /dev/null +++ b/rust/crates/truapi/src/v01/testing.rs @@ -0,0 +1,28 @@ +use parity_scale_codec::{Decode, Encode}; + +use crate::CallError; + +/// V1 request payload for the debug-only Testing API. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct TestingVersionProbeRequest { + pub message: String, +} + +/// Request payload for echoing a framework/domain error through the wire shape. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct EchoErrorRequest { + pub error: CallError, +} + +/// V1 response payload for the debug-only Testing API. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct TestingVersionProbeResponse { + pub received_version: u8, + pub message: String, +} + +/// Domain error for the debug-only Testing API. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum TestingVersionProbeError { + Unknown { reason: String }, +} diff --git a/rust/crates/truapi/src/v02.rs b/rust/crates/truapi/src/v02.rs new file mode 100644 index 00000000..763b7985 --- /dev/null +++ b/rust/crates/truapi/src/v02.rs @@ -0,0 +1,5 @@ +//! TrUAPI Protocol v0.2 type definitions. + +mod testing; + +pub use testing::*; diff --git a/rust/crates/truapi/src/v02/testing.rs b/rust/crates/truapi/src/v02/testing.rs new file mode 100644 index 00000000..cbd918bd --- /dev/null +++ b/rust/crates/truapi/src/v02/testing.rs @@ -0,0 +1,22 @@ +use parity_scale_codec::{Decode, Encode}; + +/// Request payload for the debug-only Testing API. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct TestingVersionProbeRequest { + pub message: String, + pub marker: u32, +} + +/// Response payload for the debug-only Testing API. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct TestingVersionProbeResponse { + pub received_version: u8, + pub message: String, + pub marker: u32, +} + +/// Domain error for the debug-only Testing API. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum TestingVersionProbeError { + Unknown { reason: String }, +} diff --git a/rust/crates/truapi/src/versioned/mod.rs b/rust/crates/truapi/src/versioned.rs similarity index 98% rename from rust/crates/truapi/src/versioned/mod.rs rename to rust/crates/truapi/src/versioned.rs index 9da72067..75f0de53 100644 --- a/rust/crates/truapi/src/versioned/mod.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -44,6 +44,8 @@ pub mod resource_allocation; pub mod signing; pub mod statement_store; pub mod system; +#[cfg(debug_assertions)] +pub mod testing; pub mod theme; #[cfg(test)] diff --git a/rust/crates/truapi/src/versioned/statement_store.rs b/rust/crates/truapi/src/versioned/statement_store.rs index 979885ba..ed31d47e 100644 --- a/rust/crates/truapi/src/versioned/statement_store.rs +++ b/rust/crates/truapi/src/versioned/statement_store.rs @@ -5,6 +5,7 @@ use crate::v01; truapi_macros::versioned_type! { pub enum RemoteStatementStoreSubscribeRequest { V1 => v01::RemoteStatementStoreSubscribeRequest } pub enum RemoteStatementStoreSubscribeItem { V1 => v01::RemoteStatementStoreSubscribeItem } + pub enum RemoteStatementStoreSubscribeError { V1 => v01::GenericError } pub enum RemoteStatementStoreCreateProofRequest { V1 => v01::RemoteStatementStoreCreateProofRequest } pub enum RemoteStatementStoreCreateProofResponse { V1 => v01::RemoteStatementStoreCreateProofResponse } pub enum RemoteStatementStoreCreateProofError { V1 => v01::RemoteStatementStoreCreateProofError } diff --git a/rust/crates/truapi/src/versioned/testing.rs b/rust/crates/truapi/src/versioned/testing.rs new file mode 100644 index 00000000..e5d2cda0 --- /dev/null +++ b/rust/crates/truapi/src/versioned/testing.rs @@ -0,0 +1,18 @@ +//! Versioned wrappers for the debug-only [`Testing`](crate::api::Testing) API. + +use crate::{v01, v02}; + +truapi_macros::versioned_type! { + pub enum TestingVersionProbeRequest { + V1 => v01::TestingVersionProbeRequest, + V2 => v02::TestingVersionProbeRequest, + } + pub enum TestingVersionProbeResponse { + V1 => v01::TestingVersionProbeResponse, + V2 => v02::TestingVersionProbeResponse, + } + pub enum TestingVersionProbeError { + V1 => v01::TestingVersionProbeError, + V2 => v02::TestingVersionProbeError, + } +} From dea080ad1e7c2b7ce695b0da16ece92e3a53f1d6 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 1 Jul 2026 22:35:28 +0200 Subject: [PATCH 02/56] fixup! feat(truapi): add testing API and versioned wiring --- rust/crates/truapi/src/api/account.rs | 5 +-- rust/crates/truapi/src/api/signing.rs | 54 ++++++++++++++++++--------- rust/crates/truapi/src/lib.rs | 4 +- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index 7c4e065f..83211328 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -86,10 +86,9 @@ pub trait Account: Send + Sync { /// }, /// ringLocation: { /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, - /// ringRootHash: "0xd6eec26135305a8ad257a20d003357284c8aa03d0bdb2b357ab0a22371e11ef2", - /// hints: { palletInstance: 42 }, + /// ringRootHash: "0x...", /// }, - /// context: "0x", + /// context: "0x48656c6c6f", /// }); /// assert(result.isOk(), "createAccountProof failed:", result); /// console.log("account proof created:", result.value); diff --git a/rust/crates/truapi/src/api/signing.rs b/rust/crates/truapi/src/api/signing.rs index 6bc4db1e..699609f1 100644 --- a/rust/crates/truapi/src/api/signing.rs +++ b/rust/crates/truapi/src/api/signing.rs @@ -20,18 +20,19 @@ pub trait Signing: Send + Sync { /// Construct a signed transaction for a product account. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; + /// import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; /// - /// const result = await truapi.signing.createTransaction({ + /// const payload = await buildCreateTransactionPayload({ /// signer: { /// dotNsIdentifier: "truapi-playground.dot", /// derivationIndex: 0, /// }, - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, - /// callData: "0x0000", - /// extensions: [], - /// txExtVersion: 0, + /// genesisHash: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + /// callData: "0x000000", /// }); + /// assert(payload.isOk(), "buildCreateTransactionPayload failed:", payload); + /// + /// const result = await truapi.signing.createTransaction(payload.value); /// assert(result.isOk(), "createTransaction failed:", result); /// console.log("transaction created:", result.value); /// ``` @@ -47,18 +48,27 @@ pub trait Signing: Send + Sync { /// Construct a signed transaction for a non-product (legacy) account. /// /// ```ts - /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; + /// import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; /// - /// const signerResult = await accountIdForDotNsUsername(); - /// assert(signerResult.isOk(), "accountIdForDotNsUsername failed:", signerResult); - /// console.log("fetched user account:", signerResult.value); + /// const accountsResult = await truapi.account.getLegacyAccounts(); + /// assert(accountsResult.isOk(), "getLegacyAccounts failed:", accountsResult); + /// const legacyAccount = accountsResult.value.accounts[0]; + /// assert(legacyAccount, "no legacy accounts available"); + /// console.log("selected legacy account:", legacyAccount); + /// + /// const payload = await buildCreateTransactionPayload({ + /// signer: { + /// dotNsIdentifier: "truapi-playground.dot", + /// derivationIndex: 0, + /// }, + /// genesisHash: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + /// callData: "0x000000", + /// }); + /// assert(payload.isOk(), "buildCreateTransactionPayload failed:", payload); /// /// const result = await truapi.signing.createTransactionWithLegacyAccount({ - /// signer: signerResult.value, - /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, - /// callData: "0x0000", - /// extensions: [], - /// txExtVersion: 0, + /// ...payload.value, + /// signer: legacyAccount.publicKey, /// }); /// assert(result.isOk(), "createTransactionWithLegacyAccount failed:", result); /// console.log("transaction created:", result.value); @@ -78,8 +88,13 @@ pub trait Signing: Send + Sync { /// Sign raw bytes with a non-product account. /// /// ```ts + /// const accountsResult = await truapi.account.getLegacyAccounts(); + /// assert(accountsResult.isOk(), "getLegacyAccounts failed:", accountsResult); + /// const legacyAccount = accountsResult.value.accounts[0]; + /// assert(legacyAccount, "no legacy accounts available"); + /// /// const result = await truapi.signing.signRawWithLegacyAccount({ - /// signer: "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + /// signer: legacyAccount.publicKey, /// payload: { /// tag: "Bytes", /// value: { bytes: "0x48656c6c6f" }, @@ -103,8 +118,13 @@ pub trait Signing: Send + Sync { /// ```ts /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; /// + /// const accountsResult = await truapi.account.getLegacyAccounts(); + /// assert(accountsResult.isOk(), "getLegacyAccounts failed:", accountsResult); + /// const legacyAccount = accountsResult.value.accounts[0]; + /// assert(legacyAccount, "no legacy accounts available"); + /// /// const result = await truapi.signing.signPayloadWithLegacyAccount({ - /// signer: "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + /// signer: legacyAccount.publicKey, /// payload: { /// blockHash: "0xd6eec26135305a8ad257a20d003357284c8aa03d0bdb2b357ab0a22371e11ef2", /// blockNumber: "0x00000000", diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 6395b51e..72be14c0 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -25,8 +25,8 @@ pub mod latest { use crate::versioned::{self, Versioned}; pub use crate::v01::{ - AllocatableResource, GenericError, HostSignPayloadData, NotificationId, ProductAccountId, - RawPayload, RemotePermission, ThemeVariant, + AccountId, AllocatableResource, GenericError, HostSignPayloadData, NotificationId, + ProductAccountId, RawPayload, RemotePermission, ThemeVariant, }; pub type LatestOf = ::Latest; From 34091523d48c7d7c40e44eaf92784110cbb0de2c Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 1 Jul 2026 23:22:06 +0200 Subject: [PATCH 03/56] fixup! feat(truapi): add testing API and versioned wiring --- playground/package.json | 4 +- playground/src/lib/example-helpers.ts | 444 +++++++++++++++++- playground/src/lib/example-runner.ts | 36 +- playground/src/lib/monaco-setup.ts | 6 + playground/yarn.lock | 49 +- rust/crates/truapi-codegen/src/ts/examples.rs | 6 + 6 files changed, 507 insertions(+), 38 deletions(-) diff --git a/playground/package.json b/playground/package.json index 848f58b8..78fef5f3 100644 --- a/playground/package.json +++ b/playground/package.json @@ -23,7 +23,9 @@ "dependencies": { "@monaco-editor/react": "^4", "@parity/truapi": "link:../js/packages/truapi", - "@polkadot-api/substrate-bindings": "^0.12.0", + "@polkadot-api/metadata-builders": "0.14.2", + "@polkadot-api/substrate-bindings": "0.20.2", + "@polkadot-api/utils": "0.4.0", "monaco-editor": "^0.52", "neverthrow": "^8.2.0", "next": "15.5.18", diff --git a/playground/src/lib/example-helpers.ts b/playground/src/lib/example-helpers.ts index 3cfb5d3c..0ee99018 100644 --- a/playground/src/lib/example-helpers.ts +++ b/playground/src/lib/example-helpers.ts @@ -4,9 +4,26 @@ import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; import { Blake2128Concat, Bytes, + decAnyMetadata, Storage, + unifyMetadata, } from "@polkadot-api/substrate-bindings"; -import type { Client, HexString, StorageResultItem } from "@parity/truapi"; +import { + getDynamicBuilder, + getLookupFn, +} from "@polkadot-api/metadata-builders"; +import { fromHex, toHex } from "@polkadot-api/utils"; +import type { + Client, + HexString, + ProductAccountId, + ProductAccountTxPayload, + RemoteChainHeadFollowItem, + RuntimeSpec, + RuntimeType, + StorageResultItem, + TxPayloadExtension, +} from "@parity/truapi"; export type ChainHeadCtx = { genesisHash: `0x${string}`; @@ -23,6 +40,12 @@ export type AccountIdForDotNsUsername = ( username?: string, ) => Promise>; +export type BuildCreateTransactionPayload = (opts: { + signer: ProductAccountId; + genesisHash: HexString; + callData: HexString; +}) => Promise>; + const usernameOwnerOfStorage = Storage("Resources")("UsernameOwnerOf", [ Bytes(), Blake2128Concat, @@ -176,6 +199,425 @@ export function createAccountIdForDotNsUsername( }; } +export function createBuildCreateTransactionPayload( + truapi: Client, +): BuildCreateTransactionPayload { + return async function buildCreateTransactionPayload(opts) { + const accountResult = await truapi.account.getAccount({ + productAccountId: opts.signer, + }); + if (accountResult.isErr()) { + return err(toError(accountResult.error)); + } + + const built = await buildTransactionContext( + truapi, + opts.genesisHash, + accountResult.value.account.publicKey, + ); + if (built.isErr()) return err(built.error); + + const { metadata, runtime, nonce, genesisHash } = built.value; + const unified = unifyMetadata(decAnyMetadata(metadata)); + const lookupFn = getLookupFn(unified); + const builder = getDynamicBuilder(lookupFn); + const chainState = { + genesisHash: fromHex(genesisHash), + specVersion: runtime.specVersion, + transactionVersion: runtime.transactionVersion ?? 0, + nonce, + }; + + return ok({ + signer: opts.signer, + genesisHash, + callData: opts.callData, + extensions: encodeSignedExtensions( + unified, + lookupFn, + builder, + chainState, + ), + txExtVersion: txExtVersionFromMetadata(unified), + }); + }; +} + +type UnifiedMetadata = ReturnType; +type LookupFn = ReturnType; +type LookupEntry = ReturnType; +type DynamicBuilder = ReturnType; + +type ChainState = { + genesisHash: Uint8Array; + specVersion: number; + transactionVersion: number; + nonce: number; +}; + +type TransactionContext = { + genesisHash: HexString; + metadata: Uint8Array; + nonce: number; + runtime: RuntimeSpec; +}; + +function buildTransactionContext( + truapi: Client, + genesisHash: HexString, + accountPublicKey: HexString, +): Promise> { + return new Promise((resolve) => { + let subscription: ReturnType< + ReturnType["subscribe"] + > | null = null; + const completedOperations = new Map>(); + const operationWaiters = new Map< + string, + (result: Result) => void + >(); + let initialized = false; + let settled = false; + + const settle = (result: Result) => { + if (settled) return; + settled = true; + try { + subscription?.unsubscribe(); + } catch { + /* benign */ + } + resolve(result); + }; + + const finishOperation = ( + operationId: string, + result: Result, + ) => { + const waiter = operationWaiters.get(operationId); + if (waiter) { + operationWaiters.delete(operationId); + waiter(result); + return; + } + completedOperations.set(operationId, result); + }; + + const waitForOperation = ( + operationId: string, + ): Promise> => { + const completed = completedOperations.get(operationId); + if (completed) { + completedOperations.delete(operationId); + return Promise.resolve(completed); + } + return new Promise((operationResolve) => { + operationWaiters.set(operationId, operationResolve); + }); + }; + + const callHead = async ( + hash: HexString, + fn: string, + callParameters: HexString, + ): Promise> => { + if (!subscription) { + return err(new Error("chain head subscription was not initialized")); + } + const result = await truapi.chain.callHead({ + genesisHash, + followSubscriptionId: subscription.subscriptionId, + hash, + function: fn, + callParameters, + }); + if (result.isErr()) return err(toError(result.error)); + if (result.value.operation.tag !== "Started") { + return err(new Error(`chainHead call limit reached for ${fn}`)); + } + return waitForOperation(result.value.operation.value.operationId); + }; + + const handleInitialized = async ( + item: Extract, + ) => { + if (initialized) return; + initialized = true; + const hash = item.value.finalizedBlockHashes[0]; + if (!hash) { + settle( + err(new Error("chainHead initialized without a finalized hash")), + ); + return; + } + const runtime = runtimeSpecFrom(item.value.finalizedBlockRuntime); + if (runtime.isErr()) { + settle(err(runtime.error)); + return; + } + + const [metadata, nonce] = await Promise.all([ + callHead(hash, "Metadata_metadata", "0x"), + callHead(hash, "AccountNonceApi_account_nonce", accountPublicKey), + ]); + if (metadata.isErr()) { + settle(err(metadata.error)); + return; + } + if (nonce.isErr()) { + settle(err(nonce.error)); + return; + } + + const rawMetadata = unwrapOpaqueMetadata(metadata.value); + if (rawMetadata.isErr()) { + settle(err(rawMetadata.error)); + return; + } + + let decodedNonce: number; + try { + decodedNonce = nonceFromRuntimeApiOutput(nonce.value); + } catch (error) { + settle(err(toError(error))); + return; + } + + const followSubscriptionId = subscription?.subscriptionId; + if (followSubscriptionId) { + void truapi.chain.unpinHead({ + genesisHash, + followSubscriptionId, + hashes: [hash], + }); + } + + settle( + ok({ + genesisHash, + metadata: rawMetadata.value, + nonce: decodedNonce, + runtime: runtime.value, + }), + ); + }; + + subscription = truapi.chain + .followHeadSubscribe({ + request: { genesisHash, withRuntime: true }, + }) + .subscribe({ + next: (item) => { + switch (item.tag) { + case "Initialized": + void handleInitialized(item); + return; + case "OperationCallDone": + finishOperation(item.value.operationId, ok(item.value.output)); + return; + case "OperationError": + finishOperation( + item.value.operationId, + err( + new Error(`chainHead operation failed: ${item.value.error}`), + ), + ); + return; + case "OperationInaccessible": + finishOperation( + item.value.operationId, + err(new Error("chainHead operation inaccessible")), + ); + return; + case "Stop": + settle( + err( + new Error( + "chain head subscription stopped before transaction context was built", + ), + ), + ); + return; + } + }, + error: (error) => settle(err(toError(error))), + complete: () => + settle( + err( + new Error( + "chain head subscription completed before transaction context was built", + ), + ), + ), + }); + }); +} + +function runtimeSpecFrom(value?: RuntimeType): Result { + if (!value) return err(new Error("chainHead did not include runtime data")); + if (value.tag === "Invalid") { + return err(new Error(`chainHead runtime invalid: ${value.value.error}`)); + } + if (value.value.transactionVersion === undefined) { + return err(new Error("runtime did not include transactionVersion")); + } + return ok(value.value); +} + +function unwrapOpaqueMetadata(output: HexString): Result { + try { + const raw = Bytes().dec(fromHex(output)); + if ( + raw.length < 5 || + raw[0] !== 0x6d || + raw[1] !== 0x65 || + raw[2] !== 0x74 || + raw[3] !== 0x61 + ) { + return err( + new Error("runtime Metadata_metadata returned invalid metadata"), + ); + } + return ok(raw); + } catch (error) { + return err(toError(error)); + } +} + +function nonceFromRuntimeApiOutput(output: HexString): number { + const bytes = fromHex(output); + if (bytes.length < 4) { + throw new Error("AccountNonceApi_account_nonce returned too few bytes"); + } + return new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getUint32(0, true); +} + +function txExtVersionFromMetadata(metadata: UnifiedMetadata): number { + const latestVersion = metadata.extrinsic.version.reduce( + (max, version) => Math.max(max, version), + 0, + ); + return latestVersion === 4 ? 0 : latestVersion; +} + +function encodeSignedExtensions( + metadata: UnifiedMetadata, + lookupFn: LookupFn, + builder: DynamicBuilder, + chainState: ChainState, +): TxPayloadExtension[] { + const exts = metadata.extrinsic.signedExtensions[0] as Array<{ + identifier: string; + type: number; + additionalSigned: number; + }>; + + return exts.map((ext) => { + const values = signedExtensionValues(ext, lookupFn, chainState); + const extra = encodeExtensionField( + builder, + lookupFn, + ext.type, + values.extra, + ); + const additionalSigned = encodeExtensionField( + builder, + lookupFn, + ext.additionalSigned, + values.additionalSigned, + ); + + return { + id: ext.identifier, + extra: toHex(extra) as HexString, + additionalSigned: toHex(additionalSigned) as HexString, + }; + }); +} + +function signedExtensionValues( + ext: { identifier: string; type: number; additionalSigned: number }, + lookupFn: LookupFn, + chainState: ChainState, +): { extra: unknown; additionalSigned: unknown } { + switch (ext.identifier) { + case "CheckNonce": + return { extra: chainState.nonce, additionalSigned: undefined }; + case "CheckSpecVersion": + return { + extra: undefined, + additionalSigned: chainState.specVersion, + }; + case "CheckTxVersion": + return { + extra: undefined, + additionalSigned: chainState.transactionVersion, + }; + case "CheckGenesis": + return { + extra: undefined, + additionalSigned: toHex(chainState.genesisHash), + }; + case "CheckMortality": + return { + extra: { type: "Immortal" }, + additionalSigned: toHex(chainState.genesisHash), + }; + case "VerifyMultiSignature": + return { extra: { type: "Disabled" }, additionalSigned: undefined }; + case "ChargeAssetTxPayment": + return { + extra: { tip: 0, asset_id: undefined }, + additionalSigned: undefined, + }; + case "RestrictOrigins": + return { extra: false, additionalSigned: undefined }; + default: + return { + extra: defaultValueForType(lookupFn(ext.type)), + additionalSigned: defaultValueForType(lookupFn(ext.additionalSigned)), + }; + } +} + +function encodeExtensionField( + builder: DynamicBuilder, + lookupFn: LookupFn, + typeId: number, + value: unknown, +): Uint8Array { + const entry = lookupFn(typeId); + if (!entry || entry.type === "void") return new Uint8Array(0); + const codec = builder.buildDefinition(typeId) as { + enc: (value: unknown) => Uint8Array; + }; + return codec.enc(value); +} + +function defaultValueForType(entry: LookupEntry): unknown { + if (!entry) return undefined; + if (entry.type === "void" || entry.type === "option") return undefined; + if (entry.type === "primitive") { + if (entry.value === "bool") return false; + if (entry.value.startsWith("u") || entry.value.startsWith("i")) return 0; + return undefined; + } + if (entry.type === "compact") return 0; + if (entry.type === "array") return new Uint8Array(entry.len); + if (entry.type === "enum") { + const first = Object.entries(entry.value)[0]; + if (!first) return undefined; + const [name, variant] = first; + if (variant.type === "void") return { type: name }; + return { type: name, value: undefined }; + } + return undefined; +} + function findStorageValue( items: StorageResultItem[], key: HexString, diff --git a/playground/src/lib/example-runner.ts b/playground/src/lib/example-runner.ts index 97cf72df..79f1aee7 100644 --- a/playground/src/lib/example-runner.ts +++ b/playground/src/lib/example-runner.ts @@ -2,8 +2,10 @@ import { transform } from "sucrase"; import type { Subscription, TrUApiClient } from "@parity/truapi"; import { createAccountIdForDotNsUsername, + createBuildCreateTransactionPayload, createWithChainHeadFollow, type AccountIdForDotNsUsername, + type BuildCreateTransactionPayload, type WithChainHeadFollow, } from "./example-helpers"; @@ -39,14 +41,14 @@ function exampleAssert( // Drop any `@parity/truapi` import that does not name value specifiers (e.g. // bare type-only imports left over after sucrase). Named value imports are // rewritten by `TRUAPI_NAMED_IMPORT_RE` below. -const IMPORT_RE = /^\s*import\s+(?!\{)[^;]*?from\s+["']@parity\/truapi["'];?\s*$/gm; +const IMPORT_RE = + /^\s*import\s+(?!\{)[^;]*?from\s+["']@parity\/truapi["'];?\s*$/gm; // `import { PASEO_NEXT_V2_ASSET_HUB, ... } from "@parity/truapi"` // → `const { PASEO_NEXT_V2_ASSET_HUB, ... } = __truapi;` const TRUAPI_NAMED_IMPORT_RE = /^\s*import\s*(\{[^}]*\})\s*from\s+["']@parity\/truapi["'];?\s*$/gm; // `import { from, take, ... } from "rxjs"` → `const { from, take, ... } = __rxjs;` -const RXJS_IMPORT_RE = - /^\s*import\s*(\{[^}]*\})\s*from\s+["']rxjs["'];?\s*$/gm; +const RXJS_IMPORT_RE = /^\s*import\s*(\{[^}]*\})\s*from\s+["']rxjs["'];?\s*$/gm; const EXPORT_RE = /^(\s*)export\s+(async\s+function|function|const|let|var|class)\b/gm; @@ -58,14 +60,16 @@ type ConsoleShim = { warn: (...args: unknown[]) => void; }; -const AsyncFunction = Object.getPrototypeOf( - async function () {}, -).constructor as new (...args: string[]) => ( +const AsyncFunction = Object.getPrototypeOf(async function () {}) + .constructor as new ( + ...args: string[] +) => ( truapi: unknown, __console: ConsoleShim, __rxjs: unknown, withChainHeadFollow: WithChainHeadFollow, accountIdForDotNsUsername: AccountIdForDotNsUsername, + buildCreateTransactionPayload: BuildCreateTransactionPayload, __truapi: unknown, assert: typeof exampleAssert, ) => Promise; @@ -105,6 +109,7 @@ export async function runExample(opts: { rxjs: unknown, withChainHeadFollow: WithChainHeadFollow, accountIdForDotNsUsername: AccountIdForDotNsUsername, + buildCreateTransactionPayload: BuildCreateTransactionPayload, truapiPkg: unknown, assert: typeof exampleAssert, ) => Promise; @@ -115,6 +120,7 @@ export async function runExample(opts: { "__rxjs", "withChainHeadFollow", "accountIdForDotNsUsername", + "buildCreateTransactionPayload", "__truapi", "assert", body, @@ -147,16 +153,22 @@ export async function runExample(opts: { }; const [rxjs, truapiPkg] = await Promise.all([getRxjs(), getTruapiPkg()]); - const withChainHeadFollow = createWithChainHeadFollow(trackingClient as TrUApiClient); + const withChainHeadFollow = createWithChainHeadFollow( + trackingClient as TrUApiClient, + ); const accountIdForDotNsUsername = createAccountIdForDotNsUsername( trackingClient as TrUApiClient, ); + const buildCreateTransactionPayload = createBuildCreateTransactionPayload( + trackingClient as TrUApiClient, + ); const promise = run( trackingClient, consoleShim, rxjs, withChainHeadFollow, accountIdForDotNsUsername, + buildCreateTransactionPayload, truapiPkg, exampleAssert, ); @@ -181,17 +193,17 @@ function createTrackingClient( }); } -function wrapService( - svc: object, - onSub: (sub: Subscription) => void, -): unknown { +function wrapService(svc: object, onSub: (sub: Subscription) => void): unknown { return new Proxy(svc as Record, { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); if (typeof value !== "function") return value; return (...args: unknown[]) => { const out = (value as (...a: unknown[]) => unknown).apply(target, args); - if (out && typeof (out as { subscribe?: unknown }).subscribe === "function") { + if ( + out && + typeof (out as { subscribe?: unknown }).subscribe === "function" + ) { return wrapObservable(out as ObservableLike, onSub); } return out; diff --git a/playground/src/lib/monaco-setup.ts b/playground/src/lib/monaco-setup.ts index eb401a25..facddd99 100644 --- a/playground/src/lib/monaco-setup.ts +++ b/playground/src/lib/monaco-setup.ts @@ -97,6 +97,12 @@ export function setupMonaco(m: Monaco): void { ` }): import("rxjs").Observable;`, ` /** Resolve a DotNS username to the owning raw AccountId32 hex string. Defaults to truapi.account.getUserId(). */`, ` function accountIdForDotNsUsername(username?: string): Promise>;`, + ` /** Build a metadata-backed product-account transaction payload for \`truapi.signing.createTransaction\`. */`, + ` function buildCreateTransactionPayload(opts: {`, + ` signer: import("@parity/truapi").ProductAccountId;`, + ` genesisHash: \`0x\${string}\`;`, + ` callData: \`0x\${string}\`;`, + ` }): Promise>;`, ` /**`, ` * Assert a condition, throwing when it does not hold. Examples signal`, ` * failure explicitly with \`assert(...)\`; the diagnosis marks an example`, diff --git a/playground/yarn.lock b/playground/yarn.lock index a485b3d9..01d5a21a 100644 --- a/playground/yarn.lock +++ b/playground/yarn.lock @@ -371,11 +371,6 @@ resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.18.tgz#beac6228e60e3ee08ce7a20b7f61b3dc516d4b10" integrity sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg== -"@noble/hashes@^1.8.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" - integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== - "@noble/hashes@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.2.0.tgz#22da1d16a469954fce877055d559900a6c73b63b" @@ -408,10 +403,8 @@ integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA== "@parity/truapi@link:../js/packages/truapi": - version "0.3.1" - dependencies: - neverthrow "^8.2.0" - scale-ts "^1.6.1" + version "0.0.0" + uid "" "@playwright/test@^1.49.1": version "1.59.1" @@ -420,20 +413,28 @@ dependencies: playwright "1.59.1" -"@polkadot-api/substrate-bindings@^0.12.0": - version "0.12.0" - resolved "https://registry.yarnpkg.com/@polkadot-api/substrate-bindings/-/substrate-bindings-0.12.0.tgz#2b9cd9ba1b7e29c4a1d0be0575504c02cb435c78" - integrity sha512-cIjDeJRHW6g3z+/55UzpoG4LG1N0HbT4x3NvZsQkYg4eoio9Sw7Pw2aZZX86pWemxc7vQbNw7WSz2Gz+ckdX6Q== +"@polkadot-api/metadata-builders@0.14.2": + version "0.14.2" + resolved "https://registry.yarnpkg.com/@polkadot-api/metadata-builders/-/metadata-builders-0.14.2.tgz#b7081728eb6451ae7cc5d56061b301058b1d4af2" + integrity sha512-nhsFfti0M5tE0LR8++0wHqbP54I/QSFXP/uF5I82MYVSKY0NqIDkIFvr27oLo/ltF+o3vcN44ZJQqvi1k6l9mA== + dependencies: + "@polkadot-api/substrate-bindings" "0.20.2" + "@polkadot-api/utils" "0.4.0" + +"@polkadot-api/substrate-bindings@0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@polkadot-api/substrate-bindings/-/substrate-bindings-0.20.2.tgz#d0a74935e1b78583375202fb560b22b2d8001ecf" + integrity sha512-js5UTREoI+FlrPRXMhtKimVWmOqwfNFBnhyshsdloSZHNx/Hulg2RQZNvrVTscyZTf8LyxlGJaH5dsitOUoFKw== dependencies: - "@noble/hashes" "^1.8.0" - "@polkadot-api/utils" "0.1.2" - "@scure/base" "^1.2.5" + "@noble/hashes" "^2.2.0" + "@polkadot-api/utils" "0.4.0" + "@scure/base" "^2.2.0" scale-ts "^1.6.1" -"@polkadot-api/utils@0.1.2": - version "0.1.2" - resolved "https://registry.yarnpkg.com/@polkadot-api/utils/-/utils-0.1.2.tgz#45471371183efaa2fc52f40d84326d84e49c7297" - integrity sha512-yhs5k2a8N1SBJcz7EthZoazzLQUkZxbf+0271Xzu42C5AEM9K9uFLbsB+ojzHEM72O5X8lPtSwGKNmS7WQyDyg== +"@polkadot-api/utils@0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@polkadot-api/utils/-/utils-0.4.0.tgz#6ee6476aa40dbdb92e4ded39d2feb9002b5b509a" + integrity sha512-9b/hwRM0UloLWV7SfpNaSD/4k8UQAHoaACAk7Xe+1MlfAm2JtnmPiB1GfGrfTyBlsrJVUIBCZpEmbmxVMaIqBA== "@rollup/rollup-linux-x64-gnu@^4.24.0": version "4.60.4" @@ -450,10 +451,10 @@ resolved "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz" integrity sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag== -"@scure/base@^1.2.5": - version "1.2.6" - resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.2.6.tgz#ca917184b8231394dd8847509c67a0be522e59f6" - integrity sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg== +"@scure/base@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.2.0.tgz#1311378ed247df6d58f8eb8941921965e97e5747" + integrity sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg== "@swc/helpers@0.5.15": version "0.5.15" diff --git a/rust/crates/truapi-codegen/src/ts/examples.rs b/rust/crates/truapi-codegen/src/ts/examples.rs index b0cc842f..11b6d151 100644 --- a/rust/crates/truapi-codegen/src/ts/examples.rs +++ b/rust/crates/truapi-codegen/src/ts/examples.rs @@ -45,6 +45,12 @@ declare global { }): Observable; /** Resolve a DotNS username to the owning raw AccountId32 hex string. Defaults to truapi.account.getUserId(). */ function accountIdForDotNsUsername(username?: string): Promise>; + /** Build a metadata-backed product-account transaction payload for `truapi.signing.createTransaction`. */ + function buildCreateTransactionPayload(opts: { + signer: import("@parity/truapi").ProductAccountId; + genesisHash: `0x${string}`; + callData: `0x${string}`; + }): Promise>; /** * Assert a condition, throwing when it does not hold. Examples signal * failure explicitly with `assert(...)`; the playground's diagnosis marks From 0569aafa38b64029daeda2f340f7ca842f609db6 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 2 Jul 2026 10:10:52 +0200 Subject: [PATCH 04/56] fixup! feat(truapi): add testing API and versioned wiring --- rust/crates/truapi/src/api.rs | 52 ----------------- rust/crates/truapi/src/api/testing.rs | 63 --------------------- rust/crates/truapi/src/lib.rs | 2 - rust/crates/truapi/src/v01.rs | 4 -- rust/crates/truapi/src/v01/testing.rs | 28 --------- rust/crates/truapi/src/v02.rs | 5 -- rust/crates/truapi/src/v02/testing.rs | 22 ------- rust/crates/truapi/src/versioned.rs | 2 - rust/crates/truapi/src/versioned/testing.rs | 18 ------ 9 files changed, 196 deletions(-) delete mode 100644 rust/crates/truapi/src/api/testing.rs delete mode 100644 rust/crates/truapi/src/v01/testing.rs delete mode 100644 rust/crates/truapi/src/v02.rs delete mode 100644 rust/crates/truapi/src/v02/testing.rs delete mode 100644 rust/crates/truapi/src/versioned/testing.rs diff --git a/rust/crates/truapi/src/api.rs b/rust/crates/truapi/src/api.rs index 3e59e2c5..957509e4 100644 --- a/rust/crates/truapi/src/api.rs +++ b/rust/crates/truapi/src/api.rs @@ -14,8 +14,6 @@ pub mod resource_allocation; pub mod signing; pub mod statement_store; pub mod system; -#[cfg(debug_assertions)] -pub mod testing; pub mod theme; pub use account::Account; @@ -32,12 +30,9 @@ pub use resource_allocation::ResourceAllocation; pub use signing::Signing; pub use statement_store::StatementStore; pub use system::System; -#[cfg(debug_assertions)] -pub use testing::Testing; pub use theme::Theme; /// The unified TrUAPI contract. -#[cfg(debug_assertions)] pub trait TrUApi: Account + Chain @@ -53,59 +48,12 @@ pub trait TrUApi: + Signing + StatementStore + System - + Testing + Theme + Send + Sync { } -#[cfg(not(debug_assertions))] -pub trait TrUApi: - Account - + Chain - + Chat - + CoinPayment - + Entropy - + LocalStorage - + Notifications - + Payment - + Permissions - + Preimage - + ResourceAllocation - + Signing - + StatementStore - + System - + Theme - + Send - + Sync -{ -} - -#[cfg(debug_assertions)] -impl TrUApi for T where - T: Account - + Chain - + Chat - + CoinPayment - + Entropy - + LocalStorage - + Notifications - + Payment - + Permissions - + Preimage - + ResourceAllocation - + Signing - + StatementStore - + System - + Testing - + Theme - + Send - + Sync -{ -} - -#[cfg(not(debug_assertions))] impl TrUApi for T where T: Account + Chain diff --git a/rust/crates/truapi/src/api/testing.rs b/rust/crates/truapi/src/api/testing.rs deleted file mode 100644 index f8d41a2f..00000000 --- a/rust/crates/truapi/src/api/testing.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Debug-only API used to verify wire-version and framework-error handling. - -use crate::v01; -use crate::v02; -use crate::versioned::testing::{ - TestingVersionProbeError, TestingVersionProbeRequest, TestingVersionProbeResponse, -}; -use crate::wire; -use crate::{CallContext, CallError}; - -/// Development-only probes for generated client/runtime compatibility. -pub trait Testing: Send + Sync { - /// Echo the request version back to the caller. - /// - /// ```ts - /// const result = await truapi.testing.versionProbe({ - /// message: "hello from V2", - /// marker: 42, - /// }); - /// assert(result.isOk(), "testing version probe failed:", result); - /// console.log("testing version probe:", result.value); - /// ``` - #[wire(request_id = 164)] - async fn version_probe( - &self, - _cx: &CallContext, - request: TestingVersionProbeRequest, - ) -> Result> { - match request { - TestingVersionProbeRequest::V1(inner) => Ok(TestingVersionProbeResponse::V1( - v01::TestingVersionProbeResponse { - received_version: 1, - message: inner.message, - }, - )), - TestingVersionProbeRequest::V2(inner) => Ok(TestingVersionProbeResponse::V2( - v02::TestingVersionProbeResponse { - received_version: 2, - message: inner.message, - marker: inner.marker, - }, - )), - } - } - - /// Echo a framework/domain error on the public response channel. - /// - /// ```ts - /// const result = await truapi.testing.echoError({ - /// error: { tag: "HostFailure", value: { reason: "forced by test" } }, - /// }); - /// assert(result.isErr(), "expected host failure"); - /// console.log("echo error:", result.error); - /// ``` - #[wire(request_id = 166)] - async fn echo_error( - &self, - _cx: &CallContext, - request: v01::EchoErrorRequest, - ) -> Result<(), CallError> { - Err(request.error) - } -} diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 72be14c0..b4baf520 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -17,8 +17,6 @@ use parity_scale_codec::{Decode, Encode}; pub mod api; pub mod v01; -#[cfg(debug_assertions)] -pub mod v02; pub mod versioned; pub mod latest { diff --git a/rust/crates/truapi/src/v01.rs b/rust/crates/truapi/src/v01.rs index e3691b0e..8b34df5a 100644 --- a/rust/crates/truapi/src/v01.rs +++ b/rust/crates/truapi/src/v01.rs @@ -15,8 +15,6 @@ mod resource_allocation; mod signing; mod statement_store; mod system; -#[cfg(debug_assertions)] -mod testing; mod theme; mod transaction; @@ -35,7 +33,5 @@ pub use resource_allocation::*; pub use signing::*; pub use statement_store::*; pub use system::*; -#[cfg(debug_assertions)] -pub use testing::*; pub use theme::*; pub use transaction::*; diff --git a/rust/crates/truapi/src/v01/testing.rs b/rust/crates/truapi/src/v01/testing.rs deleted file mode 100644 index 297553e9..00000000 --- a/rust/crates/truapi/src/v01/testing.rs +++ /dev/null @@ -1,28 +0,0 @@ -use parity_scale_codec::{Decode, Encode}; - -use crate::CallError; - -/// V1 request payload for the debug-only Testing API. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct TestingVersionProbeRequest { - pub message: String, -} - -/// Request payload for echoing a framework/domain error through the wire shape. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct EchoErrorRequest { - pub error: CallError, -} - -/// V1 response payload for the debug-only Testing API. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct TestingVersionProbeResponse { - pub received_version: u8, - pub message: String, -} - -/// Domain error for the debug-only Testing API. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum TestingVersionProbeError { - Unknown { reason: String }, -} diff --git a/rust/crates/truapi/src/v02.rs b/rust/crates/truapi/src/v02.rs deleted file mode 100644 index 763b7985..00000000 --- a/rust/crates/truapi/src/v02.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! TrUAPI Protocol v0.2 type definitions. - -mod testing; - -pub use testing::*; diff --git a/rust/crates/truapi/src/v02/testing.rs b/rust/crates/truapi/src/v02/testing.rs deleted file mode 100644 index cbd918bd..00000000 --- a/rust/crates/truapi/src/v02/testing.rs +++ /dev/null @@ -1,22 +0,0 @@ -use parity_scale_codec::{Decode, Encode}; - -/// Request payload for the debug-only Testing API. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct TestingVersionProbeRequest { - pub message: String, - pub marker: u32, -} - -/// Response payload for the debug-only Testing API. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct TestingVersionProbeResponse { - pub received_version: u8, - pub message: String, - pub marker: u32, -} - -/// Domain error for the debug-only Testing API. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum TestingVersionProbeError { - Unknown { reason: String }, -} diff --git a/rust/crates/truapi/src/versioned.rs b/rust/crates/truapi/src/versioned.rs index 75f0de53..9da72067 100644 --- a/rust/crates/truapi/src/versioned.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -44,8 +44,6 @@ pub mod resource_allocation; pub mod signing; pub mod statement_store; pub mod system; -#[cfg(debug_assertions)] -pub mod testing; pub mod theme; #[cfg(test)] diff --git a/rust/crates/truapi/src/versioned/testing.rs b/rust/crates/truapi/src/versioned/testing.rs deleted file mode 100644 index e5d2cda0..00000000 --- a/rust/crates/truapi/src/versioned/testing.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Versioned wrappers for the debug-only [`Testing`](crate::api::Testing) API. - -use crate::{v01, v02}; - -truapi_macros::versioned_type! { - pub enum TestingVersionProbeRequest { - V1 => v01::TestingVersionProbeRequest, - V2 => v02::TestingVersionProbeRequest, - } - pub enum TestingVersionProbeResponse { - V1 => v01::TestingVersionProbeResponse, - V2 => v02::TestingVersionProbeResponse, - } - pub enum TestingVersionProbeError { - V1 => v01::TestingVersionProbeError, - V2 => v02::TestingVersionProbeError, - } -} From 8c93e99db1c385c1190007916bf6d526e32f3625 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Tue, 30 Jun 2026 09:24:44 +0200 Subject: [PATCH 05/56] feat(truapi-platform): add host capability traits New crate defining the host syscall traits (storage, navigation, consent, permissions, ...) that host runtimes implement. Types are re-exported from truapi::versioned/v01 rather than redefined. --- Cargo.lock | 301 +++++++++++ rust/crates/truapi-platform/Cargo.toml | 17 + rust/crates/truapi-platform/README.md | 35 ++ rust/crates/truapi-platform/src/lib.rs | 522 ++++++++++++++++++++ rust/crates/truapi-platform/tests/bounds.rs | 100 ++++ 5 files changed, 975 insertions(+) create mode 100644 rust/crates/truapi-platform/Cargo.toml create mode 100644 rust/crates/truapi-platform/README.md create mode 100644 rust/crates/truapi-platform/src/lib.rs create mode 100644 rust/crates/truapi-platform/tests/bounds.rs diff --git a/Cargo.lock b/Cargo.lock index 515ec10f..7a2c5a54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,17 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "bitvec" version = "1.0.1" @@ -190,12 +201,32 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "funty" version = "2.0.0" @@ -308,6 +339,109 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "impl-trait-for-tuples" version = "0.2.3" @@ -365,6 +499,12 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "memchr" version = "2.8.0" @@ -405,12 +545,27 @@ dependencies = [ "syn", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -514,6 +669,18 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -531,12 +698,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tap" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -600,6 +788,18 @@ dependencies = [ "syn", ] +[[package]] +name = "truapi-platform" +version = "0.1.0" +dependencies = [ + "async-trait", + "derive_more", + "futures", + "parity-scale-codec", + "truapi", + "url", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -618,6 +818,24 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -648,6 +866,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "wyz" version = "0.5.1" @@ -657,6 +881,83 @@ dependencies = [ "tap", ] +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/rust/crates/truapi-platform/Cargo.toml b/rust/crates/truapi-platform/Cargo.toml new file mode 100644 index 00000000..dea02704 --- /dev/null +++ b/rust/crates/truapi-platform/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "truapi-platform" +version = "0.1.0" +edition.workspace = true +description = "Platform capability traits for TrUAPI host implementations" +license = "MIT" + +[dependencies] +truapi = { path = "../truapi" } +async-trait = "0.1" +derive_more = { version = "2", features = ["display"] } +futures = "0.3" +parity-scale-codec = { version = "3", features = ["derive"] } +url = "2" + +[lints.rust] +unsafe_code = "forbid" diff --git a/rust/crates/truapi-platform/README.md b/rust/crates/truapi-platform/README.md new file mode 100644 index 00000000..3605d440 --- /dev/null +++ b/rust/crates/truapi-platform/README.md @@ -0,0 +1,35 @@ +# truapi-platform + +Platform capability traits for TrUAPI host implementations. + +Each host (web/WASM, desktop, iOS/UniFFI, Android/UniFFI) implements these +traits to provide the native capabilities the shared Rust runtime cannot reach +directly. The dispatcher in `truapi-server` calls this surface while the Rust +runtime owns product account management, SSO signing, statement-store protocol +flows, permission state, and auth state transitions. + +## Type Imports + +Host-facing wire types are imported from `truapi::latest` by this crate and are +exposed through the trait signatures below. + +## Traits + +- `ProductStorage`: product-scoped key-value storage. +- `CoreStorage`: typed core-owned storage slots such as auth session, pairing + identity, and permission authorization state. +- `CoreAdmin`: host UI controls for logout, pairing cancellation, session-store + refresh, and permission administration. +- `Navigation`: open URLs in the system browser. +- `Notifications`: deliver and cancel push notifications. +- `Permissions`: prompt for device and remote authorizations. +- `Features`: report host feature support. +- `ChainProvider` / `JsonRpcConnection`: open JSON-RPC connections to chains. +- `AuthPresenter`: render core-owned auth state transitions. +- `UserConfirmation`: confirm signing, transaction, resource, alias, and + preimage actions before the core asks the paired wallet. +- `ThemeHost`: stream the host theme into the runtime. +- `PreimageHost`: submit and look up preimages through the host-selected backend. + +`Platform` is a blanket-implemented supertrait that combines the capability +traits above. diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs new file mode 100644 index 00000000..566c076f --- /dev/null +++ b/rust/crates/truapi-platform/src/lib.rs @@ -0,0 +1,522 @@ +//! Capability traits a TrUAPI host must implement. +//! +//! Each trait covers a single OS-primitive surface the Rust core cannot reach +//! from its own process (key-value persistence, URL launching, push +//! notifications, permission UI, chain RPC, host-selected preimage backends). +//! Account management, signing, and statement-store protocol flows live in the +//! Rust core itself and are not part of this trait set. +//! +//! Async capability traits use `async_trait` so the combined [`Platform`] +//! surface can be used as a trait object by the runtime. + +use futures::stream::BoxStream; +use parity_scale_codec::{Decode, Encode}; + +pub use async_trait::async_trait; + +use truapi::latest::{ + GenericError, HostDevicePermissionRequest, HostDevicePermissionResponse, + HostFeatureSupportedRequest, HostFeatureSupportedResponse, HostLocalStorageReadError, + HostNavigateToError, HostPushNotificationRequest, HostPushNotificationResponse, + HostRequestResourceAllocationRequest, HostSignPayloadRequest, + HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, + HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, NotificationId, + PreimageSubmitError, ProductAccountTxPayload, RemotePermissionRequest, + RemotePermissionResponse, ThemeVariant, +}; +use url::Url; + +/// Static runtime configuration supplied by the embedding host before the +/// core handles product-scoped calls. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeConfig { + /// Canonical product identifier used for account derivation. + pub product_id: String, + /// Host metadata shown by the wallet during SSO pairing. + pub host_info: HostInfo, + /// Platform metadata shown by the wallet during SSO pairing. + pub platform_info: PlatformInfo, + /// People-chain genesis hash used for statement-store SSO. + pub people_chain_genesis_hash: [u8; 32], + /// Deeplink URI scheme used in pairing QR payloads, without `://`. + pub pairing_deeplink_scheme: String, +} + +/// Host metadata shown by the wallet during SSO pairing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostInfo { + /// Host name shown by the wallet during SSO pairing. + pub name: String, + /// Optional host icon URL/CID shown by the wallet during SSO pairing. + pub icon: Option, + /// Optional host version shown by the wallet during SSO pairing. + pub version: Option, +} + +/// Platform metadata shown by the wallet during SSO pairing. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PlatformInfo { + /// Optional platform/browser name shown by the wallet during SSO pairing. + pub kind: Option, + /// Optional platform/browser version shown by the wallet during SSO pairing. + pub version: Option, +} + +impl RuntimeConfig { + /// Build a runtime config, validating fields whose representation cannot + /// be made invalid by Rust types alone. + pub fn new( + product_id: String, + host_info: HostInfo, + platform_info: PlatformInfo, + people_chain_genesis_hash: [u8; 32], + pairing_deeplink_scheme: String, + ) -> Result { + let config = Self { + product_id, + host_info, + platform_info, + people_chain_genesis_hash, + pairing_deeplink_scheme, + }; + config.validate()?; + Ok(config) + } + + fn validate(&self) -> Result<(), RuntimeConfigValidationError> { + require_non_empty("product_id", &self.product_id)?; + require_non_empty("host_info.name", &self.host_info.name)?; + require_non_empty("pairing_deeplink_scheme", &self.pairing_deeplink_scheme)?; + if self.pairing_deeplink_scheme.contains("://") { + return Err(RuntimeConfigValidationError::InvalidDeeplinkScheme { + scheme: self.pairing_deeplink_scheme.clone(), + }); + } + if let Some(icon) = &self.host_info.icon { + let parsed = + Url::parse(icon).map_err(|err| RuntimeConfigValidationError::InvalidHostIcon { + reason: err.to_string(), + })?; + if parsed.scheme() != "https" { + return Err(RuntimeConfigValidationError::InsecureHostIcon { + scheme: parsed.scheme().to_string(), + }); + } + } + Ok(()) + } +} + +fn require_non_empty(field: &'static str, value: &str) -> Result<(), RuntimeConfigValidationError> { + if value.trim().is_empty() { + return Err(RuntimeConfigValidationError::EmptyField { field }); + } + Ok(()) +} + +/// Runtime config validation error. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] +pub enum RuntimeConfigValidationError { + /// Required string field was empty or whitespace-only. + #[display("{field} must not be empty")] + EmptyField { + /// Field name. + field: &'static str, + }, + /// Host icon URL could not be parsed as an absolute URL. + #[display("host_info.icon must be an absolute HTTPS URL: {reason}")] + InvalidHostIcon { + /// Parse failure reason. + reason: String, + }, + /// Host icon URL used a non-HTTPS scheme. + #[display("host_info.icon must use https scheme, got {scheme:?}")] + InsecureHostIcon { + /// Actual URL scheme. + scheme: String, + }, + /// Pairing deeplink scheme included a URL separator. + #[display("pairing_deeplink_scheme must not include ://, got {scheme:?}")] + InvalidDeeplinkScheme { + /// Actual deeplink scheme value. + scheme: String, + }, +} + +impl std::error::Error for RuntimeConfigValidationError {} + +/// Product-scoped key-value storage. The platform namespaces keys so different +/// products cannot read each other's data. +#[async_trait] +pub trait ProductStorage: Send + Sync { + /// Read a value by key. + async fn read(&self, key: String) -> Result>, HostLocalStorageReadError>; + + /// Write a value to a key. + async fn write(&self, key: String, value: Vec) -> Result<(), HostLocalStorageReadError>; + + /// Clear a value at a key. + async fn clear(&self, key: String) -> Result<(), HostLocalStorageReadError>; +} + +/// Open URLs in the system browser. Input is already trimmed, categorized, +/// and (where needed) normalized by the core; the host implementation only +/// needs to hand the URL to the OS URL handler. +#[async_trait] +pub trait Navigation: Send + Sync { + /// Open the given URL in the system browser. + async fn navigate_to(&self, url: String) -> Result<(), HostNavigateToError>; +} + +/// Deliver push notifications. +#[async_trait] +pub trait Notifications: Send + Sync { + /// Schedule or immediately display the given notification and return the + /// host-assigned id. + async fn push_notification( + &self, + notification: HostPushNotificationRequest, + ) -> Result; + + /// Cancel a notification by id. Idempotent: cancelling an already-fired or + /// unknown id still returns `Ok(())`. + async fn cancel_notification(&self, id: NotificationId) -> Result<(), GenericError> { + let _ = id; + Ok(()) + } +} + +/// Permission prompts. v0.1 keeps device permissions (camera, mic, NFC, ...) +/// separate from remote permissions (domain access, chain submit, ...), so the +/// platform surface mirrors that split. +#[async_trait] +pub trait Permissions: Send + Sync { + /// Prompt the user for a device-level permission. + async fn device_permission( + &self, + request: HostDevicePermissionRequest, + ) -> Result; + + /// Prompt the user for a remote (product-scoped) permission bundle. + async fn remote_permission( + &self, + request: RemotePermissionRequest, + ) -> Result; +} + +/// Permission request whose authorization status can be inspected or updated +/// by host administration UI. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum PermissionAuthorizationRequest { + /// Device-level permission such as camera, microphone, or location. + Device(HostDevicePermissionRequest), + /// Remote/product-scoped permission such as chain submit or HTTP access. + Remote(RemotePermissionRequest), +} + +/// Authorization status for a permission request. +/// +/// `NotDetermined` means the core has no persisted answer and will prompt the +/// host the next time the product requests this permission. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum PermissionAuthorizationStatus { + /// No persisted authorization exists. + NotDetermined, + /// Access is denied. + Denied, + /// Access is authorized. + Authorized, +} + +/// Core-owned administration API exposed to host UI. +/// +/// Hosts call this surface to drive global runtime actions or inspect/update +/// core-owned state without going through a product-scoped TrUAPI request. +#[async_trait] +pub trait CoreAdmin: Send + Sync { + /// Best-effort logout/disconnect. Clears the active session and emits the + /// resulting auth state transition. + async fn disconnect_session(&self) -> Result<(), GenericError>; + + /// Cancel any in-flight pairing request. + fn cancel_pairing(&self); + + /// Notify the core that the host-global auth session slot may have + /// changed. The core re-reads storage and emits any resulting auth state. + fn notify_session_store_changed(&self); + + /// Read a stored permission authorization status without prompting. + async fn get_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + ) -> Result; + + /// Read stored permission authorization statuses without prompting. + /// + /// Results are returned in the same order as `requests`. + async fn get_permission_authorization_statuses( + &self, + requests: Vec, + ) -> Result, GenericError>; + + /// Update a stored permission authorization status. `NotDetermined` clears + /// the stored value so the next product request prompts again. + async fn set_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), GenericError>; +} + +/// Feature-support probing. The host answers whether it can service a given +/// capability (currently scoped to per-chain support). +#[async_trait] +pub trait Features: Send + Sync { + /// Report whether the requested feature is supported. + async fn feature_supported( + &self, + request: HostFeatureSupportedRequest, + ) -> Result; +} + +/// JSON-RPC provider factory for chain access. +/// +/// The platform provides a way to get a JSON-RPC connection for a given chain. +/// The server runtime manages the chainHead v1 state machine on top of this. +#[async_trait] +pub trait ChainProvider: Send + Sync { + /// Open a JSON-RPC connection for the chain identified by `genesis_hash`. + /// Drop the returned connection to disconnect. + async fn connect( + &self, + genesis_hash: Vec, + ) -> Result, GenericError>; +} + +/// A live JSON-RPC connection to a chain. +pub trait JsonRpcConnection: Send + Sync { + /// Send a JSON-RPC request string. + fn send(&self, request: String); + + /// Stream of JSON-RPC response strings. + fn responses(&self) -> BoxStream<'static, String>; + + /// Close the connection lease. + /// + /// Hosts may keep a shared underlying transport alive, but this handle + /// must stop receiving responses and release any per-caller resources. + fn close(&self); +} + +/// Core-owned host-private storage slots. Products never address these slots; +/// the host chooses the backing store for each slot. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum CoreStorageKey { + /// Opaque SSO/auth session blob. + AuthSession, + /// Pairing device identity used during SSO flows. + PairingDeviceIdentity, + /// Persisted authorization for one product-scoped permission request. + PermissionAuthorization { + /// Product whose permission decision is being stored. + product_id: String, + /// Permission request whose authorization is being stored. + request: PermissionAuthorizationRequest, + }, +} + +/// Host-private persistence for core-owned state. +#[async_trait] +pub trait CoreStorage: Send + Sync { + /// Read a core-owned value by typed slot. + async fn read_core_storage(&self, key: CoreStorageKey) + -> Result>, GenericError>; + + /// Write a core-owned value by typed slot. + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), GenericError>; + + /// Clear a core-owned value by typed slot. + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), GenericError>; +} + +/// 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)] +pub struct SessionUiInfo { + /// 32-byte sr25519 root public key of the active session. + pub public_key: [u8; 32], + /// Wallet identity account id used for People-chain username lookup. + pub identity_account_id: Option<[u8; 32]>, + /// Short username from the People-chain identity record. + pub lite_username: Option, + /// Fully qualified username from the People-chain identity record. + pub full_username: Option, +} + +/// 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)] +pub enum AuthState { + /// No active session and no login in progress. + #[default] + Disconnected, + /// A login is in progress: present the pairing deeplink/QR. Leave this + /// state only on a subsequent emission (connected, failed, or + /// disconnected after cancellation). + Pairing { + /// Wallet pairing deeplink to render as a QR code or open directly. + deeplink: String, + }, + /// A session is active. + Connected(SessionUiInfo), + /// The last login attempt failed; show the reason and offer a retry. + LoginFailed { + /// Human-readable failure reason. + reason: String, + }, +} + +/// Host auth UI driven by core-owned [`AuthState`] transitions. +pub trait AuthPresenter: Send + Sync { + /// Observe an auth state change. Emitted only when the state actually + /// changes, in transition order. Default is a no-op for hosts that + /// render no auth UI. + fn auth_state_changed(&self, state: AuthState) { + let _ = state; + } +} + +/// Review shown before a sign-payload request is sent to the paired wallet. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SignPayloadReview { + /// Product-account signing request. + Product(HostSignPayloadRequest), + /// Legacy-account signing request. + LegacyAccount(HostSignPayloadWithLegacyAccountRequest), +} + +/// Review shown before a sign-raw request is sent to the paired wallet. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SignRawReview { + /// Product-account raw signing request. + Product(HostSignRawRequest), + /// Legacy-account raw signing request. + LegacyAccount(HostSignRawWithLegacyAccountRequest), +} + +/// Review shown before a transaction-creation request is sent to the paired wallet. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum CreateTransactionReview { + /// Product-account transaction request. + Product(ProductAccountTxPayload), + /// Legacy-account transaction request. + LegacyAccount(LegacyAccountTxPayload), +} + +/// Review shown before a product asks to alias another product account. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct AccountAliasReview { + /// Product currently handling the request. + pub requesting_product_id: String, + /// Product whose account is being requested. + pub target_product_id: String, +} + +/// Review shown before a preimage is submitted. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct PreimageSubmitReview { + /// Size of the preimage in bytes. + pub size: u64, +} + +/// Review shown before a user-confirmed core action continues. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum UserConfirmationReview { + /// Sign a SCALE payload with a product or legacy account. + SignPayload(SignPayloadReview), + /// Sign raw bytes with a product or legacy account. + SignRaw(SignRawReview), + /// Create a transaction with a product or legacy account. + CreateTransaction(CreateTransactionReview), + /// Allow a product to request another product account alias. + AccountAlias(AccountAliasReview), + /// Allocate resources for the requesting product. + ResourceAllocation(HostRequestResourceAllocationRequest), + /// Submit a preimage to the host-selected backend. + PreimageSubmit(PreimageSubmitReview), +} + +/// Local user confirmation UI for session-channel operations. +#[async_trait] +pub trait UserConfirmation: Send + Sync { + /// Confirm a reviewed action before the core asks the SSO peer. + async fn confirm_user_action( + &self, + review: UserConfirmationReview, + ) -> Result { + let _ = review; + Ok(false) + } +} + +/// Host theme source. +pub trait ThemeHost: Send + Sync { + /// Emits current theme immediately, then future changes. + fn subscribe_theme(&self) -> BoxStream<'static, Result>; +} + +/// Host preimage backend. The core owns wire mapping and subscription +/// lifecycle; the host owns the selected backend. +#[async_trait] +pub trait PreimageHost: Send + Sync { + /// Submit the preimage and return its key. + async fn submit_preimage(&self, value: Vec) -> Result, PreimageSubmitError> { + let _ = value; + Err(PreimageSubmitError::Unknown { + reason: "submitPreimage callback not provided by host".to_string(), + }) + } + + /// Emits current value/miss immediately, then future updates. + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, GenericError>>; +} + +/// Combined platform interface. A host must provide all capability traits. +pub trait Platform: + Navigation + + Notifications + + Permissions + + Features + + ProductStorage + + CoreStorage + + ChainProvider + + AuthPresenter + + UserConfirmation + + ThemeHost + + PreimageHost +{ +} + +impl Platform for T where + T: Navigation + + Notifications + + Permissions + + Features + + ProductStorage + + CoreStorage + + ChainProvider + + AuthPresenter + + UserConfirmation + + ThemeHost + + PreimageHost +{ +} diff --git a/rust/crates/truapi-platform/tests/bounds.rs b/rust/crates/truapi-platform/tests/bounds.rs new file mode 100644 index 00000000..5d8c8a43 --- /dev/null +++ b/rust/crates/truapi-platform/tests/bounds.rs @@ -0,0 +1,100 @@ +//! Compile-time check that the `Platform` super-trait composes its capability +//! traits with `Send + Sync + 'static` bounds and remains object-safe via +//! `async_trait`. + +use truapi_platform::{ + HostInfo, Platform, PlatformInfo, RuntimeConfig, RuntimeConfigValidationError, +}; + +fn _assert_platform_bounds() {} + +fn _assert_platform_object_safe(_: &(dyn Platform + 'static)) {} + +#[test] +fn runtime_config_validation_cases() { + struct TestCase { + name: &'static str, + product_id: &'static str, + host_name: &'static str, + host_icon: Option<&'static str>, + pairing_deeplink_scheme: &'static str, + expected: Result<(), RuntimeConfigValidationError>, + } + + let cases = vec![ + TestCase { + name: "accepts HTTPS host icon", + product_id: "dotli.dot", + host_name: "Polkadot Web", + host_icon: Some("https://dot.li/dotli.png"), + pairing_deeplink_scheme: "polkadotapp", + expected: Ok(()), + }, + TestCase { + name: "rejects empty product id", + product_id: "", + host_name: "Polkadot Web", + host_icon: Some("https://dot.li/dotli.png"), + pairing_deeplink_scheme: "polkadotapp", + expected: Err(RuntimeConfigValidationError::EmptyField { + field: "product_id", + }), + }, + TestCase { + name: "rejects empty host name", + product_id: "dotli.dot", + host_name: " ", + host_icon: Some("https://dot.li/dotli.png"), + pairing_deeplink_scheme: "polkadotapp", + expected: Err(RuntimeConfigValidationError::EmptyField { + field: "host_info.name", + }), + }, + TestCase { + name: "rejects relative host icon", + product_id: "dotli.dot", + host_name: "Polkadot Web", + host_icon: Some("/dotli.png"), + pairing_deeplink_scheme: "polkadotapp", + expected: Err(RuntimeConfigValidationError::InvalidHostIcon { + reason: "relative URL without a base".to_string(), + }), + }, + TestCase { + name: "rejects non-HTTPS host icon", + product_id: "dotli.dot", + host_name: "Polkadot Web", + host_icon: Some("http://localhost:3000/dotli.png"), + pairing_deeplink_scheme: "polkadotapp", + expected: Err(RuntimeConfigValidationError::InsecureHostIcon { + scheme: "http".to_string(), + }), + }, + TestCase { + name: "rejects malformed deeplink scheme", + product_id: "dotli.dot", + host_name: "Polkadot Web", + host_icon: Some("https://dot.li/dotli.png"), + pairing_deeplink_scheme: "polkadotapp://", + expected: Err(RuntimeConfigValidationError::InvalidDeeplinkScheme { + scheme: "polkadotapp://".to_string(), + }), + }, + ]; + + for case in cases { + let result = RuntimeConfig::new( + case.product_id.to_string(), + HostInfo { + name: case.host_name.to_string(), + icon: case.host_icon.map(str::to_string), + version: None, + }, + PlatformInfo::default(), + [0xa2; 32], + case.pairing_deeplink_scheme.to_string(), + ) + .map(|_| ()); + assert_eq!(result, case.expected, "{}", case.name); + } +} From 97d0b79b13ead8fe33791c36e655fa46e011fa1f Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 2 Jul 2026 10:51:19 +0200 Subject: [PATCH 06/56] fixup! feat(truapi-platform): add host capability traits --- rust/crates/truapi-platform/README.md | 10 +++++++--- rust/crates/truapi-platform/src/lib.rs | 12 +++++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/rust/crates/truapi-platform/README.md b/rust/crates/truapi-platform/README.md index 3605d440..25067c05 100644 --- a/rust/crates/truapi-platform/README.md +++ b/rust/crates/truapi-platform/README.md @@ -13,13 +13,11 @@ flows, permission state, and auth state transitions. Host-facing wire types are imported from `truapi::latest` by this crate and are exposed through the trait signatures below. -## Traits +## Host Callback Traits - `ProductStorage`: product-scoped key-value storage. - `CoreStorage`: typed core-owned storage slots such as auth session, pairing identity, and permission authorization state. -- `CoreAdmin`: host UI controls for logout, pairing cancellation, session-store - refresh, and permission administration. - `Navigation`: open URLs in the system browser. - `Notifications`: deliver and cancel push notifications. - `Permissions`: prompt for device and remote authorizations. @@ -33,3 +31,9 @@ exposed through the trait signatures below. `Platform` is a blanket-implemented supertrait that combines the capability traits above. + +## Core-Owned Admin API + +`CoreAdmin` is not part of the host-provided `Platform` callback surface. It is +the core-owned control API exposed to host UI for logout, pairing cancellation, +session-store refresh, and permission administration. diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 566c076f..7f9d1ebb 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -28,6 +28,7 @@ use url::Url; /// Static runtime configuration supplied by the embedding host before the /// core handles product-scoped calls. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeConfig { /// Canonical product identifier used for account derivation. @@ -47,7 +48,7 @@ pub struct RuntimeConfig { pub struct HostInfo { /// Host name shown by the wallet during SSO pairing. pub name: String, - /// Optional host icon URL/CID shown by the wallet during SSO pairing. + /// Optional absolute HTTPS host icon URL shown by the wallet during SSO pairing. pub icon: Option, /// Optional host version shown by the wallet during SSO pairing. pub version: Option, @@ -123,7 +124,7 @@ pub enum RuntimeConfigValidationError { /// Field name. field: &'static str, }, - /// Host icon URL could not be parsed as an absolute URL. + /// Host icon URL could not be parsed as an absolute HTTPS URL. #[display("host_info.icon must be an absolute HTTPS URL: {reason}")] InvalidHostIcon { /// Parse failure reason. @@ -289,7 +290,7 @@ pub trait ChainProvider: Send + Sync { /// Drop the returned connection to disconnect. async fn connect( &self, - genesis_hash: Vec, + genesis_hash: [u8; 32], ) -> Result, GenericError>; } @@ -459,10 +460,7 @@ pub trait UserConfirmation: Send + Sync { async fn confirm_user_action( &self, review: UserConfirmationReview, - ) -> Result { - let _ = review; - Ok(false) - } + ) -> Result; } /// Host theme source. From d2e36742c215138ef75154b0fc1c37aef27456e2 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 17:06:37 +0200 Subject: [PATCH 07/56] fixup! feat(truapi-platform): add host capability traits --- Cargo.lock | 25 +++ rust/crates/truapi-platform/Cargo.toml | 1 + rust/crates/truapi-platform/src/lib.rs | 208 ++++++++++++++------ rust/crates/truapi-platform/tests/bounds.rs | 101 ++++++---- 4 files changed, 247 insertions(+), 88 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a2c5a54..5074af2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -725,6 +725,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -797,6 +812,7 @@ dependencies = [ "futures", "parity-scale-codec", "truapi", + "unicode-normalization", "url", ] @@ -806,6 +822,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.2" diff --git a/rust/crates/truapi-platform/Cargo.toml b/rust/crates/truapi-platform/Cargo.toml index dea02704..ee4f101c 100644 --- a/rust/crates/truapi-platform/Cargo.toml +++ b/rust/crates/truapi-platform/Cargo.toml @@ -11,6 +11,7 @@ async-trait = "0.1" derive_more = { version = "2", features = ["display"] } futures = "0.3" parity-scale-codec = { version = "3", features = ["derive"] } +unicode-normalization = "0.1" url = "2" [lints.rust] diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 7f9d1ebb..9b8ccac5 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -11,6 +11,7 @@ use futures::stream::BoxStream; use parity_scale_codec::{Decode, Encode}; +use unicode_normalization::UnicodeNormalization; pub use async_trait::async_trait; @@ -26,74 +27,81 @@ use truapi::latest::{ }; use url::Url; -/// Static runtime configuration supplied by the embedding host before the -/// core handles product-scoped calls. +/// Role-neutral runtime configuration supplied by the embedding host. #[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] -pub struct RuntimeConfig { - /// Canonical product identifier used for account derivation. - pub product_id: String, - /// Host metadata shown by the wallet during SSO pairing. +pub struct HostRuntimeConfig { + /// Host metadata. pub host_info: HostInfo, - /// Platform metadata shown by the wallet during SSO pairing. + /// Platform metadata. pub platform_info: PlatformInfo, +} + +/// Pairing-host runtime configuration supplied by the embedding host. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairingHostConfig { + /// Host identity shown to the signing host during pairing. + pub host: HostRuntimeConfig, /// People-chain genesis hash used for statement-store SSO. pub people_chain_genesis_hash: [u8; 32], /// Deeplink URI scheme used in pairing QR payloads, without `://`. pub pairing_deeplink_scheme: String, } -/// Host metadata shown by the wallet during SSO pairing. +/// Signing-host runtime configuration supplied by the embedding host. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SigningHostConfig { + /// Host identity. Not read by the local-signing paths yet; retained for + /// parity with [`PairingHostConfig`] and for the future signer-side SSO + /// responder, which advertises host identity in handshake responses. + pub host: HostRuntimeConfig, + /// People-chain genesis hash used for statement-store product calls. + pub people_chain_genesis_hash: [u8; 32], +} + +/// Product identity attached to one product-facing TrUAPI connection. +/// +/// A host may create multiple product runtimes from the same long-lived host +/// runtime, each with its own product context. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProductContext { + /// Product identifier used for account derivation and product-scoped + /// storage/permission namespaces. + pub product_id: String, +} + +/// Host metadata. #[derive(Debug, Clone, PartialEq, Eq)] pub struct HostInfo { - /// Host name shown by the wallet during SSO pairing. + /// Host name. pub name: String, - /// Optional absolute HTTPS host icon URL shown by the wallet during SSO pairing. + /// Optional absolute HTTPS host icon URL. pub icon: Option, - /// Optional host version shown by the wallet during SSO pairing. + /// Optional host version. pub version: Option, } -/// Platform metadata shown by the wallet during SSO pairing. +/// Platform metadata. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PlatformInfo { - /// Optional platform/browser name shown by the wallet during SSO pairing. + /// Optional platform/browser name. pub kind: Option, - /// Optional platform/browser version shown by the wallet during SSO pairing. + /// Optional platform/browser version. pub version: Option, } -impl RuntimeConfig { - /// Build a runtime config, validating fields whose representation cannot - /// be made invalid by Rust types alone. +impl HostRuntimeConfig { + /// Build a role-neutral host runtime config, validating fields whose + /// representation cannot be made invalid by Rust types alone. pub fn new( - product_id: String, host_info: HostInfo, platform_info: PlatformInfo, - people_chain_genesis_hash: [u8; 32], - pairing_deeplink_scheme: String, ) -> Result { - let config = Self { - product_id, - host_info, - platform_info, - people_chain_genesis_hash, - pairing_deeplink_scheme, - }; - config.validate()?; - Ok(config) - } - - fn validate(&self) -> Result<(), RuntimeConfigValidationError> { - require_non_empty("product_id", &self.product_id)?; - require_non_empty("host_info.name", &self.host_info.name)?; - require_non_empty("pairing_deeplink_scheme", &self.pairing_deeplink_scheme)?; - if self.pairing_deeplink_scheme.contains("://") { - return Err(RuntimeConfigValidationError::InvalidDeeplinkScheme { - scheme: self.pairing_deeplink_scheme.clone(), - }); - } - if let Some(icon) = &self.host_info.icon { + require_non_empty("host_info.name", &host_info.name)?; + if let Some(icon) = &host_info.icon { let parsed = Url::parse(icon).map_err(|err| RuntimeConfigValidationError::InvalidHostIcon { reason: err.to_string(), @@ -104,7 +112,83 @@ impl RuntimeConfig { }); } } - Ok(()) + Ok(Self { + host_info, + platform_info, + }) + } +} + +impl PairingHostConfig { + /// Build a pairing-host runtime config, validating fields whose + /// representation cannot be made invalid by Rust types alone. + pub fn new( + host_info: HostInfo, + platform_info: PlatformInfo, + people_chain_genesis_hash: [u8; 32], + pairing_deeplink_scheme: String, + ) -> Result { + require_non_empty("pairing_deeplink_scheme", &pairing_deeplink_scheme)?; + if pairing_deeplink_scheme.contains("://") { + return Err(RuntimeConfigValidationError::InvalidDeeplinkScheme { + scheme: pairing_deeplink_scheme, + }); + } + let config = Self { + host: HostRuntimeConfig::new(host_info, platform_info)?, + people_chain_genesis_hash, + pairing_deeplink_scheme, + }; + Ok(config) + } +} + +impl SigningHostConfig { + /// Build a signing-host runtime config, validating fields whose + /// representation cannot be made invalid by Rust types alone. + pub fn new( + host_info: HostInfo, + platform_info: PlatformInfo, + people_chain_genesis_hash: [u8; 32], + ) -> Result { + Ok(Self { + host: HostRuntimeConfig::new(host_info, platform_info)?, + people_chain_genesis_hash, + }) + } +} + +impl ProductContext { + /// Build a product context, validating fields whose representation cannot + /// be made invalid by Rust types alone. + pub fn new(product_id: String) -> Result { + Ok(Self { + product_id: normalize_product_identifier(&product_id)?, + }) + } +} + +/// Whether `identifier` is a product scope the core is allowed to derive for. +pub fn is_product_identifier(identifier: &str) -> bool { + normalize_product_identifier(identifier).is_ok() +} + +/// Normalize product identifiers before derivation and policy checks. +pub fn normalize_product_identifier( + product_id: &str, +) -> Result { + let trimmed = product_id.trim(); + require_non_empty("product_id", trimmed)?; + let normalized = trimmed.nfc().collect::().to_lowercase(); + if normalized.ends_with(".dot") + || normalized == "localhost" + || normalized.starts_with("localhost:") + { + Ok(normalized) + } else { + Err(RuntimeConfigValidationError::InvalidProductId { + product_id: product_id.to_string(), + }) } } @@ -142,12 +226,20 @@ pub enum RuntimeConfigValidationError { /// Actual deeplink scheme value. scheme: String, }, + /// Product id was not a `.dot` or localhost product identifier. + #[display("product_id must be a .dot or localhost product identifier, got {product_id:?}")] + InvalidProductId { + /// Actual product id value. + product_id: String, + }, } -impl std::error::Error for RuntimeConfigValidationError {} +impl core::error::Error for RuntimeConfigValidationError {} -/// Product-scoped key-value storage. The platform namespaces keys so different -/// products cannot read each other's data. +/// Product-scoped key-value storage. +/// +/// The core namespaces product keys before calling this trait. Host +/// implementations should treat `key` as an opaque OS-style storage key. #[async_trait] pub trait ProductStorage: Send + Sync { /// Read a value by key. @@ -187,9 +279,9 @@ pub trait Notifications: Send + Sync { } } -/// Permission prompts. v0.1 keeps device permissions (camera, mic, NFC, ...) -/// separate from remote permissions (domain access, chain submit, ...), so the -/// platform surface mirrors that split. +/// Permission prompts. Device permissions (camera, mic, NFC, ...) are separate +/// from remote permissions (domain access, chain submit, ...), so the platform +/// surface mirrors that split. #[async_trait] pub trait Permissions: Send + Sync { /// Prompt the user for a device-level permission. @@ -239,13 +331,6 @@ pub trait CoreAdmin: Send + Sync { /// resulting auth state transition. async fn disconnect_session(&self) -> Result<(), GenericError>; - /// Cancel any in-flight pairing request. - fn cancel_pairing(&self); - - /// Notify the core that the host-global auth session slot may have - /// changed. The core re-reads storage and emits any resulting auth state. - fn notify_session_store_changed(&self); - /// Read a stored permission authorization status without prompting. async fn get_permission_authorization_status( &self, @@ -269,6 +354,19 @@ pub trait CoreAdmin: Send + Sync { ) -> Result<(), GenericError>; } +/// Pairing-host-only administration API exposed to host UI. +#[async_trait] +pub trait PairingHostAdmin: Send + Sync { + /// Cancel any in-flight pairing request. + fn cancel_pairing(&self); + + /// Notify the core that the persisted auth-session blob may have changed. + /// + /// The host owns persistence and change detection. The pairing core owns + /// decoding that blob into live `SessionState` / `AuthState`. + fn notify_session_store_changed(&self); +} + /// Feature-support probing. The host answers whether it can service a given /// capability (currently scoped to per-chain support). #[async_trait] diff --git a/rust/crates/truapi-platform/tests/bounds.rs b/rust/crates/truapi-platform/tests/bounds.rs index 5d8c8a43..5a1733c4 100644 --- a/rust/crates/truapi-platform/tests/bounds.rs +++ b/rust/crates/truapi-platform/tests/bounds.rs @@ -3,7 +3,8 @@ //! `async_trait`. use truapi_platform::{ - HostInfo, Platform, PlatformInfo, RuntimeConfig, RuntimeConfigValidationError, + HostInfo, HostRuntimeConfig, PairingHostConfig, Platform, PlatformInfo, ProductContext, + RuntimeConfigValidationError, }; fn _assert_platform_bounds() {} @@ -14,77 +15,80 @@ fn _assert_platform_object_safe(_: &(dyn Platform + 'static)) {} fn runtime_config_validation_cases() { struct TestCase { name: &'static str, - product_id: &'static str, host_name: &'static str, host_icon: Option<&'static str>, - pairing_deeplink_scheme: &'static str, expected: Result<(), RuntimeConfigValidationError>, } let cases = vec![ TestCase { name: "accepts HTTPS host icon", - product_id: "dotli.dot", host_name: "Polkadot Web", host_icon: Some("https://dot.li/dotli.png"), - pairing_deeplink_scheme: "polkadotapp", expected: Ok(()), }, - TestCase { - name: "rejects empty product id", - product_id: "", - host_name: "Polkadot Web", - host_icon: Some("https://dot.li/dotli.png"), - pairing_deeplink_scheme: "polkadotapp", - expected: Err(RuntimeConfigValidationError::EmptyField { - field: "product_id", - }), - }, TestCase { name: "rejects empty host name", - product_id: "dotli.dot", host_name: " ", host_icon: Some("https://dot.li/dotli.png"), - pairing_deeplink_scheme: "polkadotapp", expected: Err(RuntimeConfigValidationError::EmptyField { field: "host_info.name", }), }, TestCase { name: "rejects relative host icon", - product_id: "dotli.dot", host_name: "Polkadot Web", host_icon: Some("/dotli.png"), - pairing_deeplink_scheme: "polkadotapp", expected: Err(RuntimeConfigValidationError::InvalidHostIcon { reason: "relative URL without a base".to_string(), }), }, TestCase { name: "rejects non-HTTPS host icon", - product_id: "dotli.dot", host_name: "Polkadot Web", host_icon: Some("http://localhost:3000/dotli.png"), - pairing_deeplink_scheme: "polkadotapp", expected: Err(RuntimeConfigValidationError::InsecureHostIcon { scheme: "http".to_string(), }), }, - TestCase { - name: "rejects malformed deeplink scheme", - product_id: "dotli.dot", - host_name: "Polkadot Web", - host_icon: Some("https://dot.li/dotli.png"), - pairing_deeplink_scheme: "polkadotapp://", - expected: Err(RuntimeConfigValidationError::InvalidDeeplinkScheme { - scheme: "polkadotapp://".to_string(), - }), - }, ]; for case in cases { - let result = RuntimeConfig::new( - case.product_id.to_string(), + let result = HostRuntimeConfig::new( + HostInfo { + name: case.host_name.to_string(), + icon: case.host_icon.map(str::to_string), + version: None, + }, + PlatformInfo::default(), + ) + .map(|_| ()); + assert_eq!(result, case.expected, "{}", case.name); + } +} + +#[test] +fn pairing_config_validation_cases() { + struct TestCase { + name: &'static str, + host_name: &'static str, + host_icon: Option<&'static str>, + pairing_deeplink_scheme: &'static str, + expected: Result<(), RuntimeConfigValidationError>, + } + + let cases = vec![TestCase { + name: "rejects malformed deeplink scheme", + host_name: "Polkadot Web", + host_icon: Some("https://dot.li/dotli.png"), + pairing_deeplink_scheme: "polkadotapp://", + expected: Err(RuntimeConfigValidationError::InvalidDeeplinkScheme { + scheme: "polkadotapp://".to_string(), + }), + }]; + + for case in cases { + let result = PairingHostConfig::new( HostInfo { name: case.host_name.to_string(), icon: case.host_icon.map(str::to_string), @@ -98,3 +102,34 @@ fn runtime_config_validation_cases() { assert_eq!(result, case.expected, "{}", case.name); } } + +#[test] +fn product_context_validation_cases() { + let dotli = ProductContext::new("Dotli.DOT".to_string()).expect("dot product id is valid"); + assert_eq!(dotli.product_id, "dotli.dot"); + + let localhost = + ProductContext::new(" localhost:3000 ".to_string()).expect("localhost product id is valid"); + assert_eq!(localhost.product_id, "localhost:3000"); + + assert_eq!( + ProductContext::new("localhost".to_string()).map(|context| context.product_id), + Ok("localhost".to_string()) + ); + assert_eq!( + ProductContext::new("dotli.dot".to_string()).map(|_| ()), + Ok(()) + ); + assert_eq!( + ProductContext::new("example.com".to_string()).map(|_| ()), + Err(RuntimeConfigValidationError::InvalidProductId { + product_id: "example.com".to_string(), + }) + ); + assert_eq!( + ProductContext::new(" ".to_string()).map(|_| ()), + Err(RuntimeConfigValidationError::EmptyField { + field: "product_id", + }) + ); +} From c6f888c4b02a80b925a7d00cfb0510c1d9845d9e Mon Sep 17 00:00:00 2001 From: pgherveou Date: Tue, 30 Jun 2026 09:24:47 +0200 Subject: [PATCH 08/56] feat(truapi-codegen): emit Rust dispatcher, wire table, and host callbacks Extends the rustdoc-JSON code generator to emit the Rust dispatcher and wire table consumed by truapi-server, plus the TS host-callbacks adapter. Golden tests pin the emitted shapes. --- Cargo.lock | 90 + Cargo.toml | 1 + js/packages/truapi/src/client.test.ts | 140 +- rust/crates/truapi-codegen/.gitignore | 2 + rust/crates/truapi-codegen/Cargo.toml | 3 + rust/crates/truapi-codegen/src/main.rs | 63 + rust/crates/truapi-codegen/src/platform.rs | 690 ++++++ rust/crates/truapi-codegen/src/rust.rs | 604 +++++ .../truapi-codegen/src/rust/dispatcher.rs | 740 ++++++ .../truapi-codegen/src/rust/wire_table.rs | 303 +++ rust/crates/truapi-codegen/src/rustdoc.rs | 84 +- rust/crates/truapi-codegen/src/ts.rs | 382 ++- .../truapi-codegen/src/ts/host_callbacks.rs | 1483 +++++++++++ .../truapi-codegen/src/ts/playground.rs | 4 +- .../truapi-codegen/tests/golden/dispatcher.rs | 1913 +++++++++++++++ .../tests/golden/host-callbacks-adapter.ts | 78 + .../tests/golden/host-callbacks.ts | 497 ++++ .../truapi-codegen/tests/golden/wire_table.rs | 746 ++++++ .../tests/golden/worker-callbacks.ts | 117 + .../truapi-codegen/tests/golden_rust_emit.rs | 286 +++ .../truapi-server/src/generated/dispatcher.rs | 2182 +++++++++++++++++ .../crates/truapi-server/src/generated/mod.rs | 4 + .../truapi-server/src/generated/wire_table.rs | 746 ++++++ scripts/codegen.sh | 14 +- 24 files changed, 11097 insertions(+), 75 deletions(-) create mode 100644 rust/crates/truapi-codegen/.gitignore create mode 100644 rust/crates/truapi-codegen/src/platform.rs create mode 100644 rust/crates/truapi-codegen/src/rust.rs create mode 100644 rust/crates/truapi-codegen/src/rust/dispatcher.rs create mode 100644 rust/crates/truapi-codegen/src/rust/wire_table.rs create mode 100644 rust/crates/truapi-codegen/src/ts/host_callbacks.rs create mode 100644 rust/crates/truapi-codegen/tests/golden/dispatcher.rs create mode 100644 rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts create mode 100644 rust/crates/truapi-codegen/tests/golden/host-callbacks.ts create mode 100644 rust/crates/truapi-codegen/tests/golden/wire_table.rs create mode 100644 rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts create mode 100644 rust/crates/truapi-codegen/tests/golden_rust_emit.rs create mode 100644 rust/crates/truapi-server/src/generated/dispatcher.rs create mode 100644 rust/crates/truapi-server/src/generated/mod.rs create mode 100644 rust/crates/truapi-server/src/generated/wire_table.rs diff --git a/Cargo.lock b/Cargo.lock index 5074af2e..be16e446 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,6 +75,12 @@ dependencies = [ "syn", ] +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + [[package]] name = "bitvec" version = "1.0.1" @@ -93,6 +99,12 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.6.1" @@ -218,6 +230,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -321,6 +349,17 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "hashbrown" version = "0.17.0" @@ -499,6 +538,18 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -511,6 +562,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -593,6 +650,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -608,6 +671,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -715,6 +791,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -791,6 +880,7 @@ dependencies = [ "indoc", "serde", "serde_json", + "tempfile", "truapi", ] diff --git a/Cargo.toml b/Cargo.toml index 042aff82..d657d267 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "2" members = ["rust/crates/*"] +exclude = ["rust/crates/truapi-server"] [workspace.package] edition = "2024" diff --git a/js/packages/truapi/src/client.test.ts b/js/packages/truapi/src/client.test.ts index 3ab213ba..5e1db02d 100644 --- a/js/packages/truapi/src/client.test.ts +++ b/js/packages/truapi/src/client.test.ts @@ -2,12 +2,16 @@ import type { Result } from "neverthrow"; import { describe, expect, it } from "bun:test"; import { createTransport } from "./client.js"; -import { indexedTaggedUnion, Result as ScaleResult, str, _void } from "./scale.js"; +import { CallError, indexedTaggedUnion, Result as ScaleResult, str, _void } from "./scale.js"; +import type { Codec } from "./scale.js"; import { createClient, SubscriptionError } from "./generated/client.js"; import * as T from "./generated/types.js"; import * as W from "./generated/wire-table.js"; import { encodeWireMessage } from "./transport.js"; +/** Wrap a codec in the `{ V1: [0, codec] }` indexed-tagged-union envelope. */ +const versionedV1 = (codec: Codec) => indexedTaggedUnion({ V1: [0, codec] }); + function toHex(u: Uint8Array): string { return Array.from(u) .map((b) => b.toString(16).padStart(2, "0")) @@ -56,9 +60,34 @@ function providerFixture() { /** Encode a V1 host-handshake response result payload. */ function handshakeResponsePayload(value: { success: true; value: undefined }): Uint8Array { - return indexedTaggedUnion({ - V1: [0, ScaleResult(_void, T.HostHandshakeError)], - }).enc({ tag: "V1", value }); + return versionedV1(ScaleResult(_void, CallError(T.VersionedHostHandshakeError))).enc({ + tag: "V1", + value, + }); +} + +function accountGetResponsePayload( + value: + | { + success: true; + value: T.HostAccountGetResponse; + } + | { + success: false; + value: { tag: "Domain"; value: T.VersionedHostAccountGetError }; + }, +): Uint8Array { + return versionedV1( + ScaleResult(T.HostAccountGetResponse, CallError(T.VersionedHostAccountGetError)), + ).enc({ tag: "V1", value }); +} + +/** Encode a raw testing echo error response payload. */ +function testingEchoErrorPayload(reason: string): Uint8Array { + return ScaleResult(_void, CallError(T.V01TestingVersionProbeError)).enc({ + success: false, + value: { tag: "HostFailure", value: { reason } }, + }); } describe("generated client transport", () => { @@ -88,6 +117,29 @@ describe("generated client transport", () => { expect(toHex(fixture.sent[0])).toBe(toHex(expectedFrame)); }); + it("uses the latest generated request version for testing probes", () => { + const fixture = providerFixture(); + const transport = createTransport(fixture.provider); + const client = createClient(transport); + + const request = { + message: "hello from test", + marker: 42, + }; + void client.testing.versionProbe(request); + + const expectedPayload = T.VersionedTestingVersionProbeRequest.enc({ + tag: "V2", + value: request, + }); + const expectedFrame = new Uint8Array(str.enc("p:1").length + 1 + expectedPayload.length); + expectedFrame.set(str.enc("p:1"), 0); + expectedFrame[str.enc("p:1").length] = W.TESTING_VERSION_PROBE.request; + expectedFrame.set(expectedPayload, str.enc("p:1").length + 1); + + expect(toHex(fixture.sent[0])).toBe(toHex(expectedFrame)); + }); + it("uses the transport codec version for generated handshake calls", () => { const fixture = providerFixture(); const transport = createTransport(fixture.provider); @@ -129,6 +181,63 @@ describe("generated client transport", () => { expect(result.isOk()).toBe(true); }); + it("decodes request domain errors from the versioned response envelope", async () => { + const fixture = providerFixture(); + const transport = createTransport(fixture.provider); + const client = createClient(transport); + + const response = client.account.getAccount({ + productAccountId: { dotNsIdentifier: "foo", derivationIndex: 0 }, + }); + const reason = { tag: "V1", value: { tag: "NotConnected", value: undefined } } as const; + const frame = unwrap( + encodeWireMessage({ + requestId: "p:1", + payload: { + id: W.ACCOUNT_GET_ACCOUNT.response, + value: accountGetResponsePayload({ + success: false, + value: { tag: "Domain", value: reason }, + }), + }, + }), + "encode account_get error response", + ); + fixture.receive(frame); + + const result = await response; + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toEqual({ tag: "Domain", value: reason }); + }); + + it("returns framework call errors as typed Err values", async () => { + const fixture = providerFixture(); + const transport = createTransport(fixture.provider); + const client = createClient(transport); + + const response = client.testing.echoError({ + error: { tag: "HostFailure", value: { reason: "forced by test" } }, + }); + const frame = unwrap( + encodeWireMessage({ + requestId: "p:1", + payload: { + id: W.TESTING_ECHO_ERROR.response, + value: testingEchoErrorPayload("forced by test"), + }, + }), + "encode testing framework error response", + ); + fixture.receive(frame); + + const result = await response; + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toEqual({ + tag: "HostFailure", + value: { reason: "forced by test" }, + }); + }); + it("auto-responds to an inbound handshake with the versioned-result shape", () => { const fixture = providerFixture(); createTransport(fixture.provider); @@ -225,14 +334,18 @@ describe("generated client transport", () => { }); const reason = { tag: "PermissionDenied", value: undefined } as const; + const callError = { + tag: "Domain", + value: { tag: "V1", value: reason }, + } as const; const frame = unwrap( encodeWireMessage({ requestId: sub.subscriptionId, payload: { id: W.PAYMENT_BALANCE_SUBSCRIBE.interrupt, - value: T.VersionedHostPaymentBalanceSubscribeError.enc({ + value: versionedV1(CallError(T.VersionedHostPaymentBalanceSubscribeError)).enc({ tag: "V1", - value: reason, + value: callError, }), }, }), @@ -243,7 +356,7 @@ describe("generated client transport", () => { expect(completions).toEqual([]); expect(errors).toHaveLength(1); expect(errors[0]).toBeInstanceOf(SubscriptionError); - expect((errors[0] as SubscriptionError).reason).toEqual(reason); + expect((errors[0] as SubscriptionError).reason).toEqual(callError); expect(fixture.sent).toHaveLength(1); }); @@ -258,15 +371,18 @@ describe("generated client transport", () => { .subscribe({ error: (error) => errors.push(error) }); const reason = "Denied"; + const callError = { + tag: "Domain", + value: { tag: "V1", value: reason }, + } as const; const frame = unwrap( encodeWireMessage({ requestId: sub.subscriptionId, payload: { id: W.COIN_PAYMENT_REBALANCE_PURSE.interrupt, - value: T.VersionedHostCoinPaymentRebalancePurseError.enc({ - tag: "V1", - value: reason, - }), + value: versionedV1( + CallError(T.VersionedHostCoinPaymentRebalancePurseError), + ).enc({ tag: "V1", value: callError }), }, }), "encode typed coin payment interrupt", @@ -275,7 +391,7 @@ describe("generated client transport", () => { expect(errors).toHaveLength(1); expect(errors[0]).toBeInstanceOf(SubscriptionError); - expect((errors[0] as SubscriptionError).reason).toEqual(reason); + expect((errors[0] as SubscriptionError).reason).toEqual(callError); }); it("treats a malformed receive payload as terminal and sends _stop", () => { diff --git a/rust/crates/truapi-codegen/.gitignore b/rust/crates/truapi-codegen/.gitignore new file mode 100644 index 00000000..7a478389 --- /dev/null +++ b/rust/crates/truapi-codegen/.gitignore @@ -0,0 +1,2 @@ +# Mismatch dumps written by tests/golden_rust_emit.rs for local inspection. +tests/golden/*.actual diff --git a/rust/crates/truapi-codegen/Cargo.toml b/rust/crates/truapi-codegen/Cargo.toml index c6d3ef3c..c376346e 100644 --- a/rust/crates/truapi-codegen/Cargo.toml +++ b/rust/crates/truapi-codegen/Cargo.toml @@ -17,3 +17,6 @@ anyhow = "1" clap = { version = "4", features = ["derive"] } indoc = "2" convert_case = "0.6" + +[dev-dependencies] +tempfile = "3" diff --git a/rust/crates/truapi-codegen/src/main.rs b/rust/crates/truapi-codegen/src/main.rs index 0dfa318c..42a5aa1e 100644 --- a/rust/crates/truapi-codegen/src/main.rs +++ b/rust/crates/truapi-codegen/src/main.rs @@ -1,7 +1,10 @@ use anyhow::{Context, Result}; use clap::Parser; +use std::path::PathBuf; use std::str::FromStr; +mod platform; +mod rust; mod rustdoc; mod ts; @@ -43,6 +46,32 @@ struct Cli { #[arg(long)] client_examples_output: Option, + /// Output directory for the generated Rust dispatcher / wire-table (optional). + /// + /// When set, emits `dispatcher.rs` and `wire_table.rs` for the + /// `truapi-server` crate to include. + #[arg(long)] + rust_output: Option, + + /// Path to rustdoc JSON for the `truapi-platform` crate (optional). + /// + /// When provided together with `--platform-ts-output`, walks the + /// platform crate's capability traits and emits the typed TS + /// `HostCallbacks` surface plus the WASM raw callback adapter. + #[arg(long)] + platform_input: Option, + + /// Output directory for the generated typed `HostCallbacks` TypeScript + /// surface (optional). Only honored when `--platform-input` is also set. + #[arg(long)] + platform_ts_output: Option, + + /// Output directory for the generated WASM host-callback adapter + /// (optional). Only honored when `--platform-input` and + /// `--platform-ts-output` are also set. Defaults to `--platform-ts-output`. + #[arg(long)] + platform_wasm_adapter_output: Option, + /// Output directory for generated explorer metadata (optional). When set, /// writes `codegen/types.ts` with the DataType list consumed by the /// explorer site. @@ -111,6 +140,40 @@ fn main() -> Result<()> { .with_context(|| format!("writing client examples to {path}"))?; println!("Generated client examples in {path}"); } + if let Some(path) = &cli.rust_output { + rust::generate(&api, path) + .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) { + 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() + || cli.platform_wasm_adapter_output.is_some() + { + anyhow::bail!( + "--platform-input and --platform-ts-output must be provided together; \ + --platform-wasm-adapter-output additionally requires both" + ); + } if let Some(path) = &cli.explorer_output { ts::generate_explorer(&api, path, client_version) .with_context(|| format!("writing explorer metadata to {path}"))?; diff --git a/rust/crates/truapi-codegen/src/platform.rs b/rust/crates/truapi-codegen/src/platform.rs new file mode 100644 index 00000000..f37b5e91 --- /dev/null +++ b/rust/crates/truapi-codegen/src/platform.rs @@ -0,0 +1,690 @@ +//! Parse `truapi-platform`-style "plain capability traits" from rustdoc JSON. +//! +//! Unlike the `truapi` API crate, the platform crate has no `#[wire(id = N)]` +//! annotations: it is a set of host-facing capability traits whose methods +//! use `async_trait` (rustdoc exposes those as boxed `Future` trait objects) or +//! plain synchronous functions returning trait objects / `BoxStream`. This +//! module walks the rustdoc index for every public trait in the platform crate +//! and produces a [`PlatformDefinition`] the TS emitter can render directly. + +use std::collections::BTreeSet; + +use anyhow::{Context, Result, bail}; + +use crate::rustdoc::{ + Crate, Item, NameContext, TypeDef, TypeDefKind, TypeRef, VariantFields, clean_docs, + extract_enum, extract_struct, resolve_type, summarize_json, +}; + +/// Top-level extracted shape of a `truapi-platform`-style crate. +#[derive(Debug, PartialEq, Eq)] +pub struct PlatformDefinition { + /// Capability traits sorted alphabetically by name. + pub traits: Vec, + /// Local structs and enums referenced from trait method signatures, + /// sorted alphabetically by name. Emitted alongside the trait interfaces + /// so the generated TS does not have to import them from the API client. + pub types: Vec, + /// Composite super-trait (`Platform: Storage + Navigation + ...`), if any. + pub super_trait: Option, +} + +/// Single capability trait extracted from the platform crate. +#[derive(Debug, PartialEq, Eq)] +pub struct PlatformTrait { + /// Trait name as it appears in source. + pub name: String, + /// Rustdoc comment on the trait. + pub docs: Option, + /// Methods declared on the trait, in declaration order. + pub methods: Vec, +} + +/// A trait method on a capability trait. +#[derive(Debug, PartialEq, Eq)] +pub struct PlatformMethod { + /// Method name as it appears in source. + pub name: String, + /// Rustdoc comment on the method. + pub docs: Option, + /// Parameter list with names preserved (excluding `&self`). + pub params: Vec, + /// Return shape decoded from the method signature. + pub return_shape: PlatformReturn, + /// Whether the trait provides a default body, making the method optional + /// for host implementations. + pub has_default: bool, +} + +/// Method parameter (name + type). +#[derive(Debug, PartialEq, Eq)] +pub struct PlatformParam { + /// Parameter name as written in the trait method signature. + pub name: String, + /// Parameter type expressed as a [`TypeRef`]. + pub type_ref: TypeRef, +} + +/// Return shape after stripping async-trait `Pin>>` +/// / `Box` wrappers. +#[derive(Debug, PartialEq, Eq)] +pub struct PlatformReturn { + /// Whether the method returns an async-trait boxed future (i.e. is async). + pub is_async: bool, + /// Unwrapped inner shape. + pub inner: PlatformInner, +} + +/// Classification of the unwrapped return type. +#[derive(Debug, PartialEq, Eq)] +pub enum PlatformInner { + /// `()` (or no return). + Unit, + /// `Result`. The TS surface returns `Promise` and rejects with `Err`. + Result { ok: TypeRef, err: TypeRef }, + /// `BoxStream<'static, T>`, a stream of `T` items. + Stream(TypeRef), + /// `Box`, a trait object handle to a named trait. + TraitObject(String), + /// Any other concrete type, returned as-is. + Plain(TypeRef), +} + +/// Composite super-trait that aggregates capability traits. +#[derive(Debug, PartialEq, Eq)] +pub struct PlatformSuperTrait { + /// Name of the super-trait (e.g. `Platform`). + pub name: String, + /// Rustdoc comment on the super-trait. + pub docs: Option, + /// Capability trait names this super-trait composes, in source order. + pub composes: Vec, +} + +/// Walk the platform crate and extract every public trait + its methods. +pub fn extract(krate: &Crate) -> Result { + let trait_ids = collect_local_trait_ids(krate); + let names = NameContext::default(); + + let mut traits = Vec::new(); + let mut super_trait = None; + for item_id in &trait_ids { + let item = krate + .index + .get(item_id) + .with_context(|| format!("Missing rustdoc item `{item_id}` for trait"))?; + let name = item + .name + .as_ref() + .cloned() + .with_context(|| format!("Trait `{item_id}` has no name"))?; + let trait_inner = item + .inner + .get("trait") + .with_context(|| format!("Trait `{name}` missing rustdoc trait body"))?; + + if is_super_trait(trait_inner) { + if super_trait.is_some() { + bail!("Multiple super-traits with method-less bodies found; only one is supported"); + } + super_trait = Some(extract_super_trait(&name, item, trait_inner)?); + continue; + } + + traits.push(extract_capability_trait( + &name, + item, + trait_inner, + krate, + &names, + )?); + } + + traits.sort_by(|a, b| a.name.cmp(&b.name)); + let types = collect_referenced_local_types(krate, &traits, &names)?; + + Ok(PlatformDefinition { + traits, + types, + super_trait, + }) +} + +/// Extract every local struct or enum whose name appears in a trait method +/// signature. +fn collect_referenced_local_types( + krate: &Crate, + traits: &[PlatformTrait], + names: &NameContext, +) -> Result> { + let mut referenced = BTreeSet::new(); + for trait_def in traits { + for method in &trait_def.methods { + for param in &method.params { + collect_named_types(¶m.type_ref, &mut referenced); + } + match &method.return_shape.inner { + // Err types never reach the TS signature (errors throw), so + // their names are not emitted either. + PlatformInner::Result { ok, .. } => collect_named_types(ok, &mut referenced), + PlatformInner::Stream(inner) | PlatformInner::Plain(inner) => { + collect_named_types(inner, &mut referenced) + } + PlatformInner::TraitObject(_) | PlatformInner::Unit => {} + } + } + } + + // Local types can reference further local types from their fields or + // variant payloads (e.g. `AuthState::Connected(SessionUiInfo)`), so keep + // extracting until the referenced set stops growing. + let mut types: Vec = Vec::new(); + let mut extracted: BTreeSet = BTreeSet::new(); + loop { + let mut grew = false; + for (item_id, item_path) in &krate.paths { + if item_path.crate_id != 0 || !matches!(item_path.kind.as_str(), "struct" | "enum") { + continue; + } + let Some(name) = item_path.path.last() else { + continue; + }; + if !referenced.contains(name) || extracted.contains(name) { + continue; + } + let item = krate.index.get(item_id).with_context(|| { + format!( + "Missing rustdoc item `{item_id}` for {} `{name}`", + item_path.kind + ) + })?; + let module_path = item_path.path[..item_path.path.len() - 1].to_vec(); + let type_def = if item_path.kind == "struct" { + extract_struct(item_id, item, krate, names, module_path)? + } else { + extract_enum(item_id, item, krate, names, module_path)? + }; + collect_type_def_references(&type_def, &mut referenced); + extracted.insert(name.clone()); + types.push(type_def); + grew = true; + } + if !grew { + break; + } + } + types.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(types) +} + +/// Collect named types referenced from a local type's fields or variants. +fn collect_type_def_references(type_def: &TypeDef, out: &mut BTreeSet) { + match &type_def.kind { + TypeDefKind::Alias(ty) => collect_named_types(ty, out), + TypeDefKind::Struct(fields) => { + for field in fields { + collect_named_types(&field.type_ref, out); + } + } + TypeDefKind::TupleStruct(types) => { + for ty in types { + collect_named_types(ty, out); + } + } + TypeDefKind::Enum(variants) => { + for variant in variants { + match &variant.fields { + VariantFields::Unit => {} + VariantFields::Unnamed(types) => { + for ty in types { + collect_named_types(ty, out); + } + } + VariantFields::Named(fields) => { + for field in fields { + collect_named_types(&field.type_ref, out); + } + } + } + } + } + } +} + +fn collect_named_types(ty: &TypeRef, out: &mut BTreeSet) { + match ty { + TypeRef::Named { name, args } => { + out.insert(name.clone()); + for arg in args { + collect_named_types(arg, out); + } + } + TypeRef::Vec(inner) | TypeRef::Option(inner) | TypeRef::Array(inner, _) => { + collect_named_types(inner, out) + } + TypeRef::Tuple(items) => { + for item in items { + collect_named_types(item, out); + } + } + TypeRef::Primitive(_) | TypeRef::Generic(_) | TypeRef::Unit => {} + } +} + +fn collect_local_trait_ids(krate: &Crate) -> BTreeSet { + let mut out = BTreeSet::new(); + for (item_id, item_path) in &krate.paths { + if item_path.crate_id != 0 || item_path.kind != "trait" { + continue; + } + out.insert(item_id.clone()); + } + out +} + +fn is_super_trait(trait_inner: &serde_json::Value) -> bool { + let no_methods = trait_inner + .get("items") + .and_then(|value| value.as_array()) + .map(|arr| arr.is_empty()) + .unwrap_or(true); + + let has_local_trait_bound = trait_inner + .get("bounds") + .and_then(|value| value.as_array()) + .map(|bounds| { + bounds.iter().any(|bound| { + bound + .get("trait_bound") + .and_then(|tb| tb.get("trait")) + .and_then(|t| t.get("path")) + .and_then(|p| p.as_str()) + .map(|name| name != "Send" && name != "Sync") + .unwrap_or(false) + }) + }) + .unwrap_or(false); + + no_methods && has_local_trait_bound +} + +fn extract_super_trait( + name: &str, + item: &Item, + trait_inner: &serde_json::Value, +) -> Result { + let bounds = trait_inner + .get("bounds") + .and_then(|value| value.as_array()) + .with_context(|| format!("Super-trait `{name}` missing rustdoc bounds"))?; + + let mut composes = Vec::new(); + for bound in bounds { + let Some(path) = bound + .get("trait_bound") + .and_then(|tb| tb.get("trait")) + .and_then(|t| t.get("path")) + .and_then(|p| p.as_str()) + else { + continue; + }; + if path == "Send" || path == "Sync" { + continue; + } + composes.push(path.to_string()); + } + + Ok(PlatformSuperTrait { + name: name.to_string(), + docs: clean_docs(item.docs.as_deref()), + composes, + }) +} + +fn extract_capability_trait( + name: &str, + item: &Item, + trait_inner: &serde_json::Value, + krate: &Crate, + names: &NameContext, +) -> Result { + let item_ids = trait_inner + .get("items") + .and_then(|value| value.as_array()) + .with_context(|| format!("Trait `{name}` missing rustdoc items array"))?; + + let mut methods = Vec::new(); + for method_id in item_ids { + let method_id = value_to_id(method_id) + .with_context(|| format!("Trait `{name}` contained a non-item method id"))?; + let method_item = krate + .index + .get(&method_id) + .with_context(|| format!("Trait `{name}` references missing item `{method_id}`"))?; + if let Some(method) = extract_method(method_item, names)? { + methods.push(method); + } + } + + Ok(PlatformTrait { + name: name.to_string(), + docs: clean_docs(item.docs.as_deref()), + methods, + }) +} + +fn extract_method(item: &Item, names: &NameContext) -> Result> { + let Some(fn_inner) = item.inner.get("function") else { + return Ok(None); + }; + let name = item + .name + .as_ref() + .cloned() + .with_context(|| "Method item has no name".to_string())?; + let sig = fn_inner + .get("sig") + .with_context(|| format!("Method `{name}` missing rustdoc signature"))?; + + let mut params = Vec::new(); + if let Some(inputs) = sig.get("inputs").and_then(|value| value.as_array()) { + for input in inputs { + let arr = input + .as_array() + .with_context(|| format!("Method `{name}` has an invalid input entry"))?; + let param_name = arr + .first() + .and_then(|value| value.as_str()) + .with_context(|| format!("Method `{name}` has an unnamed input"))? + .to_string(); + if param_name == "self" { + continue; + } + let ty = arr.get(1).with_context(|| { + format!("Method `{name}` input `{param_name}` is missing a type") + })?; + let type_ref = resolve_type(ty, names).with_context(|| { + format!("Method `{name}` input `{param_name}` has an unsupported type") + })?; + params.push(PlatformParam { + name: param_name, + type_ref, + }); + } + } + + let return_shape = resolve_return(sig.get("output"), names) + .with_context(|| format!("Method `{name}` has an unsupported return type"))?; + let has_default = fn_inner + .get("has_body") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + Ok(Some(PlatformMethod { + name, + docs: clean_docs(item.docs.as_deref()), + params, + return_shape, + has_default, + })) +} + +fn resolve_return( + output: Option<&serde_json::Value>, + names: &NameContext, +) -> Result { + let Some(output) = output else { + return Ok(PlatformReturn { + is_async: false, + inner: PlatformInner::Unit, + }); + }; + if output.is_null() { + return Ok(PlatformReturn { + is_async: false, + inner: PlatformInner::Unit, + }); + } + + if let Some(future_output) = extract_async_trait_future_output(output) { + let inner = resolve_inner_shape(&future_output, names)?; + return Ok(PlatformReturn { + is_async: true, + inner, + }); + } + + let inner = resolve_inner_shape(output, names)?; + Ok(PlatformReturn { + is_async: false, + inner, + }) +} + +fn extract_async_trait_future_output(output: &serde_json::Value) -> Option { + let pin = output.get("resolved_path")?; + if resolved_leaf(pin) != Some("Pin") { + return None; + } + let boxed = generic_arg(pin, 0)?; + let boxed = boxed.get("resolved_path")?; + if resolved_leaf(boxed) != Some("Box") { + return None; + } + let dyn_trait = generic_arg(boxed, 0)?; + let dyn_trait = dyn_trait.get("dyn_trait")?; + let traits = dyn_trait.get("traits")?.as_array()?; + for trait_entry in traits { + let trait_obj = trait_entry.get("trait")?; + if resolved_leaf(trait_obj) != Some("Future") { + continue; + } + let constraints = trait_obj + .get("args")? + .get("angle_bracketed")? + .get("constraints")? + .as_array()?; + for constraint in constraints { + if constraint.get("name")?.as_str()? != "Output" { + continue; + } + let ty = constraint.get("binding")?.get("equality")?.get("type")?; + return Some(ty.clone()); + } + } + None +} + +fn resolve_inner_shape(ty: &serde_json::Value, names: &NameContext) -> Result { + // `()` tuple. + if let Some(arr) = ty.get("tuple").and_then(|v| v.as_array()) + && arr.is_empty() + { + return Ok(PlatformInner::Unit); + } + + if let Some(resolved) = ty.get("resolved_path") { + let path = resolved + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let leaf = path.rsplit("::").next().unwrap_or(path); + + match leaf { + "Result" => { + let ok = + generic_arg(resolved, 0).context("Result<...> return type missing ok arg")?; + let err = + generic_arg(resolved, 1).context("Result<...> return type missing err arg")?; + let ok_ref = resolve_inner_type(&ok, names)?; + let err_ref = resolve_inner_type(&err, names)?; + return Ok(PlatformInner::Result { + ok: ok_ref, + err: err_ref, + }); + } + "BoxStream" => { + // `BoxStream<'a, T>`: the lifetime arg is filtered out by + // `generic_arg` (it has no `type` field), so the first + // remaining positional arg is the item type. + let item = + generic_arg(resolved, 0).context("BoxStream<'a, T> missing item type")?; + return Ok(PlatformInner::Stream(resolve_type(&item, names)?)); + } + "Box" => { + // `Box` or `Box`. + if let Some(arg) = generic_arg(resolved, 0) + && let Some(dyn_trait) = arg.get("dyn_trait") + { + return Ok(PlatformInner::TraitObject(dyn_trait_leaf_name(dyn_trait)?)); + } + } + _ => {} + } + } + + let resolved_ref = resolve_type(ty, names) + .with_context(|| format!("Unsupported return shape: {}", summarize_json(ty)))?; + Ok(PlatformInner::Plain(resolved_ref)) +} + +/// Resolve a positional type. Recognises `Box` and folds it +/// into a `TypeRef::Named { name: TraitName, args: [] }` so it survives +/// through to TS emission without `rustdoc.rs` having to model dyn traits. +fn resolve_inner_type(ty: &serde_json::Value, names: &NameContext) -> Result { + if let Some(resolved) = ty.get("resolved_path") + && resolved + .get("path") + .and_then(|v| v.as_str()) + .map(|p| p.rsplit("::").next().unwrap_or(p) == "Box") + .unwrap_or(false) + && let Some(arg) = generic_arg(resolved, 0) + && let Some(dyn_trait) = arg.get("dyn_trait") + { + return Ok(TypeRef::Named { + name: dyn_trait_leaf_name(dyn_trait)?, + args: Vec::new(), + }); + } + resolve_type(ty, names) +} + +/// Extract the leaf trait name from a `Box` rustdoc `dyn_trait` +/// value (the last `::`-segment of the first listed trait path). +fn dyn_trait_leaf_name(dyn_trait: &serde_json::Value) -> Result { + Ok(dyn_trait + .get("traits") + .and_then(|t| t.as_array()) + .and_then(|arr| arr.first()) + .and_then(|first| first.get("trait")) + .and_then(|trait_obj| trait_obj.get("path")) + .and_then(|p| p.as_str()) + .context("Box missing trait path")? + .rsplit("::") + .next() + .unwrap_or_default() + .to_string()) +} + +fn resolved_leaf(resolved: &serde_json::Value) -> Option<&str> { + let path = resolved.get("path")?.as_str()?; + Some(path.rsplit("::").next().unwrap_or(path)) +} + +fn generic_arg(resolved: &serde_json::Value, index: usize) -> Option { + resolved + .get("args")? + .get("angle_bracketed")? + .get("args")? + .as_array()? + .iter() + .filter_map(|entry| entry.get("type").cloned()) + .nth(index) +} + +fn value_to_id(value: &serde_json::Value) -> Result { + if let Some(id) = value.as_str() { + return Ok(id.to_string()); + } + if let Some(id) = value.as_u64() { + return Ok(id.to_string()); + } + bail!("Expected rustdoc item id, got non-id value") +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn extract_async_trait_future_output_from_pin_box_dyn_future() { + let output = json!({ + "resolved_path": { + "path": "::core::pin::Pin", + "args": { + "angle_bracketed": { + "args": [ + { + "type": { + "resolved_path": { + "path": "Box", + "args": { + "angle_bracketed": { + "args": [ + { + "type": { + "dyn_trait": { + "traits": [ + { + "trait": { + "path": "::core::future::Future", + "args": { + "angle_bracketed": { + "args": [], + "constraints": [ + { + "name": "Output", + "binding": { + "equality": { + "type": { "primitive": "u8" } + } + } + } + ] + } + } + } + }, + { + "trait": { + "path": "::core::marker::Send", + "args": null + } + } + ], + "lifetime": "'async_trait" + } + } + } + ], + "constraints": [] + } + } + } + } + } + ], + "constraints": [] + } + } + } + }); + + assert_eq!( + extract_async_trait_future_output(&output), + Some(json!({ "primitive": "u8" })) + ); + } +} diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs new file mode 100644 index 00000000..15a2d904 --- /dev/null +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -0,0 +1,604 @@ +//! Rust code generation from extracted API definitions. +//! +//! Emits the server-side wire dispatcher (`dispatcher.rs`) and the +//! discriminant lookup table (`wire_table.rs`). The generated files are +//! intended to be included in the `truapi-server` crate. + +use std::fs; +use std::path::Path; + +use anyhow::Result; + +use convert_case::{Case, Casing}; + +use crate::rustdoc::*; + +mod dispatcher; +mod wire_table; + +pub use dispatcher::generate_dispatcher; +pub use wire_table::generate_wire_table; + +/// Generates the Rust wire dispatcher and wire-table sources into `output_dir`. +pub fn generate(api: &ApiDefinition, output_dir: &Path) -> Result<()> { + fs::create_dir_all(output_dir)?; + let dispatcher = generate_dispatcher(api)?; + fs::write(output_dir.join("dispatcher.rs"), dispatcher)?; + let wire_table = generate_wire_table(api)?; + fs::write(output_dir.join("wire_table.rs"), wire_table)?; + Ok(()) +} + +/// Trait -> versioned-module mapping. Trait names are PascalCase +/// (`JsonRpc`, `LocalStorage`); module names are snake_case +/// (`jsonrpc`, `local_storage`). The mapping is irregular enough +/// (e.g. `JsonRpc` -> `jsonrpc`) that it is hardcoded. +const TRAIT_MODULE_MAP: &[(&str, &str)] = &[ + ("Account", "account"), + ("Chain", "chain"), + ("Chat", "chat"), + ("Entropy", "entropy"), + ("JsonRpc", "jsonrpc"), + ("LocalStorage", "local_storage"), + ("Payment", "payment"), + ("Permissions", "permissions"), + ("Preimage", "preimage"), + ("ResourceAllocation", "resource_allocation"), + ("Signing", "signing"), + ("StatementStore", "statement_store"), + ("System", "system"), + ("Theme", "theme"), +]; + +/// Returns the versioned-module name for a trait, falling back to a +/// snake_case conversion of the trait name when no explicit mapping is +/// declared. New traits should be added to [`TRAIT_MODULE_MAP`] so the +/// emission stays deterministic. +fn module_for_trait(trait_name: &str) -> String { + for (name, module) in TRAIT_MODULE_MAP { + if *name == trait_name { + return (*module).to_string(); + } + } + snake_case(trait_name) +} + +/// Returns the wire-protocol method name for a trait/method pair, used both +/// as the dispatcher's registration key and as the prefix of the action tag +/// (`{wire_method}_{request|response|...}`). The form is +/// `{trait_snake}_{method}` so collisions between sibling traits (e.g. +/// `StatementStore::submit` and `Preimage::submit`) become distinct keys +/// (`statement_store_submit`, `preimage_submit`). +pub(crate) fn wire_method_name(trait_name: &str, method_name: &str) -> String { + format!("{}_{}", snake_case(trait_name), method_name) +} + +/// The `SCREAMING_SNAKE_CASE` const name holding a wire method's ids. +/// Routed through [`convert_case::Case::UpperSnake`] so it follows the same +/// casing rules as the TS wire-table emitter (`ts.rs`). +pub(crate) fn const_name(wire_method: &str) -> String { + wire_method.to_case(Case::UpperSnake) +} + +/// Const name for a trait/method pair's wire ids. Both the Rust and TS +/// wire-table emitters apply `Case::UpperSnake`, so for the real +/// (single-capital PascalCase trait, snake_case method) surface the two +/// generated const names agree. +#[cfg(test)] +pub(crate) fn wire_const_name(trait_name: &str, method_name: &str) -> String { + const_name(&wire_method_name(trait_name, method_name)) +} + +/// Convert a PascalCase identifier into snake_case. +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 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request_method(name: &str, request_id: u8) -> MethodDef { + MethodDef { + name: name.to_string(), + kind: MethodKind::Request, + params: vec![ParamDef { + name: "request".to_string(), + type_ref: TypeRef::Named { + name: "ReqWrapper".to_string(), + args: vec![], + }, + }], + return_type: ReturnType::Result { + ok: TypeRef::Named { + name: "RespWrapper".to_string(), + args: vec![], + }, + err: TypeRef::Named { + name: "CallError".to_string(), + args: vec![TypeRef::Named { + name: "ErrWrapper".to_string(), + args: vec![], + }], + }, + }, + wire: WireAttrs { + request_id: Some(request_id), + response_id: None, + start_id: None, + stop_id: None, + interrupt_id: None, + receive_id: None, + }, + docs: None, + } + } + + fn make_subscription_method(name: &str, start_id: u8) -> MethodDef { + MethodDef { + name: name.to_string(), + kind: MethodKind::Subscription, + params: vec![], + return_type: ReturnType::Subscription(TypeRef::Named { + name: "ItemWrapper".to_string(), + args: vec![], + }), + wire: WireAttrs { + request_id: None, + response_id: None, + start_id: Some(start_id), + stop_id: None, + interrupt_id: None, + receive_id: None, + }, + docs: None, + } + } + + fn versioned_test_type(name: &str) -> TypeDef { + TypeDef { + name: name.to_string(), + module_path: Vec::new(), + generic_params: Vec::new(), + kind: TypeDefKind::Enum(vec![VariantDef { + name: "V1".to_string(), + fields: VariantFields::Unnamed(vec![TypeRef::Named { + name: format!("V01{name}"), + args: vec![], + }]), + docs: None, + }]), + docs: None, + } + } + + fn versioned_request_test_types() -> Vec { + ["ReqWrapper", "RespWrapper", "ErrWrapper"] + .into_iter() + .map(versioned_test_type) + .collect() + } + + fn parse_entries(src: &str) -> Vec<(u8, String)> { + // Each method's ids are emitted as a named const, e.g. + // pub const PREIMAGE_SUBMIT: RequestFrameIds = RequestFrameIds { + // request_id: 68, + // response_id: 69, + // }; + // Reconstruct the `(id, "{method}_{suffix}")` pairs the assertions use. + let mut out = Vec::new(); + let mut lines = src.lines(); + while let Some(line) = lines.next() { + let Some(rest) = line.trim().strip_prefix("pub const ") else { + continue; + }; + let Some(colon) = rest.find(':') else { + continue; + }; + let is_sub = rest.contains("SubscriptionFrameIds"); + // Skip non-id consts (e.g. `WIRE_TABLE: &[WireEntry]`). + if !is_sub && !rest.contains("RequestFrameIds") { + continue; + } + let method = rest[..colon].trim().to_ascii_lowercase(); + + let mut ids: std::collections::BTreeMap<&str, u8> = std::collections::BTreeMap::new(); + for inner in lines.by_ref() { + let t = inner.trim(); + if t.starts_with("};") { + break; + } + if let Some((field, val)) = t.split_once(':') { + let id = val.trim().trim_end_matches(',').parse::().unwrap(); + ids.insert(field.trim(), id); + } + } + + let suffixes: &[(&str, &str)] = if is_sub { + &[ + ("start_id", "start"), + ("stop_id", "stop"), + ("interrupt_id", "interrupt"), + ("receive_id", "receive"), + ] + } else { + &[("request_id", "request"), ("response_id", "response")] + }; + for (field, suffix) in suffixes { + out.push((ids[field], format!("{method}_{suffix}"))); + } + } + out + } + + /// A single subscription method must reserve four consecutive wire + /// ids (start/stop/interrupt/receive) even when no sibling methods + /// exist to mask off-by-one errors. + #[test] + fn wire_table_subscribe_method_reserves_four_ids() { + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Account".to_string(), + module_path: Vec::new(), + methods: vec![make_subscription_method("connection_status_subscribe", 18)], + docs: None, + }], + public_trait_order: vec!["Account".to_string()], + types: vec![], + }; + + let src = generate_wire_table(&api).expect("generate_wire_table"); + let entries = parse_entries(&src); + assert_eq!( + entries, + vec![ + (18, "account_connection_status_subscribe_start".into()), + (19, "account_connection_status_subscribe_stop".into()), + (20, "account_connection_status_subscribe_interrupt".into()), + (21, "account_connection_status_subscribe_receive".into()), + ], + ); + } + + /// Two traits each declaring a method named `submit` must produce two + /// distinct, non-colliding wire method keys; the emitter prefixes by + /// the snake_case trait name (e.g. `statement_store_submit` / + /// `preimage_submit`). + #[test] + fn collision_safe_when_two_traits_share_method_name() { + let api = ApiDefinition { + traits: vec![ + TraitDef { + name: "StatementStore".to_string(), + module_path: Vec::new(), + methods: vec![make_request_method("submit", 62)], + docs: None, + }, + TraitDef { + name: "Preimage".to_string(), + module_path: Vec::new(), + methods: vec![make_request_method("submit", 68)], + docs: None, + }, + ], + public_trait_order: vec!["StatementStore".to_string(), "Preimage".to_string()], + types: versioned_request_test_types(), + }; + + let dispatcher = generate_dispatcher(&api).expect("dispatcher"); + assert!( + dispatcher.contains("wire_table::STATEMENT_STORE_SUBMIT"), + "dispatcher missing prefixed StatementStore const:\n{dispatcher}" + ); + assert!( + dispatcher.contains("wire_table::PREIMAGE_SUBMIT"), + "dispatcher missing prefixed Preimage const:\n{dispatcher}" + ); + + let table = generate_wire_table(&api).expect("wire_table"); + let entries = parse_entries(&table); + assert!( + entries + .iter() + .any(|(_, tag)| tag == "statement_store_submit_request"), + "wire_table missing prefixed StatementStore tag:\n{table}" + ); + assert!( + entries + .iter() + .any(|(_, tag)| tag == "preimage_submit_request"), + "wire_table missing prefixed Preimage tag:\n{table}" + ); + } + + /// If a future change ever produces the same wire method key from two + /// different (trait, method) pairs, both emitters must fail loudly + /// rather than silently overwrite a handler. + #[test] + fn wire_table_rejects_method_name_collision() { + // `Foo::bar_baz` and `FooBar::baz` both snake-case to + // `foo_bar_baz`. The emitter must reject the pair. + let api = ApiDefinition { + traits: vec![ + TraitDef { + name: "Foo".to_string(), + module_path: Vec::new(), + methods: vec![make_request_method("bar_baz", 10)], + docs: None, + }, + TraitDef { + name: "FooBar".to_string(), + module_path: Vec::new(), + methods: vec![make_request_method("baz", 12)], + docs: None, + }, + ], + public_trait_order: vec!["Foo".to_string(), "FooBar".to_string()], + types: vec![], + }; + let err = generate_wire_table(&api).expect_err("duplicate wire method name must error"); + let msg = format!("{err}"); + assert!( + msg.contains("wire method name `foo_bar_baz` reused"), + "unexpected error message: {msg}", + ); + + let err = generate_dispatcher(&api).expect_err("duplicate wire method name must error"); + let msg = format!("{err}"); + assert!( + msg.contains("Wire method name `foo_bar_baz` registered twice"), + "unexpected dispatcher error message: {msg}", + ); + } + + /// Emission must be deterministic: running the codegen twice on the + /// same API produces byte-identical output. + #[test] + fn idempotent_emission() { + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Permissions".to_string(), + module_path: Vec::new(), + methods: vec![make_request_method("request_device_permission", 8)], + docs: None, + }], + public_trait_order: vec!["Permissions".to_string()], + types: versioned_request_test_types(), + }; + + let dispatcher_a = generate_dispatcher(&api).expect("dispatcher a"); + let dispatcher_b = generate_dispatcher(&api).expect("dispatcher b"); + assert_eq!(dispatcher_a, dispatcher_b); + + let table_a = generate_wire_table(&api).expect("wire_table a"); + let table_b = generate_wire_table(&api).expect("wire_table b"); + assert_eq!(table_a, table_b); + } + + /// Methods with a `#[wire(request_id = N)]` annotation get a 2-id + /// slot (request/response). Methods with `#[wire(start_id = N)]` + /// get a 4-id slot (start/stop/interrupt/receive). The emitter + /// must enforce that, and reject collisions. + #[test] + fn wire_table_rejects_collisions() { + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Permissions".to_string(), + module_path: Vec::new(), + methods: vec![ + make_request_method("alpha", 10), + make_request_method("beta", 10), + ], + docs: None, + }], + public_trait_order: vec!["Permissions".to_string()], + types: vec![], + }; + let err = generate_wire_table(&api).expect_err("duplicate ids must error"); + let msg = format!("{err}"); + assert!( + msg.contains("wire id 10 reused"), + "unexpected error message: {msg}", + ); + } + + /// Pin `wire_const_name`'s `convert_case::Case::UpperSnake` behavior: + /// digits split off (`v2` -> `V_2`) and acronyms split (`HTTPServer` + /// snake-cases to `h_t_t_p_server`, then upper-snakes to + /// `H_T_T_P_SERVER`). Real traits/methods avoid both, so the committed + /// output is unaffected; the pin guards future drift. + #[test] + fn wire_const_name_pins_digits_and_acronyms() { + assert_eq!(wire_const_name("Preimage", "submit"), "PREIMAGE_SUBMIT"); + assert_eq!(wire_const_name("Signing", "sign_v2"), "SIGNING_SIGN_V_2"); + assert_eq!( + wire_const_name("HTTPServer", "serve"), + "H_T_T_P_SERVER_SERVE" + ); + assert_eq!( + wire_const_name("StatementStore", "create_proof"), + "STATEMENT_STORE_CREATE_PROOF" + ); + } + + #[test] + fn module_for_trait_maps_irregular_names() { + assert_eq!(module_for_trait("JsonRpc"), "jsonrpc"); + assert_eq!(module_for_trait("LocalStorage"), "local_storage"); + assert_eq!( + module_for_trait("ResourceAllocation"), + "resource_allocation" + ); + assert_eq!(module_for_trait("Account"), "account"); + } + + /// A request-kind method must not carry subscription wire ids. The + /// emitter rejects `start_id` / `stop_id` / `interrupt_id` / `receive_id` + /// on a `MethodKind::Request`. + #[test] + fn wire_table_request_with_subscription_id_errors() { + let mut method = make_request_method("alpha", 10); + method.wire.start_id = Some(99); + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Permissions".to_string(), + module_path: Vec::new(), + methods: vec![method], + docs: None, + }], + public_trait_order: vec!["Permissions".to_string()], + types: vec![], + }; + let err = generate_wire_table(&api).expect_err("request kind + start_id must error"); + let msg = format!("{err}"); + assert!( + msg.contains("must not use subscription wire ids"), + "unexpected error message: {msg}", + ); + } + + /// A subscription-kind method must not carry request wire ids. + #[test] + fn wire_table_subscription_with_request_id_errors() { + let mut method = make_subscription_method("connection_status_subscribe", 18); + method.wire.request_id = Some(99); + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Account".to_string(), + module_path: Vec::new(), + methods: vec![method], + docs: None, + }], + public_trait_order: vec!["Account".to_string()], + types: vec![], + }; + let err = generate_wire_table(&api).expect_err("subscription kind + request_id must error"); + let msg = format!("{err}"); + assert!( + msg.contains("must not use request wire ids"), + "unexpected error message: {msg}", + ); + } + + /// A request-kind method missing the mandatory `request_id` annotation + /// must fail emission, not silently default to 0. + #[test] + fn wire_table_missing_request_id_errors() { + let mut method = make_request_method("alpha", 10); + method.wire.request_id = None; + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Permissions".to_string(), + module_path: Vec::new(), + methods: vec![method], + docs: None, + }], + public_trait_order: vec!["Permissions".to_string()], + types: vec![], + }; + let err = generate_wire_table(&api).expect_err("missing request_id annotation must error"); + let msg = format!("{err}"); + assert!( + msg.contains("missing #[wire(request_id"), + "unexpected error message: {msg}", + ); + } + + /// Subscription-kind method missing `start_id` is similarly rejected. + #[test] + fn wire_table_missing_start_id_errors() { + let mut method = make_subscription_method("connection_status_subscribe", 18); + method.wire.start_id = None; + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Account".to_string(), + module_path: Vec::new(), + methods: vec![method], + docs: None, + }], + public_trait_order: vec!["Account".to_string()], + types: vec![], + }; + let err = generate_wire_table(&api).expect_err("missing start_id annotation must error"); + let msg = format!("{err}"); + assert!( + msg.contains("missing #[wire(start_id"), + "unexpected error message: {msg}", + ); + } + + /// The dispatcher expects each method to take exactly one versioned + /// wrapper parameter (plus `&self` and `&CallContext`, which are + /// elided from `params`). A method with two params errors out. + #[test] + fn dispatcher_multi_param_method_errors() { + let mut method = make_request_method("alpha", 10); + method.params.push(ParamDef { + name: "extra".to_string(), + type_ref: TypeRef::Named { + name: "ExtraWrapper".to_string(), + args: vec![], + }, + }); + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Permissions".to_string(), + module_path: Vec::new(), + methods: vec![method], + docs: None, + }], + public_trait_order: vec!["Permissions".to_string()], + types: vec![], + }; + let err = generate_dispatcher(&api).expect_err("two-param method must error"); + let msg = format!("{err}"); + assert!( + msg.contains("expected at most one request parameter"), + "unexpected error message: {msg}", + ); + } + + /// The response wrapper extraction expects a `TypeRef::Named` with no + /// generic args. Anything else (primitives, tuples, generics) errors. + #[test] + fn dispatcher_non_named_root_response_errors() { + let mut method = make_request_method("alpha", 10); + method.return_type = ReturnType::Result { + ok: TypeRef::Primitive("u32".to_string()), + err: TypeRef::Named { + name: "CallError".to_string(), + args: vec![TypeRef::Named { + name: "ErrWrapper".to_string(), + args: vec![], + }], + }, + }; + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Permissions".to_string(), + module_path: Vec::new(), + methods: vec![method], + docs: None, + }], + public_trait_order: vec!["Permissions".to_string()], + types: vec![], + }; + let err = generate_dispatcher(&api).expect_err("primitive response must error"); + let msg = format!("{err}"); + assert!( + msg.contains("response is not a versioned wrapper"), + "unexpected error message: {msg}", + ); + } +} diff --git a/rust/crates/truapi-codegen/src/rust/dispatcher.rs b/rust/crates/truapi-codegen/src/rust/dispatcher.rs new file mode 100644 index 00000000..5155e99c --- /dev/null +++ b/rust/crates/truapi-codegen/src/rust/dispatcher.rs @@ -0,0 +1,740 @@ +//! Emits `dispatcher.rs`: the server-side wire dispatcher that routes +//! incoming frames to the host trait implementation. +//! +//! For each method the emitter produces an `on_request` (or +//! `on_subscription`) registration that: +//! 1. SCALE-decodes the versioned request wrapper from the wire bytes. +//! 2. Calls the host trait method (which receives the wrapper directly +//! and matches `_::V1(inner)` internally). +//! 3. SCALE-encodes the versioned response wrapper back onto the wire. +//! +//! The generated file expects to live inside a `truapi-server` crate +//! and references `crate::dispatcher::Dispatcher`. The codegen itself +//! does not compile the output; string-diff golden tests guard it. + +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::fmt::Write; + +use anyhow::{Result, bail}; +use indoc::{formatdoc, indoc, writedoc}; + +use crate::rustdoc::*; + +use super::{const_name, module_for_trait, wire_method_name}; + +/// Emit the contents of `dispatcher.rs`. +pub fn generate_dispatcher(api: &ApiDefinition) -> Result { + let traits = order_traits(api)?; + + // Reject any duplicate wire method name across traits before emission, so + // a future addition can't silently overwrite a handler in the HashMap. + let mut seen: BTreeSet = BTreeSet::new(); + for trait_def in &traits { + for method in &trait_def.methods { + let key = wire_method_name(&trait_def.name, &method.name); + if !seen.insert(key.clone()) { + bail!( + "Wire method name `{key}` registered twice; \ + change `{}::{}` or its sibling trait to disambiguate", + trait_def.name, + method.name + ); + } + } + } + + let mut modules = Vec::with_capacity(traits.len()); + for trait_def in &traits { + modules.push(build_module(api, trait_def)?); + } + + let mut out = String::new(); + write_header(&mut out); + write_imports(&mut out, &traits); + writeln!(out).unwrap(); + write_top_register(&mut out, &traits); + + for module in &modules { + writeln!(out).unwrap(); + out.push_str(module); + } + + Ok(out) +} + +/// Returns the traits to emit, in the order declared by the top-level +/// `TrUApi` super-trait. Falls back to alphabetical order if the +/// extractor did not record a public ordering (e.g. synthetic tests). +fn order_traits(api: &ApiDefinition) -> Result> { + let by_name: BTreeMap<&str, &TraitDef> = + api.traits.iter().map(|t| (t.name.as_str(), t)).collect(); + + if api.public_trait_order.is_empty() { + return Ok(api.traits.iter().collect()); + } + + let mut ordered = Vec::with_capacity(api.public_trait_order.len()); + for name in &api.public_trait_order { + let Some(trait_def) = by_name.get(name.as_str()) else { + bail!("trait `{name}` appears in TrUApi but was not extracted"); + }; + ordered.push(*trait_def); + } + Ok(ordered) +} + +/// Emit the `register_{module}` function for a single trait. +fn build_module(api: &ApiDefinition, trait_def: &TraitDef) -> Result { + let module = module_for_trait(&trait_def.name); + + let mut methods = Vec::with_capacity(trait_def.methods.len()); + for method in &trait_def.methods { + let wire_method = wire_method_name(&trait_def.name, &method.name); + methods.push(MethodEmission::build(api, &module, &wire_method, method)?); + } + + let fn_name = format!("register_{module}"); + let trait_name = &trait_def.name; + let mut code = String::new(); + if trait_name == "Testing" { + writeln!(code, "#[cfg(debug_assertions)]").unwrap(); + } + writedoc!( + code, + r#" + fn {fn_name}

(dispatcher: &mut Dispatcher, host: Arc

) + where + P: {trait_name} + Send + Sync + 'static, + {{ + "# + ) + .unwrap(); + let last = methods.len().saturating_sub(1); + for (idx, method) in methods.iter().enumerate() { + let host_expr = if idx == last { "host" } else { "host.clone()" }; + method.write(&mut code, host_expr); + } + writeln!(code, "}}").unwrap(); + + Ok(code) +} + +struct MethodEmission { + /// Rust method name on the host trait (used for the `host.(...)` call). + name: String, + /// Fully-qualified wire method name (`{trait_snake}_{method}`); uppercased + /// to the `wire_table` const this method registers against. + wire_name: String, + module: String, + kind: MethodKind, + request_payload: Option, + response_wrapper: Option, + error_payload: WirePayload, + item_wrapper: Option, +} + +#[derive(Clone)] +enum WirePayload { + Versioned(String), + Raw(TypeRef), +} + +impl MethodEmission { + fn build( + api: &ApiDefinition, + module: &str, + wire_method: &str, + method: &MethodDef, + ) -> Result { + let versioned_wrappers = versioned_wrapper_names(api); + let request_payload = match method.params.as_slice() { + [] => None, + [param] => match ¶m.type_ref { + TypeRef::Named { name, args } + if args.is_empty() && versioned_wrappers.contains(name) => + { + Some(WirePayload::Versioned(name.clone())) + } + _ => Some(WirePayload::Raw(param.type_ref.clone())), + }, + _ => bail!( + "Method `{}`: expected at most one request parameter (got {})", + method.name, + method.params.len() + ), + }; + + let error_payload = match &method.return_type { + ReturnType::Result { err, .. } | ReturnType::ResultSubscription { err, .. } => { + wire_payload_for_error(&method.name, err, &versioned_wrappers)? + } + ReturnType::Subscription(_) => WirePayload::Raw(TypeRef::Unit), + }; + + let (response_wrapper, item_wrapper) = match &method.return_type { + // `Result<(), _>` returns produce an empty wire payload. + // The trait method is called for its side effects and the + // dispatcher encodes `()` (zero bytes) on success. + ReturnType::Result { + ok: TypeRef::Unit, .. + } => (None, None), + ReturnType::Result { ok, .. } => ( + Some( + versioned_wrapper_root(&method.name, "response", ok, &versioned_wrappers)? + .to_string(), + ), + None, + ), + ReturnType::Subscription(item) => ( + None, + Some( + versioned_wrapper_root( + &method.name, + "subscription item", + item, + &versioned_wrappers, + )? + .to_string(), + ), + ), + ReturnType::ResultSubscription { item, .. } => ( + None, + Some( + versioned_wrapper_root( + &method.name, + "subscription item", + item, + &versioned_wrappers, + )? + .to_string(), + ), + ), + }; + + Ok(MethodEmission { + name: method.name.clone(), + wire_name: wire_method.to_string(), + module: module.to_string(), + kind: method.kind, + request_payload, + response_wrapper, + error_payload, + item_wrapper, + }) + } + + fn write(&self, out: &mut String, host_expr: &str) { + match self.kind { + MethodKind::Request => self.write_request(out, host_expr), + MethodKind::Subscription | MethodKind::ResultSubscription => { + self.write_subscription(out, host_expr) + } + } + } + + fn write_request(&self, out: &mut String, host_expr: &str) { + let module = &self.module; + let method = &self.name; + let ids = const_name(&self.wire_name); + + write_indented( + out, + 4, + &formatdoc! { + r#" + {{ + let host = {host_expr}; + dispatcher.on_request(wire_table::{ids}, move |request_id: String, bytes: Vec| {{ + let host = host.clone(); + Box::pin(async move {{ + "# + }, + ); + let (call_args, target_version_expr) = match &self.request_payload { + Some(WirePayload::Versioned(request)) => { + let error = self + .error_payload + .versioned_name() + .expect("versioned request methods must use versioned errors"); + write_indented( + out, + 16, + &formatdoc! { + r#" + let request: versioned::{module}::{request} = match Decode::decode(&mut &bytes[..]) {{ + Ok(request) => request, + Err(err) => {{ + let error: truapi::CallError = + truapi::CallError::MalformedFrame {{ reason: err.to_string() }}; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + }} + }}; + let target_version = request.version(); + "# + }, + ); + ( + "&cx, request".to_string(), + Some("target_version".to_string()), + ) + } + Some(WirePayload::Raw(request)) => { + let request_ty = rust_type_ref(request).expect("raw request type"); + let error_ty = self + .error_payload + .rust_error_type(module) + .expect("raw request methods must have error type"); + write_indented( + out, + 16, + &formatdoc! { + r#" + let request: {request_ty} = match Decode::decode(&mut &bytes[..]) {{ + Ok(request) => request, + Err(err) => {{ + let error: truapi::CallError<{error_ty}> = + truapi::CallError::MalformedFrame {{ reason: err.to_string() }}; + return Ok(encode_raw_err_payload(error)); + }} + }}; + "# + }, + ); + ("&cx, request".to_string(), None) + } + None => { + writeln!(out, " let _ = bytes;").unwrap(); + let target = self + .error_payload + .versioned_name() + .map(|error| format!("::LATEST")); + ("&cx".to_string(), target) + } + }; + writeln!( + out, + " let cx = CallContext::with_request_id(request_id.clone());" + ) + .unwrap(); + match &self.response_wrapper { + Some(response) => { + let target_version_expr = target_version_expr + .as_deref() + .expect("versioned responses require a target version"); + write_indented( + out, + 16, + &formatdoc! { + r#" + let response: versioned::{module}::{response} = match host.{method}({call_args}).await {{ + Ok(value) => value, + Err(err) => {{ + return Ok(encode_versioned_err_payload(err, {target_version_expr})); + }} + }}; + Ok(encode_versioned_ok_payload(response)) + "# + }, + ); + } + None => match (&self.error_payload, target_version_expr.as_deref()) { + (WirePayload::Versioned(_), Some(target_version_expr)) => { + write_indented( + out, + 16, + &formatdoc! { + r#" + match host.{method}({call_args}).await {{ + Ok(()) => Ok(encode_versioned_unit_ok_payload({target_version_expr})), + Err(err) => {{ + Ok(encode_versioned_err_payload(err, {target_version_expr})) + }} + }} + "# + }, + ); + } + (WirePayload::Raw(_), _) => { + write_indented( + out, + 16, + &formatdoc! { + r#" + match host.{method}({call_args}).await {{ + Ok(()) => Ok(encode_raw_unit_ok_payload()), + Err(err) => Ok(encode_raw_err_payload(err)), + }} + "# + }, + ); + } + (WirePayload::Versioned(_), None) => unreachable!("missing versioned target"), + }, + } + write_indented( + out, + 4, + indoc! { + r#" + }) + }); + } + "# + }, + ); + } + + fn write_subscription(&self, out: &mut String, host_expr: &str) { + let module = &self.module; + let method = &self.name; + let ids = const_name(&self.wire_name); + let item = self + .item_wrapper + .as_deref() + .expect("subscription methods must have an item wrapper"); + let error = self.error_payload.versioned_name(); + + let is_result_sub = matches!(self.kind, MethodKind::ResultSubscription); + + write_indented( + out, + 4, + &formatdoc! { + r#" + {{ + let host = {host_expr}; + dispatcher.on_subscription(wire_table::{ids}, move |request_id: String, bytes: Vec| {{ + let host = host.clone(); + Box::pin(async move {{ + "# + }, + ); + let (call_args, target_version_expr) = if let Some(WirePayload::Versioned(request)) = + &self.request_payload + { + let decode_error = match error { + Some(error) => { + let block = formatdoc! { + r#" + Err(err) => {{ + let error: truapi::CallError = + truapi::CallError::MalformedFrame {{ + reason: err.to_string(), + }}; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + }} + "# + }; + block + .lines() + .map(|line| format!(" {line}")) + .collect::>() + .join("\n") + } + None => " Err(_) => return Err(Vec::new()),".to_string(), + }; + write_indented( + out, + 16, + &formatdoc! { + r#" + let request: versioned::{module}::{request} = match Decode::decode(&mut &bytes[..]) {{ + Ok(request) => request, + {decode_error} + }}; + "# + }, + ); + if is_result_sub { + writeln!( + out, + " let target_version = request.version();" + ) + .unwrap(); + } + ("&cx, request".to_string(), "target_version".to_string()) + } else { + writeln!(out, " let _ = bytes;").unwrap(); + let target_version = error + .map(|error| format!("::LATEST")) + .unwrap_or_else(|| "1".to_string()); + ("&cx".to_string(), target_version) + }; + writeln!( + out, + " let cx = CallContext::with_request_id(request_id.clone());" + ) + .unwrap(); + if is_result_sub { + let _ = error.expect("result subscription methods must have an error wrapper"); + write_indented( + out, + 16, + &formatdoc! { + r#" + let stream = match host.{method}({call_args}).await {{ + Ok(sub) => sub, + Err(err) => {{ + return Err(encode_versioned_interrupt_payload(err, {target_version_expr})); + }} + }}; + "# + }, + ); + } else { + writeln!( + out, + " let stream = host.{method}({call_args}).await;" + ) + .unwrap(); + } + writeln!( + out, + " Ok(subscription_stream::(stream))" + ) + .unwrap(); + write_indented( + out, + 4, + indoc! { + r#" + }) + }); + } + "# + }, + ); + } +} + +impl WirePayload { + fn versioned_name(&self) -> Option<&str> { + match self { + Self::Versioned(name) => Some(name), + Self::Raw(_) => None, + } + } + + fn rust_error_type(&self, module: &str) -> Result { + match self { + Self::Versioned(name) => Ok(format!("versioned::{module}::{name}")), + Self::Raw(ty) => rust_type_ref(ty), + } + } +} + +fn wire_payload_for_error( + method: &str, + ty: &TypeRef, + versioned_wrappers: &BTreeSet, +) -> Result { + let inner = call_error_inner(ty).unwrap_or(ty); + match inner { + TypeRef::Named { name, args } if args.is_empty() && versioned_wrappers.contains(name) => { + Ok(WirePayload::Versioned(name.clone())) + } + _ => { + if matches!(inner, TypeRef::Unit) { + bail!("Method `{method}`: error type cannot be unit") + } + Ok(WirePayload::Raw(inner.clone())) + } + } +} + +fn versioned_wrapper_root<'a>( + method: &str, + role: &str, + ty: &'a TypeRef, + versioned_wrappers: &BTreeSet, +) -> Result<&'a str> { + let TypeRef::Named { name, args } = ty else { + bail!("Method `{method}`: {role} is not a versioned wrapper") + }; + if !args.is_empty() || !versioned_wrappers.contains(name) { + bail!("Method `{method}`: {role} is not a versioned wrapper") + } + Ok(name) +} + +fn versioned_wrapper_names(api: &ApiDefinition) -> BTreeSet { + api.types + .iter() + .filter_map(|ty| { + let TypeDefKind::Enum(variants) = &ty.kind else { + return None; + }; + if variants.iter().all(|variant| { + variant + .name + .strip_prefix('V') + .is_some_and(|version| version.parse::().is_ok()) + }) { + Some(ty.name.clone()) + } else { + None + } + }) + .collect() +} + +fn rust_type_ref(ty: &TypeRef) -> Result { + match ty { + TypeRef::Primitive(name) => Ok(match name.as_str() { + "str" => "String".to_string(), + "compact" => "u128".to_string(), + "optionBool" => "parity_scale_codec::OptionBool".to_string(), + other => other.to_string(), + }), + TypeRef::Named { name, args } if name == "CallError" && args.len() == 1 => { + Ok(format!("truapi::CallError<{}>", rust_type_ref(&args[0])?)) + } + TypeRef::Named { name, args } if args.is_empty() => { + if let Some((version, base)) = version_prefixed_type(name) { + Ok(format!("truapi::v{version:02}::{base}")) + } else { + Ok(format!("truapi::v01::{name}")) + } + } + TypeRef::Named { name, args } => { + let args = args + .iter() + .map(rust_type_ref) + .collect::>>()? + .join(", "); + Ok(format!("truapi::v01::{name}<{args}>")) + } + TypeRef::Vec(inner) => Ok(format!("Vec<{}>", rust_type_ref(inner)?)), + TypeRef::Option(inner) => Ok(format!("Option<{}>", rust_type_ref(inner)?)), + TypeRef::Tuple(items) => { + let items = items + .iter() + .map(rust_type_ref) + .collect::>>()? + .join(", "); + Ok(format!("({items})")) + } + TypeRef::Array(inner, len) => Ok(format!("[{}; {len}]", rust_type_ref(inner)?)), + TypeRef::Generic(name) => Ok(name.clone()), + TypeRef::Unit => Ok("()".to_string()), + } +} + +fn version_prefixed_type(name: &str) -> Option<(u32, &str)> { + let rest = name.strip_prefix('V')?; + if rest.len() < 3 { + return None; + } + let (version, base) = rest.split_at(2); + if base.is_empty() { + return None; + } + Some((version.parse().ok()?, base)) +} + +fn call_error_inner(ty: &TypeRef) -> Option<&TypeRef> { + match ty { + TypeRef::Named { name, args } if name == "CallError" && args.len() == 1 => Some(&args[0]), + _ => None, + } +} + +/// Append `block` to `out`, prefixing every non-empty line with `indent` spaces. +fn write_indented(out: &mut String, indent: usize, block: &str) { + let pad = " ".repeat(indent); + for line in block.lines() { + if line.is_empty() { + out.push('\n'); + } else { + writeln!(out, "{pad}{line}").unwrap(); + } + } +} + +fn write_header(out: &mut String) { + writedoc!( + out, + r#" + //! Wire dispatcher for the unified `TrUApi` trait. + //! + //! Auto-generated by truapi-codegen. Do not edit. + + "# + ) + .unwrap(); +} + +fn write_imports(out: &mut String, traits: &[&TraitDef]) { + let has_testing = traits.iter().any(|trait_def| trait_def.name == "Testing"); + writedoc!( + out, + r#" + use std::sync::Arc; + + use parity_scale_codec::Decode; + + use truapi::CallContext; + use truapi::api::{{ + "# + ) + .unwrap(); + for trait_def in traits { + if trait_def.name == "Testing" { + continue; + } + writeln!(out, " {},", trait_def.name).unwrap(); + } + writedoc!( + out, + r#" + }}; + use truapi::versioned::{{self, Versioned}}; + + use crate::dispatcher::Dispatcher; + use crate::frame::encode_raw_err_payload; + use crate::frame::encode_raw_unit_ok_payload; + use crate::frame::encode_versioned_err_payload; + use crate::frame::encode_versioned_interrupt_payload; + use crate::frame::encode_versioned_ok_payload; + use crate::frame::encode_versioned_unit_ok_payload; + use crate::generated::wire_table; + use crate::subscription::subscription_stream; + "# + ) + .unwrap(); + if has_testing { + writeln!(out, "#[cfg(debug_assertions)]").unwrap(); + writeln!(out, "use truapi::api::Testing;").unwrap(); + } +} + +fn write_top_register(out: &mut String, traits: &[&TraitDef]) { + writedoc!( + out, + r#" + /// Register every TrUAPI method with the dispatcher. + pub fn register

(dispatcher: &mut Dispatcher, host: Arc

) + where + P: truapi::api::TrUApi + 'static, + {{ + "# + ) + .unwrap(); + let last = traits.len().saturating_sub(1); + for (idx, trait_def) in traits.iter().enumerate() { + let host_expr = if idx == last { "host" } else { "host.clone()" }; + let module = module_for_trait(&trait_def.name); + if trait_def.name == "Testing" { + writeln!(out, " #[cfg(debug_assertions)]").unwrap(); + } + writeln!(out, " register_{module}(dispatcher, {host_expr});").unwrap(); + } + writeln!(out, "}}").unwrap(); +} diff --git a/rust/crates/truapi-codegen/src/rust/wire_table.rs b/rust/crates/truapi-codegen/src/rust/wire_table.rs new file mode 100644 index 00000000..6c63a4f8 --- /dev/null +++ b/rust/crates/truapi-codegen/src/rust/wire_table.rs @@ -0,0 +1,303 @@ +//! Emits `wire_table.rs`: the (id, tag) lookup table the server uses to +//! pair incoming wire frames with their request, response, or +//! subscription role. +//! +//! Per-method `#[wire(...)]` annotations decide id assignment: +//! - request methods reserve `(request_id, response_id)`. +//! - subscription methods reserve `(start_id, stop_id, interrupt_id, receive_id)`. +//! +//! Missing annotations and collisions both hard-fail codegen. + +use std::collections::BTreeMap; +use std::fmt::Write; + +use anyhow::{Result, bail}; +use indoc::{formatdoc, writedoc}; + +use crate::rustdoc::*; + +use super::{const_name, wire_method_name}; + +#[derive(Debug, Clone, Copy)] +struct WireEntry { + request_id: u8, + response_id: u8, +} + +#[derive(Debug, Clone, Copy)] +struct SubEntry { + start_id: u8, + stop_id: u8, + interrupt_id: u8, + receive_id: u8, +} + +#[derive(Debug, Clone, Copy)] +enum MethodEntry { + Request(WireEntry), + Subscription(SubEntry), +} + +/// Emit the contents of `wire_table.rs`. +pub fn generate_wire_table(api: &ApiDefinition) -> Result { + let mut method_entries: Vec<(String, MethodEntry, bool)> = Vec::new(); + let mut seen: BTreeMap = BTreeMap::new(); + let mut seen_methods: BTreeMap = BTreeMap::new(); + + for trait_def in &api.traits { + for method in &trait_def.methods { + let entry = method_entry(trait_def, method)?; + let wire_method = wire_method_name(&trait_def.name, &method.name); + if let Some(existing) = seen_methods.insert( + wire_method.clone(), + format!("{}::{}", trait_def.name, method.name), + ) { + bail!( + "wire method name `{wire_method}` reused: `{existing}` and `{}::{}` collide", + trait_def.name, + method.name + ); + } + insert_entry(&mut seen, &wire_method, entry)?; + method_entries.push((wire_method, entry, trait_def.name == "Testing")); + } + } + + method_entries.sort_by_key(|(_, entry, _)| match entry { + MethodEntry::Request(WireEntry { request_id, .. }) => *request_id, + MethodEntry::Subscription(SubEntry { start_id, .. }) => *start_id, + }); + + render(&method_entries) +} + +fn method_entry(trait_def: &TraitDef, method: &MethodDef) -> Result { + let wire = &method.wire; + match method.kind { + MethodKind::Request => { + if wire.start_id.is_some() + || wire.stop_id.is_some() + || wire.interrupt_id.is_some() + || wire.receive_id.is_some() + { + bail!( + "method `{}::{}` is a request and must not use subscription wire ids", + trait_def.name, + method.name + ); + } + let request_id = wire.request_id.ok_or_else(|| { + anyhow::anyhow!( + "method `{}::{}` is missing #[wire(request_id = N)] annotation", + trait_def.name, + method.name + ) + })?; + let response_id = infer_id(wire.response_id, request_id, 1, &method.name)?; + Ok(MethodEntry::Request(WireEntry { + request_id, + response_id, + })) + } + MethodKind::Subscription | MethodKind::ResultSubscription => { + if wire.request_id.is_some() || wire.response_id.is_some() { + bail!( + "method `{}::{}` is a subscription and must not use request wire ids", + trait_def.name, + method.name + ); + } + let start_id = wire.start_id.ok_or_else(|| { + anyhow::anyhow!( + "method `{}::{}` is missing #[wire(start_id = N)] annotation", + trait_def.name, + method.name + ) + })?; + let stop_id = infer_id(wire.stop_id, start_id, 1, &method.name)?; + let interrupt_id = infer_id(wire.interrupt_id, start_id, 2, &method.name)?; + let receive_id = infer_id(wire.receive_id, start_id, 3, &method.name)?; + Ok(MethodEntry::Subscription(SubEntry { + start_id, + stop_id, + interrupt_id, + receive_id, + })) + } + } +} + +fn infer_id(explicit: Option, anchor: u8, offset: u8, method_name: &str) -> Result { + if let Some(id) = explicit { + return Ok(id); + } + anchor + .checked_add(offset) + .ok_or_else(|| anyhow::anyhow!("wire id overflow on `{method_name}` (base {anchor})")) +} + +fn insert_entry( + seen: &mut BTreeMap, + method_name: &str, + entry: MethodEntry, +) -> Result<()> { + let pairs: Vec<(u8, String)> = match entry { + MethodEntry::Request(WireEntry { + request_id, + response_id, + }) => vec![ + (request_id, format!("{method_name}_request")), + (response_id, format!("{method_name}_response")), + ], + MethodEntry::Subscription(SubEntry { + start_id, + stop_id, + interrupt_id, + receive_id, + }) => vec![ + (start_id, format!("{method_name}_start")), + (stop_id, format!("{method_name}_stop")), + (interrupt_id, format!("{method_name}_interrupt")), + (receive_id, format!("{method_name}_receive")), + ], + }; + for (id, tag) in pairs { + if let Some(existing) = seen.insert(id, tag.clone()) { + bail!("wire id {id} reused: `{existing}` and `{tag}` collide"); + } + } + Ok(()) +} + +fn render(methods: &[(String, MethodEntry, bool)]) -> Result { + let mut out = String::new(); + writedoc!( + out, + r#" + //! Wire-protocol discriminant table. + //! + //! Auto-generated by truapi-codegen. Do not edit. + //! + //! Each method reserves either two ids (request/response) or four + //! (start/stop/interrupt/receive). The ids for each method are exposed + //! as a named const (`PREIMAGE_SUBMIT`, ...); [`WIRE_TABLE`] and the + //! generated dispatcher both reference those consts so the numbers live + //! in exactly one place. The table is sorted by request/start id. + + /// Request method wire discriminants. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct RequestFrameIds {{ + /// Discriminant for the request frame. + pub request_id: u8, + /// Discriminant for the response frame. + pub response_id: u8, + }} + + /// Subscription method wire discriminants. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct SubscriptionFrameIds {{ + /// Discriminant for the start frame. + pub start_id: u8, + /// Discriminant for the stop frame. + pub stop_id: u8, + /// Discriminant for the interrupt frame (server-initiated termination). + pub interrupt_id: u8, + /// Discriminant for each receive frame (a streamed item). + pub receive_id: u8, + }} + + /// A single wire-table row. + pub struct WireEntry {{ + /// Method name from the Rust trait. + pub method: &'static str, + /// What kind of slot this entry describes. + pub kind: WireKind, + }} + + /// Wire-slot shape: request/response pair or subscription quartet. + pub enum WireKind {{ + /// Request/response method. + Request(RequestFrameIds), + /// Subscription method. + Subscription(SubscriptionFrameIds), + }} + "# + ) + .unwrap(); + + // Per-method consts: the single source of truth for each method's ids. + for (name, entry, debug_only) in methods { + let konst = const_name(name); + if *debug_only { + out.push('\n'); + out.push_str("#[cfg(debug_assertions)]"); + } + let block = match entry { + MethodEntry::Request(WireEntry { + request_id, + response_id, + }) => formatdoc! { + r#" + /// Wire discriminants for `{name}`. + pub const {konst}: RequestFrameIds = RequestFrameIds {{ + request_id: {request_id}, + response_id: {response_id}, + }}; + "# + }, + MethodEntry::Subscription(SubEntry { + start_id, + stop_id, + interrupt_id, + receive_id, + }) => formatdoc! { + r#" + /// Wire discriminants for `{name}`. + pub const {konst}: SubscriptionFrameIds = SubscriptionFrameIds {{ + start_id: {start_id}, + stop_id: {stop_id}, + interrupt_id: {interrupt_id}, + receive_id: {receive_id}, + }}; + "# + }, + }; + out.push('\n'); + out.push_str(&block); + } + + out.push('\n'); + writedoc!( + out, + r#" + /// The full wire table. Ordering is part of the wire protocol; + /// only ever append. Removed methods leave their slot empty. + pub const WIRE_TABLE: &[WireEntry] = &[ + "# + ) + .unwrap(); + for (name, entry, debug_only) in methods { + let konst = const_name(name); + let variant = match entry { + MethodEntry::Request(_) => "Request", + MethodEntry::Subscription(_) => "Subscription", + }; + let block = formatdoc! { + r#" + WireEntry {{ + method: "{name}", + kind: WireKind::{variant}({konst}), + }}, + "# + }; + if *debug_only { + writeln!(out, " #[cfg(debug_assertions)]").unwrap(); + } + for line in block.lines() { + writeln!(out, " {line}").unwrap(); + } + } + writeln!(out, "];").unwrap(); + + Ok(out) +} diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index 5cc8c5a9..0dd136ee 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -6,9 +6,17 @@ use std::collections::{BTreeMap, HashMap}; use anyhow::{Context, Result, bail}; use serde::Deserialize; +/// Minimum rustdoc JSON `format_version` the extractors are tested against. +/// Emitted by nightly 2026-02-23 (rustc 1.95.0-nightly); older formats may +/// encode item shapes differently and are rejected outright. +const MIN_FORMAT_VERSION: u32 = 57; + /// Parsed rustdoc crate. IDs are integers but serialized as string keys in JSON maps. #[derive(Debug, Deserialize)] pub struct Crate { + /// rustdoc JSON format version stamped into the document. + #[serde(default)] + pub format_version: Option, pub index: HashMap, #[serde(default)] pub paths: HashMap, @@ -220,7 +228,7 @@ struct ItemCandidate { } #[derive(Debug, Default)] -struct NameContext { +pub(crate) struct NameContext { by_item_id: HashMap, by_path: HashMap, } @@ -242,9 +250,23 @@ impl NameContext { } /// Parses rustdoc JSON output into the minimal crate model used by the code -/// generator. +/// generator. Rejects documents older than [`MIN_FORMAT_VERSION`] because the +/// untyped walkers in this crate assume the format of recent nightlies. pub fn parse(json: &str) -> Result { - serde_json::from_str(json).context("Failed to parse rustdoc JSON") + let krate: Crate = serde_json::from_str(json).context("Failed to parse rustdoc JSON")?; + let Some(version) = krate.format_version else { + bail!( + "rustdoc JSON is missing `format_version`; regenerate it with \ + `cargo +nightly rustdoc --output-format json` (nightly 2026-02-23 or later)" + ); + }; + if version < MIN_FORMAT_VERSION { + bail!( + "rustdoc JSON format_version {version} is older than the tested minimum \ + {MIN_FORMAT_VERSION}; regenerate with nightly 2026-02-23 or later" + ); + } + Ok(krate) } /// Extracts the public traits and types that make up the generated API surface @@ -433,8 +455,11 @@ fn disambiguated_type_name(simple_name: &str, path: &[String]) -> String { if path.iter().any(|segment| segment == "versioned") { return simple_name.to_string(); } - if path.iter().any(|segment| segment == "v01") { - return format!("V01{simple_name}"); + if let Some(version) = path + .iter() + .find_map(|segment| version_module_number(segment)) + { + return format!("V{version:02}{simple_name}"); } let module = path .iter() @@ -445,6 +470,12 @@ fn disambiguated_type_name(simple_name: &str, path: &[String]) -> String { format!("{module}{simple_name}") } +fn version_module_number(segment: &str) -> Option { + segment + .strip_prefix('v') + .and_then(|value| value.parse::().ok()) +} + fn to_pascal_case(value: &str) -> String { value .split('_') @@ -853,7 +884,8 @@ fn extract_generic_arg( resolve_type(&generic, names) } -fn resolve_type(ty: &serde_json::Value, names: &NameContext) -> Result { +/// Resolve a rustdoc JSON type node into the internal type reference model. +pub(crate) fn resolve_type(ty: &serde_json::Value, names: &NameContext) -> Result { if let Some(name) = ty.get("generic").and_then(|value| value.as_str()) { return Ok(TypeRef::Generic(name.to_string())); } @@ -994,7 +1026,8 @@ fn expect_single_arg(type_name: &str, mut args: Vec) -> Result Ok(args.remove(0)) } -fn extract_struct( +/// Extract a struct item, including field docs and generic parameters. +pub(crate) fn extract_struct( item_id: &str, item: &Item, krate: &Crate, @@ -1095,7 +1128,8 @@ fn extract_struct( }) } -fn extract_enum( +/// Extract an enum item, including variant docs and field payloads. +pub(crate) fn extract_enum( item_id: &str, item: &Item, krate: &Crate, @@ -1297,7 +1331,8 @@ fn value_id(value: &serde_json::Value) -> Result { bail!("Expected rustdoc item id, got {}", summarize_json(value)) } -fn summarize_json(value: &serde_json::Value) -> String { +/// Render a bounded JSON snippet for diagnostics. +pub(crate) fn summarize_json(value: &serde_json::Value) -> String { const LIMIT: usize = 200; let mut text = @@ -1319,4 +1354,35 @@ mod tests { assert_eq!(clean_docs(Some(docs)).as_deref(), Some("Trait summary.")); } + + #[test] + fn parse_accepts_tested_format_version() { + let json = format!(r#"{{ "format_version": {MIN_FORMAT_VERSION}, "index": {{}} }}"#); + + assert!(parse(&json).is_ok()); + } + + #[test] + fn parse_rejects_missing_format_version() { + let err = parse(r#"{ "index": {} }"#).expect_err("missing format_version must error"); + + assert!( + format!("{err}").contains("missing `format_version`"), + "unexpected error: {err}" + ); + } + + #[test] + fn parse_rejects_old_format_version() { + let json = format!( + r#"{{ "format_version": {}, "index": {{}} }}"#, + MIN_FORMAT_VERSION - 1 + ); + let err = parse(&json).expect_err("old format_version must error"); + + assert!( + format!("{err}").contains("older than the tested minimum"), + "unexpected error: {err}" + ); + } } diff --git a/rust/crates/truapi-codegen/src/ts.rs b/rust/crates/truapi-codegen/src/ts.rs index d833b542..019c33f7 100644 --- a/rust/crates/truapi-codegen/src/ts.rs +++ b/rust/crates/truapi-codegen/src/ts.rs @@ -13,10 +13,12 @@ use crate::rustdoc::*; mod examples; mod explorer; +mod host_callbacks; mod playground; pub use examples::generate_client_examples; pub use explorer::generate_explorer; +pub use host_callbacks::generate as generate_host_callbacks; pub use playground::generate_playground_services; #[derive(Default)] @@ -30,23 +32,34 @@ struct CodecContext { /// qualifies every named type with `T.*`. Used by the client/playground/ /// examples generators that emit version-aliased public names (e.g. /// `T.HostAccountGetRequest`). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum NameMode { +#[derive(Clone, Copy, Debug, Default)] +enum NameMode<'a> { #[default] Public, + PreserveQualified, + Generated { + aliases: &'a BTreeMap, + }, } -fn resolve_named(name: &str, mode: NameMode) -> String { +fn resolve_named(name: &str, mode: NameMode<'_>) -> String { match mode { NameMode::Public => public_versioned_type_name(name), + NameMode::PreserveQualified => name.to_string(), + NameMode::Generated { aliases } => aliases + .get(name) + .cloned() + .unwrap_or_else(|| name.to_string()), } } /// Decide how to namespace a resolved type name for `qualified` rendering. /// `Public` prefixes every name with `T.*`. -fn qualify_named(resolved: &str, mode: NameMode) -> String { +fn qualify_named(resolved: &str, mode: NameMode<'_>) -> String { match mode { NameMode::Public => format!("T.{resolved}"), + NameMode::PreserveQualified => format!("T.{resolved}"), + NameMode::Generated { .. } => resolved.to_string(), } } @@ -147,6 +160,130 @@ fn selected_public_aliases( .collect() } +fn emitted_version_prefixed_types( + wrappers: &HashMap, + emit_versions: &HashMap>, + aliases: &BTreeMap, +) -> BTreeSet { + let mut names = BTreeSet::new(); + for (wrapper_name, versions) in emit_versions { + let Some(wrapper) = wrappers.get(wrapper_name) else { + continue; + }; + for version in versions { + let Some(variant) = wrapper.variants.get(version) else { + continue; + }; + collect_preserved_version_prefixed_types(&variant.kind, aliases, &mut names); + } + } + names +} + +fn preserve_version_prefixed_types_referenced_by_emitted_types( + api: &ApiDefinition, + aliases: &BTreeMap, + names: &mut BTreeSet, +) { + loop { + let before = names.len(); + for ty in &api.types { + if version_prefixed_type(&ty.name).is_some() + && !aliases.contains_key(&ty.name) + && !names.contains(&ty.name) + { + continue; + } + collect_preserved_version_prefixed_type_refs_from_type(ty, aliases, names); + } + if names.len() == before { + break; + } + } +} + +fn collect_preserved_version_prefixed_type_refs_from_type( + ty: &TypeDef, + aliases: &BTreeMap, + names: &mut BTreeSet, +) { + match &ty.kind { + TypeDefKind::Alias(type_ref) => { + collect_preserved_version_prefixed_type_refs(type_ref, aliases, names); + } + TypeDefKind::Struct(fields) => { + for field in fields { + collect_preserved_version_prefixed_type_refs(&field.type_ref, aliases, names); + } + } + TypeDefKind::TupleStruct(fields) => { + for field in fields { + collect_preserved_version_prefixed_type_refs(field, aliases, names); + } + } + TypeDefKind::Enum(variants) => { + for variant in variants { + match &variant.fields { + VariantFields::Unit => {} + VariantFields::Unnamed(fields) => { + for field in fields { + collect_preserved_version_prefixed_type_refs(field, aliases, names); + } + } + VariantFields::Named(fields) => { + for field in fields { + collect_preserved_version_prefixed_type_refs( + &field.type_ref, + aliases, + names, + ); + } + } + } + } + } + } +} + +fn collect_preserved_version_prefixed_types( + kind: &VersionedKind, + aliases: &BTreeMap, + names: &mut BTreeSet, +) { + match kind { + VersionedKind::Unit => {} + VersionedKind::Tuple(inner) => { + collect_preserved_version_prefixed_type_refs(inner, aliases, names); + } + } +} + +fn collect_preserved_version_prefixed_type_refs( + ty: &TypeRef, + aliases: &BTreeMap, + names: &mut BTreeSet, +) { + match ty { + TypeRef::Named { name, args } => { + if version_prefixed_type(name).is_some() && !aliases.contains_key(name) { + names.insert(name.clone()); + } + for arg in args { + collect_preserved_version_prefixed_type_refs(arg, aliases, names); + } + } + TypeRef::Vec(inner) | TypeRef::Option(inner) | TypeRef::Array(inner, _) => { + collect_preserved_version_prefixed_type_refs(inner, aliases, names); + } + TypeRef::Tuple(items) => { + for item in items { + collect_preserved_version_prefixed_type_refs(item, aliases, names); + } + } + TypeRef::Primitive(_) | TypeRef::Generic(_) | TypeRef::Unit => {} + } +} + #[derive(Debug, Clone)] enum VersionedKind { Unit, @@ -762,9 +899,19 @@ fn generate_types(api: &ApiDefinition, target_version: u32) -> Result { let wrappers = collect_versioned_wrappers(api); let emit_versions = versioned_wrapper_emit_versions(api, &wrappers, target_version)?; let aliases = selected_public_aliases(api, &wrappers, &emit_versions, target_version); + let mut preserved_version_prefixed_types = + emitted_version_prefixed_types(&wrappers, &emit_versions, &aliases); + preserve_version_prefixed_types_referenced_by_emitted_types( + api, + &aliases, + &mut preserved_version_prefixed_types, + ); for ty in &api.types { - if version_prefixed_type(&ty.name).is_some() && !aliases.contains_key(&ty.name) { + if version_prefixed_type(&ty.name).is_some() + && !aliases.contains_key(&ty.name) + && !preserved_version_prefixed_types.contains(&ty.name) + { continue; } write_type_definition(&mut out, ty, &emit_versions, &aliases)?; @@ -809,7 +956,7 @@ fn generate_client(api: &ApiDefinition, target_version: u32, codec_version: u8) .unwrap(); write_observable_helper(&mut out); - let ctx = CodecContext::default(); + let ctx = codec_context(&[]); let wrappers = collect_versioned_wrappers(api); let services = public_services(api)?; @@ -861,13 +1008,12 @@ fn generate_client(api: &ApiDefinition, target_version: u32, codec_version: u8) export type Client = TrUApiClient; - export type GeneratedClientTransport = Omit & - Partial>; + export type GeneratedClientTransport = Omit & + Partial>; - function withGeneratedTransportVersions(transport: GeneratedClientTransport): TrUApiTransport {{ + function withGeneratedCodecVersion(transport: GeneratedClientTransport): TrUApiTransport {{ return {{ ...transport, - truapiVersion: transport.truapiVersion ?? TRUAPI_VERSION, codecVersion: transport.codecVersion ?? TRUAPI_CODEC_VERSION, }}; }} @@ -875,7 +1021,7 @@ fn generate_client(api: &ApiDefinition, target_version: u32, codec_version: u8) /** Creates the generated client facade by binding each service namespace to the * shared transport instance. */ export function createClient(transport: GeneratedClientTransport): TrUApiClient {{ - const versionedTransport = withGeneratedTransportVersions(transport); + const transportWithCodecVersion = withGeneratedCodecVersion(transport); return {{ "# ) @@ -888,7 +1034,7 @@ fn generate_client(api: &ApiDefinition, target_version: u32, codec_version: u8) let field = to_camel_case(&trait_def.name); writeln!( out, - " {}: new {}Client(versionedTransport),", + " {}: new {}Client(transportWithCodecVersion),", field, trait_def.name ) .unwrap(); @@ -1186,27 +1332,47 @@ fn emit_error_response( ctx: &CodecContext, wire_version: Option, ) -> Result { - emit_response( - call_error_inner(ty).unwrap_or(ty), - wrappers, - ctx, - wire_version, - ) -} + let Some(error_wrapper_ty) = call_error_inner(ty) else { + return emit_response(ty, wrappers, ctx, wire_version); + }; -fn versioned_kind_codec_expr( - kind: &VersionedKind, - qualified: bool, - ctx: &CodecContext, -) -> Result { - versioned_kind_codec_expr_mode(kind, qualified, ctx, NameMode::Public) + if let Some((wrapper_name, _wrapper)) = versioned_wrapper_for(error_wrapper_ty, wrappers) { + let version = wire_version.ok_or_else(|| { + anyhow::anyhow!("versioned error wrapper `{wrapper_name}` has no selected wire version") + })?; + let versioned_name = versioned_wrapper_ts_name(wrapper_name); + let inner_type_ts = format!("S.CallErrorValue"); + let inner_codec_expr = format!("S.CallError(T.{versioned_name})"); + let wire_codec_expr = indexed_versioned_codec_expr([(version, inner_codec_expr.clone())])?; + return Ok(ResponseEmission { + inner_type_ts: inner_type_ts.clone(), + wire_type_ts: format!("{{ tag: \"V{version}\"; value: {inner_type_ts} }}"), + wire_codec_expr, + inner_codec_expr, + }); + } + + let inner_type_ts = format!( + "S.CallErrorValue<{}>", + ts_type_qualified_preserve(error_wrapper_ty)? + ); + let inner_codec_expr = format!( + "S.CallError({})", + codec_expr_mode(error_wrapper_ty, true, ctx, NameMode::PreserveQualified)? + ); + Ok(ResponseEmission { + inner_type_ts: inner_type_ts.clone(), + wire_type_ts: inner_type_ts, + wire_codec_expr: inner_codec_expr.clone(), + inner_codec_expr, + }) } fn versioned_kind_codec_expr_mode( kind: &VersionedKind, qualified: bool, ctx: &CodecContext, - mode: NameMode, + mode: NameMode<'_>, ) -> Result { match kind { VersionedKind::Unit => Ok("S._void".to_string()), @@ -1441,6 +1607,7 @@ fn write_type_definition( emit_versions: &HashMap>, aliases: &BTreeMap, ) -> Result<()> { + let generated_names = NameMode::Generated { aliases }; let generic_decl = generic_param_declaration(&ty.generic_params); let emitted_name = if should_rename_wire_wrapper(ty, emit_versions, aliases) { versioned_wrapper_ts_name(&ty.name) @@ -1456,7 +1623,7 @@ fn write_type_definition( writeln!( out, "export type {emitted_name}{generic_decl} = {};", - ts_type(type_ref)? + ts_type_with_named(type_ref, false, generated_names)? ) .unwrap(); } @@ -1466,9 +1633,19 @@ fn write_type_definition( let (ts_name, optional) = ts_field_name(&field.name, &field.type_ref); write_jsdoc(out, " ", field.docs.as_deref()); if optional { - writeln!(out, " {ts_name}?: {};", ts_inner_option(&field.type_ref)?).unwrap(); + writeln!( + out, + " {ts_name}?: {};", + ts_inner_option_with_named(&field.type_ref, false, generated_names)? + ) + .unwrap(); } else { - writeln!(out, " {ts_name}: {};", ts_type(&field.type_ref)?).unwrap(); + writeln!( + out, + " {ts_name}: {};", + ts_type_with_named(&field.type_ref, false, generated_names)? + ) + .unwrap(); } } writeln!(out, "}}").unwrap(); @@ -1477,7 +1654,7 @@ fn write_type_definition( writeln!( out, "export type {emitted_name}{generic_decl} = {};", - unnamed_fields_type(fields)? + unnamed_fields_type_mode(fields, false, generated_names)? ) .unwrap(); } @@ -1505,7 +1682,12 @@ fn write_type_definition( } } write_jsdoc(out, " ", variant.docs.as_deref()); - writeln!(out, " | {}", enum_variant_ts_type(variant)?).unwrap(); + writeln!( + out, + " | {}", + enum_variant_ts_type_mode(variant, generated_names)? + ) + .unwrap(); } writeln!(out, ";").unwrap(); } @@ -1521,8 +1703,9 @@ fn write_codec_definition( emit_versions: &HashMap>, aliases: &BTreeMap, ) -> Result<()> { + let generated_names = NameMode::Generated { aliases }; if ty.generic_params.is_empty() { - let ctx = CodecContext::default(); + let ctx = codec_context(&[]); if let Some(wrapper) = detect_versioned_wrapper(ty) { let selected = emit_versions.get(&ty.name); let emitted_name = if should_rename_wire_wrapper(ty, emit_versions, aliases) { @@ -1542,7 +1725,12 @@ fn write_codec_definition( .map(|variant| { Ok(( variant.version, - versioned_kind_codec_expr(&variant.kind, false, &ctx)?, + versioned_kind_codec_expr_mode( + &variant.kind, + false, + &ctx, + generated_names, + )?, )) }) .collect::>>()?, @@ -1560,7 +1748,8 @@ fn write_codec_definition( .map(String::as_str) .unwrap_or(&ty.name); let type_name = top_level_type_name(emitted_name, &ty.generic_params); - let codec_expr = type_codec_expr(ty, &type_name, &ctx)?; + let codec_expr = + type_codec_expr_mode_qualified(ty, &type_name, &ctx, generated_names, false)?; writeln!( out, "export const {emitted_name}: S.Codec<{type_name}> = S.lazy((): S.Codec<{type_name}> => {codec_expr});", @@ -1599,7 +1788,7 @@ fn write_codec_definition( .get(&ty.name) .map(String::as_str) .unwrap_or(&ty.name); - let codec_body = type_codec_expr(ty, &type_name, &ctx)?; + let codec_body = type_codec_expr_mode_qualified(ty, &type_name, &ctx, generated_names, false)?; writedoc!( out, " @@ -1622,15 +1811,11 @@ fn should_rename_wire_wrapper( && (emit_versions.contains_key(&ty.name) || aliases.values().any(|alias| alias == &ty.name)) } -fn type_codec_expr(ty: &TypeDef, type_name: &str, ctx: &CodecContext) -> Result { - type_codec_expr_mode_qualified(ty, type_name, ctx, NameMode::Public, false) -} - fn type_codec_expr_mode_qualified( ty: &TypeDef, type_name: &str, ctx: &CodecContext, - mode: NameMode, + mode: NameMode<'_>, qualified: bool, ) -> Result { match &ty.kind { @@ -1704,7 +1889,7 @@ fn unit_enum_summary(variants: &[VariantDef]) -> String { ) } -fn variant_value_type_mode(fields: &VariantFields, mode: NameMode) -> Result { +fn variant_value_type_mode(fields: &VariantFields, mode: NameMode<'_>) -> Result { let qualified = false; match fields { VariantFields::Unit => Ok("undefined".to_string()), @@ -1721,7 +1906,7 @@ fn enum_variant_ts_type(variant: &VariantDef) -> Result { enum_variant_ts_type_mode(variant, NameMode::Public) } -fn enum_variant_ts_type_mode(variant: &VariantDef, mode: NameMode) -> Result { +fn enum_variant_ts_type_mode(variant: &VariantDef, mode: NameMode<'_>) -> Result { Ok(match &variant.fields { VariantFields::Unit => format!("{{ tag: \"{}\"; value?: undefined }}", variant.name), fields => format!( @@ -1736,7 +1921,7 @@ fn variant_codec_expr_mode( fields: &VariantFields, qualified: bool, ctx: &CodecContext, - mode: NameMode, + mode: NameMode<'_>, ) -> Result { match fields { VariantFields::Unit => Ok("S._void".to_string()), @@ -1757,7 +1942,11 @@ fn unnamed_fields_type(types: &[TypeRef]) -> Result { unnamed_fields_type_mode(types, false, NameMode::Public) } -fn unnamed_fields_type_mode(types: &[TypeRef], qualified: bool, mode: NameMode) -> Result { +fn unnamed_fields_type_mode( + types: &[TypeRef], + qualified: bool, + mode: NameMode<'_>, +) -> Result { if types.is_empty() { Ok("undefined".to_string()) } else if types.len() == 1 { @@ -1778,7 +1967,7 @@ fn unnamed_fields_codec_expr_mode( types: &[TypeRef], qualified: bool, ctx: &CodecContext, - mode: NameMode, + mode: NameMode<'_>, ) -> Result { if types.is_empty() { Ok("S._void".to_string()) @@ -1799,7 +1988,7 @@ fn struct_codec_expr_mode( type_name: &str, qualified: bool, ctx: &CodecContext, - mode: NameMode, + mode: NameMode<'_>, ) -> Result { let field_specs = fields .iter() @@ -1818,7 +2007,11 @@ fn struct_codec_expr_mode( )) } -fn inline_object_type_mode(fields: &[FieldDef], qualified: bool, mode: NameMode) -> Result { +fn inline_object_type_mode( + fields: &[FieldDef], + qualified: bool, + mode: NameMode<'_>, +) -> Result { Ok(format!( "{{ {} }}", fields @@ -1856,7 +2049,7 @@ fn method_payload_codec_expr_mode( params: &[ParamDef], qualified: bool, ctx: &CodecContext, - mode: NameMode, + mode: NameMode<'_>, ) -> Result { match params.len() { 0 => Ok("S._void".to_string()), @@ -1880,7 +2073,7 @@ fn codec_expr_mode( ty: &TypeRef, qualified: bool, ctx: &CodecContext, - mode: NameMode, + mode: NameMode<'_>, ) -> Result { match ty { TypeRef::Primitive(name) => match name.as_str() { @@ -1969,7 +2162,7 @@ fn ts_type(ty: &TypeRef) -> Result { ts_type_with_named(ty, false, NameMode::Public) } -fn ts_type_with_named(ty: &TypeRef, qualified: bool, mode: NameMode) -> Result { +fn ts_type_with_named(ty: &TypeRef, qualified: bool, mode: NameMode<'_>) -> Result { match ty { TypeRef::Primitive(name) => match name.as_str() { "bool" => Ok("boolean".to_string()), @@ -2052,7 +2245,7 @@ fn ts_inner_option(ty: &TypeRef) -> Result { ts_inner_option_with_named(ty, false, NameMode::Public) } -fn ts_inner_option_with_named(ty: &TypeRef, qualified: bool, mode: NameMode) -> Result { +fn ts_inner_option_with_named(ty: &TypeRef, qualified: bool, mode: NameMode<'_>) -> Result { match ty { TypeRef::Option(inner) => ts_type_with_named(inner, qualified, mode), other => ts_type_with_named(other, qualified, mode), @@ -2063,6 +2256,10 @@ fn ts_type_qualified(ty: &TypeRef) -> Result { ts_type_with_named(ty, true, NameMode::Public) } +fn ts_type_qualified_preserve(ty: &TypeRef) -> Result { + ts_type_with_named(ty, true, NameMode::PreserveQualified) +} + fn ts_field_name(name: &str, ty: &TypeRef) -> (String, bool) { let camel = to_camel_case(name); let optional = matches!(ty, TypeRef::Option(_)); @@ -2073,7 +2270,7 @@ fn payload_type(params: &[ParamDef]) -> Result { payload_type_mode(params, NameMode::Public) } -fn payload_type_mode(params: &[ParamDef], mode: NameMode) -> Result { +fn payload_type_mode(params: &[ParamDef], mode: NameMode<'_>) -> Result { match params.len() { 0 => Ok("undefined".to_string()), 1 => ts_type_with_named(¶ms[0].type_ref, true, mode), @@ -2294,6 +2491,20 @@ mod tests { versioned_tuple_wrapper_variants(name, &[(1, legacy), (2, latest)]) } + fn single_field_struct(name: &str, field_name: &str, field_type: &str) -> TypeDef { + TypeDef { + name: name.to_string(), + module_path: Vec::new(), + generic_params: Vec::new(), + kind: TypeDefKind::Struct(vec![FieldDef { + name: field_name.to_string(), + type_ref: TypeRef::Primitive(field_type.to_string()), + docs: None, + }]), + docs: None, + } + } + fn named_field_versioned_wrapper(name: &str) -> TypeDef { let fields = vec![ FieldDef { @@ -2663,6 +2874,73 @@ mod tests { assert!(client_source.contains("ResultAsync")); } + #[test] + fn generate_types_preserves_legacy_prefixed_wrapper_variants() { + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Example".to_string(), + module_path: Vec::new(), + methods: vec![ + MethodDef { + name: "legacy_call".to_string(), + kind: MethodKind::Request, + params: vec![ParamDef { + name: "request".to_string(), + type_ref: named_type("LegacyRequest"), + }], + return_type: ReturnType::Result { + ok: TypeRef::Unit, + err: named_type("ExampleError"), + }, + wire: request_wire(Some(2)), + docs: None, + }, + MethodDef { + name: "latest_call".to_string(), + kind: MethodKind::Request, + params: vec![ParamDef { + name: "request".to_string(), + type_ref: named_type("LatestRequest"), + }], + return_type: ReturnType::Result { + ok: TypeRef::Unit, + err: named_type("ExampleError"), + }, + wire: request_wire(Some(4)), + docs: None, + }, + ], + docs: None, + }], + public_trait_order: vec!["Example".to_string()], + types: vec![ + versioned_tuple_wrapper_variants("LegacyRequest", &[(1, "V01LegacyRequest")]), + versioned_tuple_wrapper_variants("LatestRequest", &[(2, "V02LatestRequest")]), + versioned_tuple_wrapper_variants( + "ExampleError", + &[(1, "V01ExampleError"), (2, "V02ExampleError")], + ), + single_field_struct("V01LegacyRequest", "legacy_marker", "u8"), + single_field_struct("V02LatestRequest", "latest_marker", "u32"), + single_field_struct("V01ExampleError", "legacy_code", "u8"), + single_field_struct("V02ExampleError", "latest_code", "u32"), + ], + }; + + let source = generate_types(&api, 2).expect("generate types"); + + assert!(source.contains("export interface V01ExampleError")); + assert!(source.contains("legacyCode: number;")); + assert!(source.contains("export interface ExampleError")); + assert!(source.contains("latestCode: number;")); + assert!(!source.contains("export interface V02ExampleError")); + assert!(source.contains(r#"{ tag: "V1"; value: V01ExampleError }"#)); + assert!(source.contains(r#"{ tag: "V2"; value: ExampleError }"#)); + assert!(source.contains( + "S.indexedTaggedUnion({V1: [0, V01ExampleError] as const, V2: [1, ExampleError] as const})" + )); + } + #[test] fn generate_client_uses_only_existing_wrapper_variant() { let api = ApiDefinition { diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs new file mode 100644 index 00000000..4d3f1882 --- /dev/null +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -0,0 +1,1483 @@ +//! Emit the typed `HostCallbacks` TypeScript surface from a parsed +//! `truapi-platform` rustdoc tree. +//! +//! One TS interface per Rust capability trait, plus a composite +//! `HostCallbacks` super-interface that mirrors the `Platform: A + B + ...` +//! super-trait so consumers can implement capabilities piecemeal. Named +//! types in trait signatures (e.g. `HostDevicePermissionRequest`) resolve +//! against `@parity/truapi`, the canonical typed client surface. + +use std::collections::BTreeSet; +use std::fmt::Write; +use std::fs; +use std::path::Path; + +use anyhow::{Result, bail}; +use indoc::{formatdoc, writedoc}; + +use crate::platform::{ + PlatformDefinition, PlatformInner, PlatformMethod, PlatformParam, PlatformReturn, PlatformTrait, +}; +use crate::rustdoc::{FieldDef, TypeDef, TypeDefKind, TypeRef, VariantDef, VariantFields}; +use crate::ts::ts_string_literal; + +/// Write the typed host-callbacks TS file and its WASM adapter/worker bridge. +/// +/// `codec_types` is the set of `@parity/truapi` type names that carry a SCALE +/// codec (structs and enums, not primitive aliases); the adapter crosses those +/// as bytes and passes everything else through unchanged. +pub fn generate( + definition: &PlatformDefinition, + codec_types: &BTreeSet, + callbacks_output_dir: &str, + adapter_output_dir: &str, +) -> 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 body = emit_host_callbacks(definition, codec_types, &local_codec_types)?; + fs::write( + Path::new(callbacks_output_dir).join("host-callbacks.ts"), + body, + )?; + let adapter = emit_wasm_adapter(definition, codec_types, &local_codec_types)?; + fs::write( + Path::new(adapter_output_dir).join("host-callbacks-adapter.ts"), + adapter, + )?; + let worker_callbacks = emit_worker_callbacks(definition, codec_types, &local_codec_types)?; + fs::write( + Path::new(adapter_output_dir).join("worker-callbacks.ts"), + worker_callbacks, + )?; + Ok(()) +} + +/// Emit one `import`/`import type` block listing `names` (one per line) from +/// `module`. No-op when `names` is empty. Does not emit a trailing blank line. +fn emit_import_block(out: &mut String, type_only: bool, module: &str, names: &BTreeSet) { + if names.is_empty() { + return; + } + let entries = names + .iter() + .map(|name| format!(" {name},")) + .collect::>() + .join("\n"); + let keyword = if type_only { "import type" } else { "import" }; + writedoc!( + out, + r#" + {keyword} {{ + {entries} + }} from "{module}"; + "#, + ) + .unwrap(); +} + +fn emit_host_callbacks( + definition: &PlatformDefinition, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, +) -> Result { + let mut out = String::new(); + writedoc!( + out, + r#" + // Auto-generated by truapi-codegen. Do not edit. + // + // Typed host-callbacks surface derived from the `truapi-platform` + // capability traits. One interface per Rust trait + a composite + // `HostCallbacks` interface that mirrors the `Platform` super-trait. + + "#, + ) + .unwrap(); + + let codec_imports = collect_local_codec_imports(definition, codec_types, local_codec_types); + if !codec_imports.is_empty() || !local_codec_types.is_empty() { + writedoc!( + out, + r#" + import * as S from "@parity/truapi/scale"; + + "#, + ) + .unwrap(); + } + if !codec_imports.is_empty() { + emit_import_block(&mut out, false, "@parity/truapi", &codec_imports); + out.push('\n'); + } + + let imports = collect_named_types(definition) + .into_iter() + .filter(|name| !codec_imports.contains(name)) + .collect::>(); + if !imports.is_empty() { + emit_import_block(&mut out, true, "@parity/truapi", &imports); + out.push('\n'); + } + + for type_def in &definition.types { + let rendered = match &type_def.kind { + TypeDefKind::Enum(_) => emit_enum_type(type_def)?, + _ => emit_struct_interface(type_def)?, + }; + out.push_str(&rendered); + out.push('\n'); + } + for type_def in definition + .types + .iter() + .filter(|ty| local_codec_types.contains(&ty.name)) + { + out.push_str(&emit_local_codec(type_def)?); + out.push('\n'); + } + + for trait_def in &definition.traits { + out.push_str(&emit_trait_interface(trait_def)?); + out.push('\n'); + } + + // The Rust super-trait `Platform` becomes `HostCallbacks` on the TS + // surface: that is the name every host implementer reaches for, and + // it stays stable even if the Rust trait is renamed. + let (composes, docs): (Vec, Option<&str>) = match &definition.super_trait { + Some(s) => (s.composes.clone(), s.docs.as_deref()), + None => ( + definition.traits.iter().map(|t| t.name.clone()).collect(), + None, + ), + }; + out.push_str(&emit_super_interface("HostCallbacks", &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. +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. + let traits = composed_traits(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`. + // Anything else named in a signature (e.g. the `NotificationId` alias) is a + // non-codec `@parity/truapi` type the `RawCallbacks` interface imports for + // its type only. + let local = local_names(definition); + let mut imports: BTreeSet = BTreeSet::new(); + let mut extra_types: BTreeSet = BTreeSet::new(); + let mut adapter_local_codec_types: BTreeSet = BTreeSet::new(); + for trait_def in &traits { + for method in &trait_def.methods { + for param in &method.params { + collect_codec_imports(¶m.type_ref, codec_types, &mut imports); + collect_local_codec_names( + ¶m.type_ref, + local_codec_types, + &mut adapter_local_codec_types, + ); + collect_extra_named(¶m.type_ref, codec_types, &local, &mut extra_types); + } + match &method.return_shape.inner { + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + collect_codec_imports(ok, codec_types, &mut imports); + collect_extra_named(ok, codec_types, &local, &mut extra_types); + } + PlatformInner::Stream(item) => { + collect_codec_imports(stream_item(item), codec_types, &mut imports) + } + PlatformInner::Unit | PlatformInner::TraitObject(_) => {} + } + } + } + + let mut out = String::new(); + writedoc!( + out, + r#" + // 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. + + "#, + ) + .unwrap(); + emit_import_block(&mut out, false, "@parity/truapi", &imports); + emit_import_block(&mut out, true, "@parity/truapi", &extra_types); + emit_import_block( + &mut out, + false, + "./host-callbacks.js", + &adapter_local_codec_types, + ); + writedoc!( + out, + r#" + import type {{ AuthState, HostCallbacks }} from "./host-callbacks.js"; + import type {{ ChainConnect }} from "../runtime.js"; + import {{ + chainConnectAdapter, + driveResultStream, + }} from "../adapter-support.js"; + + "#, + ) + .unwrap(); + out.push_str(&emit_raw_callbacks(&traits, codec_types, local_codec_types)); + writedoc!( + out, + r#" + /** Adapt typed host callbacks into the raw SCALE callback surface the + * WASM core invokes. */ + export function createWasmRawCallbacks( + host: Required, + ): RawCallbacks {{ + 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)?; + writeln!(out, " {entry}").unwrap(); + } + } + out.push_str(" };\n}\n"); + Ok(out) +} + +/// Emit the generated callback metadata/proxy used by the Web Worker bridge. +/// +/// The lifecycle/transport pieces stay hand-written in `worker-runtime.ts` and +/// `create-worker-host-runtime.ts`; this file owns the mechanical callback +/// name, arity, host-hook installation and subscription metadata that already +/// exists in the parsed `truapi-platform` trait surface. +fn emit_worker_callbacks( + definition: &PlatformDefinition, + _codec_types: &BTreeSet, + _local_codec_types: &BTreeSet, +) -> Result { + let traits = composed_traits(definition); + let mut callbacks = Vec::new(); + let mut subscriptions = Vec::new(); + + for trait_def in traits { + if trait_def.name == "ChainProvider" { + continue; + } + for method in &trait_def.methods { + match &method.return_shape.inner { + PlatformInner::Stream(_) => subscriptions.push(method), + _ => callbacks.push(method), + } + } + } + + let mut out = String::new(); + writedoc!( + out, + r#" + // Auto-generated by truapi-codegen. Do not edit. + // + // Worker-side metadata and proxy functions for the raw WASM callback + // surface. The worker transport/lifecycle remains hand-written; this + // 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(); + + out.push_str(&const_name_array("CALLBACK_NAMES", &callbacks)); + out.push_str("export type CallbackName = typeof CALLBACK_NAMES[number];\n\n"); + out.push_str(&const_name_array("SUBSCRIPTION_NAMES", &subscriptions)); + out.push_str("export type SubscriptionName = typeof SUBSCRIPTION_NAMES[number];\n\n"); + + writedoc!( + out, + r#" + export interface WorkerCallbackBridge {{ + callbackRequest(name: CallbackName, args: readonly unknown[]): Promise; + startSubscription( + name: SubscriptionName, + payload: Uint8Array | null, + sendItem: (value: T) => void, + ): () => void; + chainConnect: ChainConnect; + }} + + "# + ) + .unwrap(); + + out.push_str(&emit_worker_callback_factory( + "rawCallbacks", + "CallbackName", + &callbacks, + )?); + out.push('\n'); + out.push_str(&emit_worker_subscription_factory(&subscriptions)?); + out.push('\n'); + + writedoc!( + out, + r#" + export function createWorkerRawCallbacks( + bridge: WorkerCallbackBridge, + ): Record {{ + const callbacks: Record = {{ + ...rawCallbacks(bridge), + ...subscriptionRawCallbacks(bridge), + chainConnect: bridge.chainConnect, + }}; + return callbacks; + }} + + export function startRawSubscription( + callbacks: RawCallbacks, + name: SubscriptionName, + payload: Uint8Array | null, + sendItem: (value?: unknown) => void, + ): (() => void) | void {{ + "# + ) + .unwrap(); + out.push_str(&emit_start_raw_subscription_switch(&subscriptions)?); + writedoc!( + out, + r#" + }} + "# + ) + .unwrap(); + + 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 { + definition + .traits + .iter() + .map(|t| t.name.clone()) + .chain(definition.types.iter().map(|s| s.name.clone())) + .collect() +} + +fn const_name_array(const_name: &str, methods: &[&PlatformMethod]) -> String { + let entries = methods + .iter() + .map(|method| format!(" \"{}\",", to_camel_case(&method.name))) + .collect::>() + .join("\n"); + format!("export const {const_name} = [\n{entries}\n] as const;\n") +} + +fn emit_worker_callback_factory( + function_name: &str, + name_type: &str, + methods: &[&PlatformMethod], +) -> Result { + let mut out = String::new(); + writeln!( + out, + "function {function_name}(bridge: WorkerCallbackBridge): Required> {{" + ) + .unwrap(); + out.push_str(" return {\n"); + for method in methods { + out.push_str(&emit_worker_callback_entry(method)?); + } + out.push_str(" };\n"); + out.push_str("}\n"); + Ok(out) +} + +fn emit_worker_callback_entry(method: &PlatformMethod) -> Result { + let raw = to_camel_case(&method.name); + let args = method + .params + .iter() + .map(|p| to_camel_case(&p.name)) + .collect::>() + .join(", "); + let arg_array = if args.is_empty() { + "[]".to_string() + } else { + format!("[{args}]") + }; + if method.return_shape.is_async { + Ok(format!( + " {raw}: ({args}) =>\n bridge.callbackRequest(\"{raw}\", {arg_array}) as ReturnType,\n" + )) + } else { + match &method.return_shape.inner { + PlatformInner::Unit => Ok(format!( + " {raw}: ({args}) =>\n void bridge.callbackRequest(\"{raw}\", {arg_array}).catch(() => {{}}),\n" + )), + PlatformInner::Plain(_) => Ok(format!( + " {raw}: ({args}) =>\n bridge.callbackRequest(\"{raw}\", {arg_array}) as ReturnType,\n" + )), + PlatformInner::Result { .. } + | PlatformInner::Stream(_) + | PlatformInner::TraitObject(_) => { + bail!("unsupported non-async worker callback return shape for `{raw}`") + } + } + } +} + +fn emit_worker_subscription_factory(methods: &[&PlatformMethod]) -> Result { + let mut out = String::new(); + out.push_str( + "function subscriptionRawCallbacks(bridge: WorkerCallbackBridge): Required> {\n", + ); + out.push_str(" return {\n"); + for method in methods { + let raw = to_camel_case(&method.name); + let payload_param = worker_subscription_payload_param(method)?; + if let Some(param) = payload_param { + out.push_str(&format!( + " {raw}: ({param}, sendItem) =>\n bridge.startSubscription(\"{raw}\", {param}, sendItem),\n" + )); + } else { + out.push_str(&format!( + " {raw}: (sendItem) =>\n bridge.startSubscription(\"{raw}\", null, sendItem),\n" + )); + } + } + out.push_str(" };\n"); + out.push_str("}\n"); + Ok(out) +} + +fn emit_start_raw_subscription_switch(methods: &[&PlatformMethod]) -> Result { + let mut out = String::new(); + out.push_str(" switch (name) {\n"); + for method in methods { + let raw = to_camel_case(&method.name); + if worker_subscription_payload_param(method)?.is_some() { + out.push_str(&format!( + " case \"{raw}\":\n if (payload === null) {{\n console.warn(`[truapi worker] ${{name}} requires payload`);\n return undefined;\n }}\n return callbacks.{raw}(payload, sendItem);\n" + )); + } else { + out.push_str(&format!( + " case \"{raw}\":\n return callbacks.{raw}(sendItem);\n" + )); + } + } + out.push_str(" }\n"); + Ok(out) +} + +fn worker_subscription_payload_param(method: &PlatformMethod) -> Result> { + match method.params.as_slice() { + [] => Ok(None), + [param] => Ok(Some(to_camel_case(¶m.name))), + _ => bail!( + "subscription callback `{}` has more than one payload parameter", + method.name + ), + } +} + +fn collect_local_codec_names( + ty: &TypeRef, + local_codec_types: &BTreeSet, + out: &mut BTreeSet, +) { + match ty { + TypeRef::Named { name, args } => { + if local_codec_types.contains(name) { + out.insert(name.clone()); + } + for arg in args { + collect_local_codec_names(arg, local_codec_types, out); + } + } + TypeRef::Vec(inner) | TypeRef::Option(inner) | TypeRef::Array(inner, _) => { + collect_local_codec_names(inner, local_codec_types, out); + } + TypeRef::Tuple(items) => { + for item in items { + collect_local_codec_names(item, local_codec_types, out); + } + } + TypeRef::Primitive(_) | TypeRef::Generic(_) | TypeRef::Unit => {} + } +} + +/// Emit the complete `RawCallbacks` interface: the byte-oriented callback bag +/// produced by the typed host adapter. Codec payloads cross as `Uint8Array`, +/// strings/primitives/blobs pass through, subscriptions take a `sendItem` sink, +/// and `chainConnect` is the bespoke connection handle. +fn emit_raw_callbacks( + traits: &[&PlatformTrait], + codec_types: &BTreeSet, + local_codec_types: &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('\n'); + } + } + out.push_str(" chainConnect: ChainConnect;\n"); + out.push_str("}\n"); + out +} + +/// One `RawCallbacks` member signature for `method`. +fn raw_member( + method: &PlatformMethod, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, +) -> String { + let name = to_camel_case(&method.name); + match &method.return_shape.inner { + PlatformInner::Stream(_) => { + let mut params: Vec = method + .params + .iter() + .map(|p| { + format!( + "{}: {}", + to_camel_case(&p.name), + raw_param_ts(&p.type_ref, codec_types, local_codec_types) + ) + }) + .collect(); + params.push("sendItem: (item?: Uint8Array) => void".to_string()); + format!("{name}({}): (() => void) | void;", params.join(", ")) + } + PlatformInner::TraitObject(_) => String::new(), + inner => { + let params = method + .params + .iter() + .map(|p| { + format!( + "{}: {}", + to_camel_case(&p.name), + raw_param_ts(&p.type_ref, codec_types, local_codec_types) + ) + }) + .collect::>() + .join(", "); + let ok = match inner { + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + raw_ok_ts(ok, codec_types) + } + _ => "void".to_string(), + }; + // A synchronous (`!is_async`) callback is a fire-and-forget + // notification (e.g. `authStateChanged`); everything else is async. + if method.return_shape.is_async { + format!("{name}({params}): Promise<{ok}>;") + } else { + format!("{name}({params}): {ok};") + } + } + } +} + +/// TS type for a `RawCallbacks` parameter under the byte boundary. +fn raw_param_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_param_ts(inner, codec_types, local_codec_types) + ) + } + TypeRef::Primitive(p) => raw_primitive_ts(p), + _ => "Uint8Array".to_string(), + } +} + +/// TS type for a `RawCallbacks` `Result` ok value under the byte boundary. +fn raw_ok_ts(ty: &TypeRef, codec_types: &BTreeSet) -> String { + match ty { + TypeRef::Named { name, .. } if 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::Primitive(p) => raw_primitive_ts(p), + TypeRef::Unit => "void".to_string(), + TypeRef::Tuple(items) if items.is_empty() => "void".to_string(), + _ => "Uint8Array".to_string(), + } +} + +fn raw_primitive_ts(p: &str) -> String { + match p { + "bool" => "boolean".to_string(), + "str" => "string".to_string(), + _ => "number".to_string(), + } +} + +/// Collect named types referenced by `ty` that are neither codec types nor +/// platform-local (e.g. the `NotificationId` alias), so the `RawCallbacks` +/// interface can import them from `@parity/truapi` for their type only. +fn collect_extra_named( + ty: &TypeRef, + codec_types: &BTreeSet, + local: &BTreeSet, + out: &mut BTreeSet, +) { + match ty { + TypeRef::Named { name, args } => { + if !codec_types.contains(name) && !local.contains(name) { + out.insert(name.clone()); + } + for arg in args { + collect_extra_named(arg, codec_types, local, out); + } + } + TypeRef::Vec(inner) | TypeRef::Option(inner) | TypeRef::Array(inner, _) => { + collect_extra_named(inner, codec_types, local, out) + } + _ => {} + } +} + +/// Collect the `@parity/truapi` codec type names referenced by `ty` so the +/// adapter can import their `.enc`/`.dec`. +fn collect_codec_imports(ty: &TypeRef, codec_types: &BTreeSet, out: &mut BTreeSet) { + match ty { + TypeRef::Named { name, args } => { + if codec_types.contains(name) { + out.insert(name.clone()); + } + for arg in args { + collect_codec_imports(arg, codec_types, out); + } + } + TypeRef::Vec(inner) | TypeRef::Option(inner) | TypeRef::Array(inner, _) => { + collect_codec_imports(inner, codec_types, out) + } + _ => {} + } +} + +/// 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 +/// types are left to contextual inference from `RawCallbacks`, so only the +/// argument expression varies. +fn adapter_arg( + param: &PlatformParam, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, +) -> String { + let name = to_camel_case(¶m.name); + match ¶m.type_ref { + TypeRef::Named { name: ty, .. } + if codec_types.contains(ty) || local_codec_types.contains(ty) => + { + format!("{ty}.dec({name})") + } + TypeRef::Primitive(p) if matches!(p.as_str(), "u64" | "u128" | "i64" | "i128") => { + format!("BigInt({name})") + } + _ => name, + } +} + +/// Comma-joined arrow parameter names for a method (excluding `sendItem`). +fn param_names(method: &PlatformMethod) -> String { + method + .params + .iter() + .map(|p| to_camel_case(&p.name)) + .collect::>() + .join(", ") +} + +/// Emit one entry of the adapter object literal for `method`: every web worker +/// 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( + method: &PlatformMethod, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, +) -> Result { + let raw = to_camel_case(&method.name); + let impl_expr = match &method.return_shape.inner { + PlatformInner::Stream(item) => { + adapter_stream_impl(&raw, method, item, codec_types, local_codec_types)? + } + PlatformInner::Result { ok, .. } => { + adapter_unary_impl(&raw, 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)? + } + PlatformInner::TraitObject(_) => bail!("unexpected trait-object return on `{raw}`"), + }; + Ok(format!("{raw}: {impl_expr},")) +} + +/// 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, + method: &PlatformMethod, + ok: &TypeRef, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, +) -> Result { + let params = param_names(method); + let args = method + .params + .iter() + .map(|p| adapter_arg(p, codec_types, local_codec_types)) + .collect::>() + .join(", "); + let call = format!("host.{raw}({args})"); + let body = match ok { + TypeRef::Named { name: ty, .. } if codec_types.contains(ty) => { + format!("{ty}.enc(await {call})") + } + _ => format!("await {call}"), + }; + Ok(format!("async ({params}) => {body}")) +} + +/// 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, + method: &PlatformMethod, + item: &TypeRef, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, +) -> Result { + let args = method + .params + .iter() + .map(|p| adapter_arg(p, codec_types, local_codec_types)) + .collect::>() + .join(", "); + let item_ty = stream_item(item); + let is_unit = matches!(item_ty, TypeRef::Unit) + || matches!(item_ty, TypeRef::Tuple(items) if items.is_empty()); + let item_expr = match item_ty { + // 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) => { + format!("(item) => sendItem({ty}.enc(item))") + } + _ => "sendItem".to_string(), + }; + let mut names: Vec = method + .params + .iter() + .map(|p| to_camel_case(&p.name)) + .collect(); + names.push("sendItem".to_string()); + let params = names.join(", "); + let call = format!("host.{raw}({args})"); + Ok(format!( + "({params}) => driveResultStream({call}, {item_expr})" + )) +} + +fn collect_local_async_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); + } + } + } + let mut changed = true; + while changed { + changed = false; + let referenced = definition + .types + .iter() + .filter(|ty| out.contains(&ty.name)) + .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( + type_def: &TypeDef, + local: &BTreeSet, + out: &mut BTreeSet, +) { + walk_type_def(type_def, out, &mut |ty, out| { + collect_local_from_type(ty, local, out) + }); +} + +fn collect_from_type_def(type_def: &TypeDef, out: &mut BTreeSet) { + walk_type_def(type_def, out, &mut |ty, out| collect_from_type(ty, out)); +} + +/// Walk every `TypeRef` reachable from a type definition's fields/variant +/// payloads, applying `leaf` to each. Callers supply the leaf collector that +/// decides which names land in `out`. +fn walk_type_def( + type_def: &TypeDef, + out: &mut BTreeSet, + leaf: &mut dyn FnMut(&TypeRef, &mut BTreeSet), +) { + match &type_def.kind { + TypeDefKind::Alias(type_ref) => leaf(type_ref, out), + TypeDefKind::Struct(fields) => { + for field in fields { + leaf(&field.type_ref, out); + } + } + TypeDefKind::TupleStruct(fields) => { + for field in fields { + leaf(field, out); + } + } + TypeDefKind::Enum(variants) => { + for variant in variants { + match &variant.fields { + VariantFields::Unit => {} + VariantFields::Unnamed(types) => { + for ty in types { + leaf(ty, out); + } + } + VariantFields::Named(fields) => { + for field in fields { + leaf(&field.type_ref, out); + } + } + } + } + } + } +} + +fn collect_local_from_type(ty: &TypeRef, local: &BTreeSet, out: &mut BTreeSet) { + match ty { + TypeRef::Named { name, args } => { + if local.contains(name) { + out.insert(name.clone()); + } + 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 => {} + } +} + +fn collect_local_codec_imports( + definition: &PlatformDefinition, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, +) -> BTreeSet { + let mut out = BTreeSet::new(); + for type_def in definition + .types + .iter() + .filter(|ty| local_codec_types.contains(&ty.name)) + { + collect_codec_imports_from_type_def(type_def, codec_types, &mut out); + } + out +} + +fn collect_codec_imports_from_type_def( + type_def: &TypeDef, + codec_types: &BTreeSet, + out: &mut BTreeSet, +) { + match &type_def.kind { + TypeDefKind::Struct(fields) => { + for field in fields { + collect_codec_imports(&field.type_ref, codec_types, out); + } + } + TypeDefKind::TupleStruct(fields) => { + for field in fields { + collect_codec_imports(field, codec_types, out); + } + } + TypeDefKind::Enum(variants) => { + for variant in variants { + match &variant.fields { + VariantFields::Unit => {} + VariantFields::Unnamed(types) => { + for ty in types { + collect_codec_imports(ty, codec_types, out); + } + } + VariantFields::Named(fields) => { + for field in fields { + collect_codec_imports(&field.type_ref, codec_types, out); + } + } + } + } + } + TypeDefKind::Alias(type_ref) => collect_codec_imports(type_ref, codec_types, out), + } +} + +fn emit_local_codec(type_def: &TypeDef) -> Result { + let jsdoc = render_jsdoc("", type_def.docs.as_deref()); + let expr = local_codec_expr_for_type(type_def)?; + Ok(format!( + "{jsdoc}export const {name}: S.Codec<{name}> = S.lazy((): S.Codec<{name}> => {expr});\n", + name = type_def.name, + )) +} + +fn local_codec_expr_for_type(type_def: &TypeDef) -> Result { + match &type_def.kind { + TypeDefKind::Alias(type_ref) => local_codec_expr(type_ref), + TypeDefKind::Struct(fields) => local_struct_codec_expr(fields, &type_def.name), + TypeDefKind::TupleStruct(fields) => local_tuple_codec_expr(fields), + TypeDefKind::Enum(variants) => { + if variants + .iter() + .all(|variant| matches!(variant.fields, VariantFields::Unit)) + { + return Ok(format!( + "S.Status({})", + variants + .iter() + .map(|variant| ts_string_literal(&variant.name)) + .collect::>() + .join(", ") + )); + } + let entries = variants + .iter() + .map(|variant| { + Ok(format!( + "{}: {}", + variant.name, + local_variant_codec_expr(&variant.fields)? + )) + }) + .collect::>>()? + .join(", "); + Ok(format!("S.TaggedUnion({{{entries}}})")) + } + } +} + +fn local_variant_codec_expr(fields: &VariantFields) -> Result { + match fields { + VariantFields::Unit => Ok("S._void".to_string()), + VariantFields::Unnamed(types) => local_tuple_codec_expr(types), + VariantFields::Named(fields) => { + let type_name = inline_object_type(fields)?; + local_struct_codec_expr(fields, &type_name) + } + } +} + +fn local_tuple_codec_expr(types: &[TypeRef]) -> Result { + if types.is_empty() { + Ok("S._void".to_string()) + } else if types.len() == 1 { + local_codec_expr(&types[0]) + } else { + Ok(format!( + "S.Tuple({})", + types + .iter() + .map(local_codec_expr) + .collect::>>()? + .join(", ") + )) + } +} + +fn local_struct_codec_expr(fields: &[FieldDef], type_name: &str) -> Result { + let specs = fields + .iter() + .map(|field| { + Ok(format!( + "{}: {}", + to_camel_case(&field.name), + local_codec_expr(&field.type_ref)? + )) + }) + .collect::>>()? + .join(", "); + Ok(format!("S.Struct({{{specs}}}) as S.Codec<{type_name}>")) +} + +fn local_codec_expr(ty: &TypeRef) -> Result { + match ty { + TypeRef::Primitive(name) => match name.as_str() { + "bool" => Ok("S.bool".to_string()), + "u8" => Ok("S.u8".to_string()), + "u16" => Ok("S.u16".to_string()), + "u32" => Ok("S.u32".to_string()), + "u64" => Ok("S.u64".to_string()), + "u128" => Ok("S.u128".to_string()), + "i8" => Ok("S.i8".to_string()), + "i16" => Ok("S.i16".to_string()), + "i32" => Ok("S.i32".to_string()), + "i64" => Ok("S.i64".to_string()), + "i128" => Ok("S.i128".to_string()), + "str" => Ok("S.str".to_string()), + _ => bail!("Unsupported primitive type `{name}` in host callback codec generation"), + }, + TypeRef::Named { name, args } => { + if args.is_empty() { + Ok(name.clone()) + } else { + let codecs = args + .iter() + .map(local_codec_expr) + .collect::>>()? + .join(", "); + Ok(format!("{name}({codecs})")) + } + } + TypeRef::Vec(inner) => match inner.as_ref() { + TypeRef::Primitive(name) if name == "u8" => Ok("S.Bytes()".to_string()), + _ => Ok(format!("S.Vector({})", local_codec_expr(inner)?)), + }, + TypeRef::Option(inner) => Ok(format!("S.Option({})", local_codec_expr(inner)?)), + TypeRef::Tuple(items) => local_tuple_codec_expr(items), + TypeRef::Array(inner, len) => match inner.as_ref() { + TypeRef::Primitive(name) if name == "u8" => Ok(format!("S.Bytes({len})")), + _ => Ok(format!("S.Vector({})", local_codec_expr(inner)?)), + }, + TypeRef::Generic(name) => { + bail!("Generic `{name}` is not supported in host callback codecs") + } + TypeRef::Unit => Ok("S._void".to_string()), + } +} + +fn emit_trait_interface(trait_def: &PlatformTrait) -> Result { + let jsdoc = render_jsdoc("", trait_def.docs.as_deref()); + let body = trait_def + .methods + .iter() + .map(emit_method) + .collect::>>()? + .join("\n\n"); + Ok(formatdoc! { + r#" + {jsdoc}export interface {name} {{ + {body} + }} + "#, + name = trait_def.name.to_string(), + }) +} + +fn emit_method(method: &PlatformMethod) -> Result { + let jsdoc = render_jsdoc(" ", method.docs.as_deref()); + let params = method + .params + .iter() + .map(|p| { + let ts = ts_type(&p.type_ref)?; + Ok(format!("{}: {ts}", to_camel_case(&p.name))) + }) + .collect::>>()? + .join(", "); + let ret = format_return(&method.return_shape)?; + let name = to_camel_case(&method.name); + // A Rust default body makes the method optional for host implementations. + let optional = if method.has_default { "?" } else { "" }; + Ok(format!("{jsdoc} {name}{optional}({params}): {ret};")) +} + +/// Emit a TS interface for a local platform struct. `Option` fields become +/// optional members so hosts receive plain objects with absent-when-`None` +/// properties. +fn emit_struct_interface(struct_def: &TypeDef) -> Result { + let TypeDefKind::Struct(fields) = &struct_def.kind else { + bail!( + "Platform struct `{}` must have named fields", + struct_def.name + ); + }; + if !struct_def.generic_params.is_empty() { + bail!("Platform struct `{}` must not be generic", struct_def.name); + } + let jsdoc = render_jsdoc("", struct_def.docs.as_deref()); + let body = fields + .iter() + .map(|field| { + let jsdoc = render_jsdoc(" ", field.docs.as_deref()); + let name = to_camel_case(&field.name); + match &field.type_ref { + TypeRef::Option(inner) => Ok(format!("{jsdoc} {name}?: {};", ts_type(inner)?)), + other => Ok(format!("{jsdoc} {name}: {};", ts_type(other)?)), + } + }) + .collect::>>()? + .join("\n\n"); + Ok(formatdoc! { + r#" + {jsdoc}export interface {name} {{ + {body} + }} + "#, + name = struct_def.name, + }) +} + +/// Emit a TS type for a local platform enum. Unit-only enums become string +/// literal unions; payload enums become `{ tag, value }` tagged unions +/// matching the `@parity/truapi` client convention. +fn emit_enum_type(enum_def: &TypeDef) -> Result { + let TypeDefKind::Enum(variants) = &enum_def.kind else { + bail!("Platform enum `{}` must have variants", enum_def.name); + }; + if !enum_def.generic_params.is_empty() { + bail!("Platform enum `{}` must not be generic", enum_def.name); + } + let jsdoc = render_jsdoc("", enum_def.docs.as_deref()); + if variants + .iter() + .all(|variant| matches!(variant.fields, VariantFields::Unit)) + { + let union = variants + .iter() + .map(|variant| format!("\"{}\"", variant.name)) + .collect::>() + .join(" | "); + return Ok(format!( + "{jsdoc}export type {name} = {union};\n", + name = enum_def.name, + )); + } + let mut body = String::new(); + for variant in variants { + body.push_str(&render_jsdoc(" ", variant.docs.as_deref())); + writeln!(body, " | {}", enum_variant_type(variant)?).unwrap(); + } + Ok(formatdoc! { + r#" + {jsdoc}export type {name} = + {body}; + "#, + name = enum_def.name, + body = body.trim_end(), + }) +} + +/// Render one enum variant as a `{ tag, value }` member. Unit variants mark +/// `value` optional so consumers can write `{ tag: "X" }`. +fn enum_variant_type(variant: &VariantDef) -> Result { + Ok(match &variant.fields { + VariantFields::Unit => format!("{{ tag: \"{}\"; value?: undefined }}", variant.name), + VariantFields::Unnamed(types) => format!( + "{{ tag: \"{}\"; value: {} }}", + variant.name, + unnamed_variant_value_type(types)? + ), + VariantFields::Named(fields) => format!( + "{{ tag: \"{}\"; value: {} }}", + variant.name, + inline_object_type(fields)? + ), + }) +} + +fn unnamed_variant_value_type(types: &[TypeRef]) -> Result { + match types { + [single] => ts_type(single), + many => { + let rendered = many + .iter() + .map(ts_type) + .collect::>>()? + .join(", "); + Ok(format!("[{rendered}]")) + } + } +} + +fn inline_object_type(fields: &[FieldDef]) -> Result { + let body = fields + .iter() + .map(|field| { + let name = to_camel_case(&field.name); + match &field.type_ref { + TypeRef::Option(inner) => Ok(format!("{name}?: {}", ts_type(inner)?)), + other => Ok(format!("{name}: {}", ts_type(other)?)), + } + }) + .collect::>>()? + .join("; "); + Ok(format!("{{ {body} }}")) +} + +fn emit_super_interface(name: &str, composes: &[String], docs: Option<&str>) -> String { + let jsdoc = render_jsdoc("", docs); + if composes.is_empty() { + return format!("{jsdoc}export interface {name} {{}}\n"); + } + let extends = composes + .iter() + .map(|name| name.to_string()) + .collect::>() + .join(", "); + format!("{jsdoc}export interface {name} extends {extends} {{}}\n") +} + +fn collect_named_types(definition: &PlatformDefinition) -> BTreeSet { + let mut out: BTreeSet = BTreeSet::new(); + for trait_def in &definition.traits { + for method in &trait_def.methods { + for param in &method.params { + collect_from_type(¶m.type_ref, &mut out); + } + match &method.return_shape.inner { + // Err type is not part of the TS signature (errors throw), + // so don't import its name. + PlatformInner::Result { ok, .. } => collect_from_type(ok, &mut out), + PlatformInner::Stream(inner) => collect_from_type(inner, &mut out), + PlatformInner::Plain(inner) => collect_from_type(inner, &mut out), + // A trait object returns its bare trait name in the TS + // signature; collect it so a non-local trait gets imported + // rather than emitted as an undeclared name. Local traits are + // filtered out below since their interfaces live in this file. + PlatformInner::TraitObject(name) => { + out.insert(name.clone()); + } + PlatformInner::Unit => {} + } + } + } + for type_def in &definition.types { + collect_from_type_def(type_def, &mut out); + } + // Filter out names defined locally (the capability trait interfaces and + // the platform struct/enum types emitted into this file). + let local = local_names(definition); + out.into_iter().filter(|n| !local.contains(n)).collect() +} + +fn collect_from_type(ty: &TypeRef, out: &mut BTreeSet) { + match ty { + TypeRef::Named { name, args } => { + out.insert(name.clone()); + for arg in args { + collect_from_type(arg, out); + } + } + TypeRef::Vec(inner) | TypeRef::Option(inner) | TypeRef::Array(inner, _) => { + collect_from_type(inner, out) + } + TypeRef::Tuple(items) => { + for item in items { + collect_from_type(item, out); + } + } + TypeRef::Primitive(_) | TypeRef::Generic(_) | TypeRef::Unit => {} + } +} + +fn format_return(ret: &PlatformReturn) -> Result { + let inner = match &ret.inner { + PlatformInner::Unit => "void".to_string(), + PlatformInner::Result { ok, .. } => ts_type(ok)?, + PlatformInner::Stream(item) => format!("AsyncIterable<{}>", ts_type(item)?), + PlatformInner::TraitObject(name) => name.clone(), + PlatformInner::Plain(ty) => ts_type(ty)?, + }; + if ret.is_async { + Ok(format!("Promise<{inner}>")) + } else { + Ok(inner) + } +} + +fn ts_type(ty: &TypeRef) -> Result { + match ty { + TypeRef::Primitive(name) => match name.as_str() { + "bool" => Ok("boolean".to_string()), + "u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => Ok("number".to_string()), + "u64" | "u128" | "i64" | "i128" => Ok("bigint".to_string()), + "str" => Ok("string".to_string()), + _ => bail!("Unsupported primitive type `{name}` in host callbacks generation"), + }, + TypeRef::Named { name, args } => { + if args.is_empty() { + Ok(name.clone()) + } else { + let rendered = args + .iter() + .map(ts_type) + .collect::>>()? + .join(", "); + Ok(format!("{name}<{rendered}>")) + } + } + TypeRef::Vec(inner) => match inner.as_ref() { + TypeRef::Primitive(name) if name == "u8" => Ok("Uint8Array".to_string()), + _ => Ok(format!("Array<{}>", ts_type(inner)?)), + }, + TypeRef::Option(inner) => Ok(format!("{} | undefined", ts_type(inner)?)), + TypeRef::Tuple(items) => { + if items.is_empty() { + Ok("void".to_string()) + } else { + let rendered = items + .iter() + .map(ts_type) + .collect::>>()? + .join(", "); + Ok(format!("[{rendered}]")) + } + } + TypeRef::Array(inner, _len) => match inner.as_ref() { + TypeRef::Primitive(name) if name == "u8" => Ok("Uint8Array".to_string()), + _ => Ok(format!("Array<{}>", ts_type(inner)?)), + }, + TypeRef::Generic(name) => Ok(name.clone()), + TypeRef::Unit => Ok("void".to_string()), + } +} + +fn render_jsdoc(indent: &str, docs: Option<&str>) -> String { + let Some(docs) = docs else { + return String::new(); + }; + let docs = docs.trim(); + if docs.is_empty() { + return String::new(); + } + let body = docs + .lines() + .map(|line| { + if line.is_empty() { + format!("{indent} *") + } else { + format!("{indent} * {}", render_ts_doc_line(line)) + } + }) + .collect::>() + .join("\n"); + format!("{indent}/**\n{body}\n{indent} */\n") +} + +fn render_ts_doc_line(line: &str) -> String { + line.replace("[`", "`") + .replace("`]", "`") + .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/src/ts/playground.rs b/rust/crates/truapi-codegen/src/ts/playground.rs index 51de54ff..adfe20ed 100644 --- a/rust/crates/truapi-codegen/src/ts/playground.rs +++ b/rust/crates/truapi-codegen/src/ts/playground.rs @@ -28,7 +28,7 @@ fn generate_playground_services_code( let wrappers = collect_versioned_wrappers(api); let emit_versions = versioned_wrapper_emit_versions(api, &wrappers, target_version)?; let aliases = selected_public_aliases(api, &wrappers, &emit_versions, target_version); - let ctx = CodecContext::default(); + let ctx = codec_context(&[]); let services = public_services(api)?; let explorer_type_ids = explorer_type_id_set(api, &aliases); @@ -160,6 +160,7 @@ pub(super) struct PlaygroundDocs { pub(super) client_example: Option, } +/// Split method docs into playground description text and a TypeScript example. pub(super) fn split_playground_docs(docs: Option<&str>) -> Result { let Some(docs) = docs else { return Ok(PlaygroundDocs { @@ -238,6 +239,7 @@ fn validate_example_docs(trait_name: &str, method_name: &str, docs: Option<&str> Ok(()) } +/// Strip the generated TypeScript namespace prefix used by playground types. pub(super) fn playground_type_name(value: &str) -> String { value.replace("T.", "") } diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs new file mode 100644 index 00000000..bc2eef63 --- /dev/null +++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs @@ -0,0 +1,1913 @@ +//! Wire dispatcher for the unified `TrUApi` trait. +//! +//! Auto-generated by truapi-codegen. Do not edit. + +use std::sync::Arc; + +use parity_scale_codec::Decode; + +use truapi::CallContext; +use truapi::api::{ + Account, + Chain, + Chat, + CoinPayment, + Entropy, + LocalStorage, + Notifications, + Payment, + Permissions, + Preimage, + ResourceAllocation, + Signing, + StatementStore, + System, + Theme, +}; +use truapi::versioned::{self, Versioned}; + +use crate::dispatcher::Dispatcher; +use crate::frame::encode_raw_err_payload; +use crate::frame::encode_raw_unit_ok_payload; +use crate::frame::encode_versioned_err_payload; +use crate::frame::encode_versioned_interrupt_payload; +use crate::frame::encode_versioned_ok_payload; +use crate::frame::encode_versioned_unit_ok_payload; +use crate::generated::wire_table; +use crate::subscription::subscription_stream; +#[cfg(debug_assertions)] +use truapi::api::Testing; + +/// Register every TrUAPI method with the dispatcher. +pub fn register

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: truapi::api::TrUApi + 'static, +{ + register_account(dispatcher, host.clone()); + register_chain(dispatcher, host.clone()); + register_chat(dispatcher, host.clone()); + register_coin_payment(dispatcher, host.clone()); + register_entropy(dispatcher, host.clone()); + register_local_storage(dispatcher, host.clone()); + register_notifications(dispatcher, host.clone()); + register_payment(dispatcher, host.clone()); + register_permissions(dispatcher, host.clone()); + register_preimage(dispatcher, host.clone()); + register_resource_allocation(dispatcher, host.clone()); + register_signing(dispatcher, host.clone()); + register_statement_store(dispatcher, host.clone()); + register_system(dispatcher, host.clone()); + #[cfg(debug_assertions)] + register_testing(dispatcher, host.clone()); + register_theme(dispatcher, host); +} + +fn register_account

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Account + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::ACCOUNT_CONNECTION_STATUS_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let _ = bytes; + let cx = CallContext::with_request_id(request_id.clone()); + let stream = host.connection_status_subscribe(&cx).await; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_GET_ACCOUNT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountGetRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountGetResponse = match host.get_account(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_GET_ACCOUNT_ALIAS, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountGetAliasRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountGetAliasResponse = match host.get_account_alias(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_CREATE_ACCOUNT_PROOF, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountCreateProofRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountCreateProofResponse = match host.create_account_proof(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_GET_LEGACY_ACCOUNTS, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostGetLegacyAccountsRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostGetLegacyAccountsResponse = match host.get_legacy_accounts(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_GET_USER_ID, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostGetUserIdRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostGetUserIdResponse = match host.get_user_id(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host; + dispatcher.on_request(wire_table::ACCOUNT_REQUEST_LOGIN, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostRequestLoginRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostRequestLoginResponse = match host.request_login(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } +} + +fn register_chain

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Chain + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::CHAIN_FOLLOW_HEAD_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainHeadFollowRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(_) => return Err(Vec::new()), + }; + let cx = CallContext::with_request_id(request_id.clone()); + let stream = host.follow_head_subscribe(&cx, request).await; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAIN_GET_HEAD_HEADER, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainHeadHeaderRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainHeadHeaderResponse = match host.get_head_header(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAIN_GET_HEAD_BODY, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainHeadBodyRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainHeadBodyResponse = match host.get_head_body(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAIN_GET_HEAD_STORAGE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainHeadStorageRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainHeadStorageResponse = match host.get_head_storage(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAIN_CALL_HEAD, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainHeadCallRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainHeadCallResponse = match host.call_head(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAIN_UNPIN_HEAD, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainHeadUnpinRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainHeadUnpinResponse = match host.unpin_head(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAIN_CONTINUE_HEAD, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainHeadContinueRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainHeadContinueResponse = match host.continue_head(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAIN_STOP_HEAD_OPERATION, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainHeadStopOperationRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainHeadStopOperationResponse = match host.stop_head_operation(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAIN_GET_SPEC_GENESIS_HASH, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainSpecGenesisHashRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainSpecGenesisHashResponse = match host.get_spec_genesis_hash(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAIN_GET_SPEC_CHAIN_NAME, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainSpecChainNameRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainSpecChainNameResponse = match host.get_spec_chain_name(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAIN_GET_SPEC_PROPERTIES, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainSpecPropertiesRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainSpecPropertiesResponse = match host.get_spec_properties(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAIN_BROADCAST_TRANSACTION, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainTransactionBroadcastRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainTransactionBroadcastResponse = match host.broadcast_transaction(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host; + dispatcher.on_request(wire_table::CHAIN_STOP_TRANSACTION, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainTransactionStopRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainTransactionStopResponse = match host.stop_transaction(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } +} + +fn register_chat

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Chat + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAT_CREATE_ROOM, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chat::HostChatCreateRoomRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chat::HostChatCreateRoomResponse = match host.create_room(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAT_REGISTER_BOT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chat::HostChatRegisterBotRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chat::HostChatRegisterBotResponse = match host.register_bot(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::CHAT_LIST_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let _ = bytes; + let cx = CallContext::with_request_id(request_id.clone()); + let stream = host.list_subscribe(&cx).await; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAT_POST_MESSAGE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chat::HostChatPostMessageRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chat::HostChatPostMessageResponse = match host.post_message(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::CHAT_ACTION_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let _ = bytes; + let cx = CallContext::with_request_id(request_id.clone()); + let stream = host.action_subscribe(&cx).await; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host; + dispatcher.on_subscription(wire_table::CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chat::ProductChatCustomMessageRenderSubscribeRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(_) => return Err(Vec::new()), + }; + let cx = CallContext::with_request_id(request_id.clone()); + let stream = host.custom_message_render_subscribe(&cx, request).await; + Ok(subscription_stream::(stream)) + }) + }); + } +} + +fn register_coin_payment

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: CoinPayment + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_request(wire_table::COIN_PAYMENT_CREATE_PURSE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentCreatePurseRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::coin_payment::HostCoinPaymentCreatePurseResponse = match host.create_purse(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::COIN_PAYMENT_QUERY_PURSE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentQueryPurseRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::coin_payment::HostCoinPaymentQueryPurseResponse = match host.query_purse(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::COIN_PAYMENT_REBALANCE_PURSE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentRebalancePurseRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let stream = match host.rebalance_purse(&cx, request).await { + Ok(sub) => sub, + Err(err) => { + return Err(encode_versioned_interrupt_payload(err, target_version)); + } + }; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::COIN_PAYMENT_DELETE_PURSE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentDeletePurseRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let stream = match host.delete_purse(&cx, request).await { + Ok(sub) => sub, + Err(err) => { + return Err(encode_versioned_interrupt_payload(err, target_version)); + } + }; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::COIN_PAYMENT_CREATE_RECEIVABLE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentCreateReceivableRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::coin_payment::HostCoinPaymentCreateReceivableResponse = match host.create_receivable(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::COIN_PAYMENT_CREATE_CHEQUE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentCreateChequeRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::coin_payment::HostCoinPaymentCreateChequeResponse = match host.create_cheque(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::COIN_PAYMENT_DEPOSIT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentDepositRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let stream = match host.deposit(&cx, request).await { + Ok(sub) => sub, + Err(err) => { + return Err(encode_versioned_interrupt_payload(err, target_version)); + } + }; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::COIN_PAYMENT_REFUND, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentRefundRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let stream = match host.refund(&cx, request).await { + Ok(sub) => sub, + Err(err) => { + return Err(encode_versioned_interrupt_payload(err, target_version)); + } + }; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host; + dispatcher.on_subscription(wire_table::COIN_PAYMENT_LISTEN_FOR_PAYMENT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentListenForRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let stream = match host.listen_for_payment(&cx, request).await { + Ok(sub) => sub, + Err(err) => { + return Err(encode_versioned_interrupt_payload(err, target_version)); + } + }; + Ok(subscription_stream::(stream)) + }) + }); + } +} + +fn register_entropy

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Entropy + Send + Sync + 'static, +{ + { + let host = host; + dispatcher.on_request(wire_table::ENTROPY_DERIVE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::entropy::HostDeriveEntropyRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::entropy::HostDeriveEntropyResponse = match host.derive(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } +} + +fn register_local_storage

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: LocalStorage + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_request(wire_table::LOCAL_STORAGE_READ, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::local_storage::HostLocalStorageReadRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::local_storage::HostLocalStorageReadResponse = match host.read(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::LOCAL_STORAGE_WRITE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::local_storage::HostLocalStorageWriteRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::local_storage::HostLocalStorageWriteResponse = match host.write(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host; + dispatcher.on_request(wire_table::LOCAL_STORAGE_CLEAR, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::local_storage::HostLocalStorageClearRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::local_storage::HostLocalStorageClearResponse = match host.clear(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } +} + +fn register_notifications

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Notifications + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_request(wire_table::NOTIFICATIONS_SEND_PUSH_NOTIFICATION, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::notifications::HostPushNotificationRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::notifications::HostPushNotificationResponse = match host.send_push_notification(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host; + dispatcher.on_request(wire_table::NOTIFICATIONS_CANCEL_PUSH_NOTIFICATION, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::notifications::HostPushNotificationCancelRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::notifications::HostPushNotificationCancelResponse = match host.cancel_push_notification(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } +} + +fn register_payment

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Payment + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::PAYMENT_BALANCE_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::payment::HostPaymentBalanceSubscribeRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let stream = match host.balance_subscribe(&cx, request).await { + Ok(sub) => sub, + Err(err) => { + return Err(encode_versioned_interrupt_payload(err, target_version)); + } + }; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::PAYMENT_REQUEST, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::payment::HostPaymentRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::payment::HostPaymentResponse = match host.request(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::PAYMENT_STATUS_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::payment::HostPaymentStatusSubscribeRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let stream = match host.status_subscribe(&cx, request).await { + Ok(sub) => sub, + Err(err) => { + return Err(encode_versioned_interrupt_payload(err, target_version)); + } + }; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host; + dispatcher.on_request(wire_table::PAYMENT_TOP_UP, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::payment::HostPaymentTopUpRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::payment::HostPaymentTopUpResponse = match host.top_up(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } +} + +fn register_permissions

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Permissions + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_request(wire_table::PERMISSIONS_REQUEST_DEVICE_PERMISSION, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::permissions::HostDevicePermissionRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::permissions::HostDevicePermissionResponse = match host.request_device_permission(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host; + dispatcher.on_request(wire_table::PERMISSIONS_REQUEST_REMOTE_PERMISSION, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::permissions::RemotePermissionRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::permissions::RemotePermissionResponse = match host.request_remote_permission(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } +} + +fn register_preimage

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Preimage + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::PREIMAGE_LOOKUP_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::preimage::RemotePreimageLookupSubscribeRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(_) => return Err(Vec::new()), + }; + let cx = CallContext::with_request_id(request_id.clone()); + let stream = host.lookup_subscribe(&cx, request).await; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host; + dispatcher.on_request(wire_table::PREIMAGE_SUBMIT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::preimage::RemotePreimageSubmitRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::preimage::RemotePreimageSubmitResponse = match host.submit(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } +} + +fn register_resource_allocation

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: ResourceAllocation + Send + Sync + 'static, +{ + { + let host = host; + dispatcher.on_request(wire_table::RESOURCE_ALLOCATION_REQUEST, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::resource_allocation::HostRequestResourceAllocationRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::resource_allocation::HostRequestResourceAllocationResponse = match host.request(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } +} + +fn register_signing

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Signing + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_request(wire_table::SIGNING_CREATE_TRANSACTION, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::signing::HostCreateTransactionRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::signing::HostCreateTransactionResponse = match host.create_transaction(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::SIGNING_CREATE_TRANSACTION_WITH_LEGACY_ACCOUNT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::signing::HostCreateTransactionWithLegacyAccountRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::signing::HostCreateTransactionWithLegacyAccountResponse = match host.create_transaction_with_legacy_account(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::SIGNING_SIGN_RAW_WITH_LEGACY_ACCOUNT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::signing::HostSignRawWithLegacyAccountRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::signing::HostSignRawWithLegacyAccountResponse = match host.sign_raw_with_legacy_account(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::SIGNING_SIGN_PAYLOAD_WITH_LEGACY_ACCOUNT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::signing::HostSignPayloadWithLegacyAccountRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::signing::HostSignPayloadWithLegacyAccountResponse = match host.sign_payload_with_legacy_account(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::SIGNING_SIGN_RAW, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::signing::HostSignRawRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::signing::HostSignRawResponse = match host.sign_raw(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host; + dispatcher.on_request(wire_table::SIGNING_SIGN_PAYLOAD, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::signing::HostSignPayloadRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::signing::HostSignPayloadResponse = match host.sign_payload(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } +} + +fn register_statement_store

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: StatementStore + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::STATEMENT_STORE_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::statement_store::RemoteStatementStoreSubscribeRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let stream = match host.subscribe(&cx, request).await { + Ok(sub) => sub, + Err(err) => { + return Err(encode_versioned_interrupt_payload(err, target_version)); + } + }; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::STATEMENT_STORE_CREATE_PROOF, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::statement_store::RemoteStatementStoreCreateProofRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::statement_store::RemoteStatementStoreCreateProofResponse = match host.create_proof(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::STATEMENT_STORE_CREATE_PROOF_AUTHORIZED, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedResponse = match host.create_proof_authorized(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host; + dispatcher.on_request(wire_table::STATEMENT_STORE_SUBMIT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::statement_store::RemoteStatementStoreSubmitRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + match host.submit(&cx, request).await { + Ok(()) => Ok(encode_versioned_unit_ok_payload(target_version)), + Err(err) => { + Ok(encode_versioned_err_payload(err, target_version)) + } + } + }) + }); + } +} + +fn register_system

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: System + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_request(wire_table::SYSTEM_HANDSHAKE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::system::HostHandshakeRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::system::HostHandshakeResponse = match host.handshake(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::SYSTEM_FEATURE_SUPPORTED, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::system::HostFeatureSupportedRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::system::HostFeatureSupportedResponse = match host.feature_supported(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host; + dispatcher.on_request(wire_table::SYSTEM_NAVIGATE_TO, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::system::HostNavigateToRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::system::HostNavigateToResponse = match host.navigate_to(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } +} + +#[cfg(debug_assertions)] +fn register_testing

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Testing + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_request(wire_table::TESTING_VERSION_PROBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::testing::TestingVersionProbeRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::testing::TestingVersionProbeResponse = match host.version_probe(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host; + dispatcher.on_request(wire_table::TESTING_ECHO_ERROR, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: truapi::v01::EchoErrorRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_raw_err_payload(error)); + } + }; + let cx = CallContext::with_request_id(request_id.clone()); + match host.echo_error(&cx, request).await { + Ok(()) => Ok(encode_raw_unit_ok_payload()), + Err(err) => Ok(encode_raw_err_payload(err)), + } + }) + }); + } +} + +fn register_theme

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Theme + Send + Sync + 'static, +{ + { + let host = host; + dispatcher.on_subscription(wire_table::THEME_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let _ = bytes; + let cx = CallContext::with_request_id(request_id.clone()); + let stream = host.subscribe(&cx).await; + Ok(subscription_stream::(stream)) + }) + }); + } +} diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts new file mode 100644 index 00000000..3edef0ec --- /dev/null +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -0,0 +1,78 @@ +// 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. + +import { + HostDevicePermissionRequest, + HostDevicePermissionResponse, + HostFeatureSupportedRequest, + HostFeatureSupportedResponse, + HostPushNotificationRequest, + HostPushNotificationResponse, + RemotePermissionRequest, + RemotePermissionResponse, + ThemeVariant, +} from "@parity/truapi"; +import type { + NotificationId, +} from "@parity/truapi"; +import { + CoreStorageKey, + UserConfirmationReview, +} from "./host-callbacks.js"; +import type { AuthState, HostCallbacks } from "./host-callbacks.js"; +import type { ChainConnect } from "../runtime.js"; +import { + chainConnectAdapter, + driveResultStream, +} from "../adapter-support.js"; + +export interface RawCallbacks { + authStateChanged(state: AuthState): void; + readCoreStorage(key: Uint8Array): Promise; + writeCoreStorage(key: Uint8Array, value: Uint8Array): Promise; + clearCoreStorage(key: Uint8Array): Promise; + featureSupported(request: Uint8Array): Promise; + navigateTo(url: string): Promise; + pushNotification(notification: Uint8Array): Promise; + cancelNotification(id: NotificationId): Promise; + devicePermission(request: Uint8Array): Promise; + remotePermission(request: Uint8Array): Promise; + submitPreimage(value: Uint8Array): Promise; + lookupPreimage(key: Uint8Array, sendItem: (item?: Uint8Array) => void): (() => void) | void; + read(key: string): Promise; + write(key: string, value: Uint8Array): Promise; + 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, +): RawCallbacks { + 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)), + }; +} diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts new file mode 100644 index 00000000..4aab10b0 --- /dev/null +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -0,0 +1,497 @@ +// Auto-generated by truapi-codegen. Do not edit. +// +// Typed host-callbacks surface derived from the `truapi-platform` +// capability traits. One interface per Rust trait + a composite +// `HostCallbacks` interface that mirrors the `Platform` super-trait. + +import * as S from "@parity/truapi/scale"; + +import { + HostDevicePermissionRequest, + HostRequestResourceAllocationRequest, + HostSignPayloadRequest, + HostSignPayloadWithLegacyAccountRequest, + HostSignRawRequest, + HostSignRawWithLegacyAccountRequest, + LegacyAccountTxPayload, + ProductAccountTxPayload, + RemotePermissionRequest, +} from "@parity/truapi"; + +import type { + GenericError, + HostDevicePermissionResponse, + HostFeatureSupportedRequest, + HostFeatureSupportedResponse, + HostPushNotificationRequest, + HostPushNotificationResponse, + NotificationId, + RemotePermissionResponse, + Result, + ThemeVariant, +} from "@parity/truapi"; + +/** + * Review shown before a product asks to alias another product account. + */ +export interface AccountAliasReview { + /** + * Product currently handling the request. + */ + requestingProductId: string; + + /** + * Product whose account is being requested. + */ + targetProductId: string; +} + +/** + * 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 type AuthState = + /** + * No active session and no login in progress. + */ + | { tag: "Disconnected"; value?: undefined } + /** + * A login is in progress: present the pairing deeplink/QR. Leave this + * state only on a subsequent emission (connected, failed, or + * disconnected after cancellation). + */ + | { tag: "Pairing"; value: { deeplink: string } } + /** + * A session is active. + */ + | { tag: "Connected"; value: SessionUiInfo } + /** + * The last login attempt failed; show the reason and offer a retry. + */ + | { tag: "LoginFailed"; value: { reason: string } }; + +/** + * Core-owned host-private storage slots. Products never address these slots; + * the host chooses the backing store for each slot. + */ +export type CoreStorageKey = + /** + * Opaque SSO/auth session blob. + */ + | { tag: "AuthSession"; value?: undefined } + /** + * Pairing device identity used during SSO flows. + */ + | { tag: "PairingDeviceIdentity"; value?: undefined } + /** + * Persisted authorization for one product-scoped permission request. + */ + | { tag: "PermissionAuthorization"; value: { productId: string; request: PermissionAuthorizationRequest } }; + +/** + * Review shown before a transaction-creation request is sent to the paired wallet. + */ +export type CreateTransactionReview = + /** + * Product-account transaction request. + */ + | { tag: "Product"; value: ProductAccountTxPayload } + /** + * Legacy-account transaction request. + */ + | { tag: "LegacyAccount"; value: LegacyAccountTxPayload }; + +/** + * Permission request whose authorization status can be inspected or updated + * by host administration UI. + */ +export type PermissionAuthorizationRequest = + /** + * Device-level permission such as camera, microphone, or location. + */ + | { tag: "Device"; value: HostDevicePermissionRequest } + /** + * Remote/product-scoped permission such as chain submit or HTTP access. + */ + | { tag: "Remote"; value: RemotePermissionRequest }; + +/** + * Authorization status for a permission request. + * + * `NotDetermined` means the core has no persisted answer and will prompt the + * host the next time the product requests this permission. + */ +export type PermissionAuthorizationStatus = "NotDetermined" | "Denied" | "Authorized"; + +/** + * Review shown before a preimage is submitted. + */ +export interface PreimageSubmitReview { + /** + * Size of the preimage in bytes. + */ + size: bigint; +} + +/** + * Decoded session fields a host shell needs to render account UI without + * parsing the opaque session blob the core persists through `CoreStorage`. + */ +export interface SessionUiInfo { + /** + * 32-byte sr25519 root public key of the active session. + */ + publicKey: Uint8Array; + + /** + * Wallet identity account id used for People-chain username lookup. + */ + identityAccountId?: Uint8Array; + + /** + * Short username from the People-chain identity record. + */ + liteUsername?: string; + + /** + * Fully qualified username from the People-chain identity record. + */ + fullUsername?: string; +} + +/** + * Review shown before a sign-payload request is sent to the paired wallet. + */ +export type SignPayloadReview = + /** + * Product-account signing request. + */ + | { tag: "Product"; value: HostSignPayloadRequest } + /** + * Legacy-account signing request. + */ + | { tag: "LegacyAccount"; value: HostSignPayloadWithLegacyAccountRequest }; + +/** + * Review shown before a sign-raw request is sent to the paired wallet. + */ +export type SignRawReview = + /** + * Product-account raw signing request. + */ + | { tag: "Product"; value: HostSignRawRequest } + /** + * Legacy-account raw signing request. + */ + | { tag: "LegacyAccount"; value: HostSignRawWithLegacyAccountRequest }; + +/** + * Review shown before a user-confirmed core action continues. + */ +export type UserConfirmationReview = + /** + * Sign a SCALE payload with a product or legacy account. + */ + | { tag: "SignPayload"; value: SignPayloadReview } + /** + * Sign raw bytes with a product or legacy account. + */ + | { tag: "SignRaw"; value: SignRawReview } + /** + * Create a transaction with a product or legacy account. + */ + | { tag: "CreateTransaction"; value: CreateTransactionReview } + /** + * Allow a product to request another product account alias. + */ + | { tag: "AccountAlias"; value: AccountAliasReview } + /** + * Allocate resources for the requesting product. + */ + | { tag: "ResourceAllocation"; value: HostRequestResourceAllocationRequest } + /** + * Submit a preimage to the host-selected backend. + */ + | { tag: "PreimageSubmit"; value: PreimageSubmitReview }; + +/** + * Review shown before a product asks to alias another product account. + */ +export const AccountAliasReview: S.Codec = S.lazy((): S.Codec => S.Struct({requestingProductId: S.str, targetProductId: S.str}) as S.Codec); + +/** + * Core-owned host-private storage slots. Products never address these slots; + * the host chooses the backing store for each slot. + */ +export const CoreStorageKey: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({AuthSession: S._void, PairingDeviceIdentity: S._void, PermissionAuthorization: S.Struct({productId: S.str, request: PermissionAuthorizationRequest}) as S.Codec<{ productId: string; request: PermissionAuthorizationRequest }>})); + +/** + * Review shown before a transaction-creation request is sent to the paired wallet. + */ +export const CreateTransactionReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Product: ProductAccountTxPayload, LegacyAccount: LegacyAccountTxPayload})); + +/** + * Permission request whose authorization status can be inspected or updated + * by host administration UI. + */ +export const PermissionAuthorizationRequest: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Device: HostDevicePermissionRequest, Remote: RemotePermissionRequest})); + +/** + * Authorization status for a permission request. + * + * `NotDetermined` means the core has no persisted answer and will prompt the + * host the next time the product requests this permission. + */ +export const PermissionAuthorizationStatus: S.Codec = S.lazy((): S.Codec => S.Status("NotDetermined", "Denied", "Authorized")); + +/** + * Review shown before a preimage is submitted. + */ +export const PreimageSubmitReview: S.Codec = S.lazy((): S.Codec => S.Struct({size: S.u64}) as S.Codec); + +/** + * Review shown before a sign-payload request is sent to the paired wallet. + */ +export const SignPayloadReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Product: HostSignPayloadRequest, LegacyAccount: HostSignPayloadWithLegacyAccountRequest})); + +/** + * Review shown before a sign-raw request is sent to the paired wallet. + */ +export const SignRawReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Product: HostSignRawRequest, LegacyAccount: HostSignRawWithLegacyAccountRequest})); + +/** + * Review shown before a user-confirmed core action continues. + */ +export const UserConfirmationReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({SignPayload: SignPayloadReview, SignRaw: SignRawReview, CreateTransaction: CreateTransactionReview, AccountAlias: AccountAliasReview, ResourceAllocation: HostRequestResourceAllocationRequest, PreimageSubmit: PreimageSubmitReview})); + +/** + * Host auth UI driven by core-owned `AuthState` transitions. + */ +export interface AuthPresenter { + /** + * Observe an auth state change. Emitted only when the state actually + * changes, in transition order. Default is a no-op for hosts that + * render no auth UI. + */ + authStateChanged?(state: AuthState): void; +} + +/** + * JSON-RPC provider factory for chain access. + * + * The platform provides a way to get a JSON-RPC connection for a given chain. + * The server runtime manages the chainHead v1 state machine on top of this. + */ +export interface ChainProvider { + /** + * Open a JSON-RPC connection for the chain identified by `genesis_hash`. + * Drop the returned connection to disconnect. + */ + connect(genesisHash: Uint8Array): Promise; +} + +/** + * Core-owned administration API exposed to host UI. + * + * Hosts call this surface to drive global runtime actions or inspect/update + * core-owned state without going through a product-scoped TrUAPI request. + */ +export interface CoreAdmin { + /** + * Best-effort logout/disconnect. Clears the active session and emits the + * resulting auth state transition. + */ + disconnectSession(): Promise; + + /** + * Cancel any in-flight pairing request. + */ + cancelPairing(): void; + + /** + * Notify the core that the host-global auth session slot may have + * changed. The core re-reads storage and emits any resulting auth state. + */ + notifySessionStoreChanged(): void; + + /** + * Read a stored permission authorization status without prompting. + */ + getPermissionAuthorizationStatus(request: PermissionAuthorizationRequest): Promise; + + /** + * Read stored permission authorization statuses without prompting. + * + * Results are returned in the same order as `requests`. + */ + getPermissionAuthorizationStatuses(requests: Array): Promise>; + + /** + * Update a stored permission authorization status. `NotDetermined` clears + * the stored value so the next product request prompts again. + */ + setPermissionAuthorizationStatus(request: PermissionAuthorizationRequest, status: PermissionAuthorizationStatus): Promise; +} + +/** + * Host-private persistence for core-owned state. + */ +export interface CoreStorage { + /** + * Read a core-owned value by typed slot. + */ + readCoreStorage(key: CoreStorageKey): Promise; + + /** + * Write a core-owned value by typed slot. + */ + writeCoreStorage(key: CoreStorageKey, value: Uint8Array): Promise; + + /** + * Clear a core-owned value by typed slot. + */ + clearCoreStorage(key: CoreStorageKey): Promise; +} + +/** + * Feature-support probing. The host answers whether it can service a given + * capability (currently scoped to per-chain support). + */ +export interface Features { + /** + * Report whether the requested feature is supported. + */ + featureSupported(request: HostFeatureSupportedRequest): Promise; +} + +/** + * A live JSON-RPC connection to a chain. + */ +export interface JsonRpcConnection { + /** + * Send a JSON-RPC request string. + */ + send(request: string): void; + + /** + * Stream of JSON-RPC response strings. + */ + responses(): AsyncIterable; + + /** + * Close the connection lease. + * + * Hosts may keep a shared underlying transport alive, but this handle + * must stop receiving responses and release any per-caller resources. + */ + close(): void; +} + +/** + * Open URLs in the system browser. Input is already trimmed, categorized, + * and (where needed) normalized by the core; the host implementation only + * needs to hand the URL to the OS URL handler. + */ +export interface Navigation { + /** + * Open the given URL in the system browser. + */ + navigateTo(url: string): Promise; +} + +/** + * Deliver push notifications. + */ +export interface Notifications { + /** + * Schedule or immediately display the given notification and return the + * host-assigned id. + */ + pushNotification(notification: HostPushNotificationRequest): Promise; + + /** + * Cancel a notification by id. Idempotent: cancelling an already-fired or + * unknown id still returns `success`. + */ + cancelNotification?(id: NotificationId): Promise; +} + +/** + * Permission prompts. v0.1 keeps device permissions (camera, mic, NFC, ...) + * separate from remote permissions (domain access, chain submit, ...), so the + * platform surface mirrors that split. + */ +export interface Permissions { + /** + * Prompt the user for a device-level permission. + */ + devicePermission(request: HostDevicePermissionRequest): Promise; + + /** + * Prompt the user for a remote (product-scoped) permission bundle. + */ + remotePermission(request: RemotePermissionRequest): Promise; +} + +/** + * Host preimage backend. The core owns wire mapping and subscription + * lifecycle; the host owns the selected backend. + */ +export interface PreimageHost { + /** + * Submit the preimage and return its key. + */ + submitPreimage?(value: Uint8Array): Promise; + + /** + * Emits current value/miss immediately, then future updates. + */ + lookupPreimage(key: Uint8Array): AsyncIterable>; +} + +/** + * Product-scoped key-value storage. The platform namespaces keys so different + * products cannot read each other's data. + */ +export interface ProductStorage { + /** + * Read a value by key. + */ + read(key: string): Promise; + + /** + * Write a value to a key. + */ + write(key: string, value: Uint8Array): Promise; + + /** + * Clear a value at a key. + */ + clear(key: string): Promise; +} + +/** + * Host theme source. + */ +export interface ThemeHost { + /** + * Emits current theme immediately, then future changes. + */ + subscribeTheme(): AsyncIterable>; +} + +/** + * Local user confirmation UI for session-channel operations. + */ +export interface UserConfirmation { + /** + * Confirm a reviewed action before the core asks the SSO peer. + */ + confirmUserAction?(review: UserConfirmationReview): Promise; +} + +/** + * 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 {} diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs new file mode 100644 index 00000000..1d529c9f --- /dev/null +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -0,0 +1,746 @@ +//! Wire-protocol discriminant table. +//! +//! Auto-generated by truapi-codegen. Do not edit. +//! +//! Each method reserves either two ids (request/response) or four +//! (start/stop/interrupt/receive). The ids for each method are exposed +//! as a named const (`PREIMAGE_SUBMIT`, ...); [`WIRE_TABLE`] and the +//! generated dispatcher both reference those consts so the numbers live +//! in exactly one place. The table is sorted by request/start id. + +/// Request method wire discriminants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RequestFrameIds { + /// Discriminant for the request frame. + pub request_id: u8, + /// Discriminant for the response frame. + pub response_id: u8, +} + +/// Subscription method wire discriminants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SubscriptionFrameIds { + /// Discriminant for the start frame. + pub start_id: u8, + /// Discriminant for the stop frame. + pub stop_id: u8, + /// Discriminant for the interrupt frame (server-initiated termination). + pub interrupt_id: u8, + /// Discriminant for each receive frame (a streamed item). + pub receive_id: u8, +} + +/// A single wire-table row. +pub struct WireEntry { + /// Method name from the Rust trait. + pub method: &'static str, + /// What kind of slot this entry describes. + pub kind: WireKind, +} + +/// Wire-slot shape: request/response pair or subscription quartet. +pub enum WireKind { + /// Request/response method. + Request(RequestFrameIds), + /// Subscription method. + Subscription(SubscriptionFrameIds), +} + +/// Wire discriminants for `system_handshake`. +pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { + request_id: 0, + response_id: 1, +}; + +/// Wire discriminants for `system_feature_supported`. +pub const SYSTEM_FEATURE_SUPPORTED: RequestFrameIds = RequestFrameIds { + request_id: 2, + response_id: 3, +}; + +/// Wire discriminants for `notifications_send_push_notification`. +pub const NOTIFICATIONS_SEND_PUSH_NOTIFICATION: RequestFrameIds = RequestFrameIds { + request_id: 4, + response_id: 5, +}; + +/// Wire discriminants for `system_navigate_to`. +pub const SYSTEM_NAVIGATE_TO: RequestFrameIds = RequestFrameIds { + request_id: 6, + response_id: 7, +}; + +/// Wire discriminants for `permissions_request_device_permission`. +pub const PERMISSIONS_REQUEST_DEVICE_PERMISSION: RequestFrameIds = RequestFrameIds { + request_id: 8, + response_id: 9, +}; + +/// Wire discriminants for `permissions_request_remote_permission`. +pub const PERMISSIONS_REQUEST_REMOTE_PERMISSION: RequestFrameIds = RequestFrameIds { + request_id: 10, + response_id: 11, +}; + +/// Wire discriminants for `local_storage_read`. +pub const LOCAL_STORAGE_READ: RequestFrameIds = RequestFrameIds { + request_id: 12, + response_id: 13, +}; + +/// Wire discriminants for `local_storage_write`. +pub const LOCAL_STORAGE_WRITE: RequestFrameIds = RequestFrameIds { + request_id: 14, + response_id: 15, +}; + +/// Wire discriminants for `local_storage_clear`. +pub const LOCAL_STORAGE_CLEAR: RequestFrameIds = RequestFrameIds { + request_id: 16, + response_id: 17, +}; + +/// Wire discriminants for `account_connection_status_subscribe`. +pub const ACCOUNT_CONNECTION_STATUS_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 18, + stop_id: 19, + interrupt_id: 20, + receive_id: 21, +}; + +/// Wire discriminants for `account_get_account`. +pub const ACCOUNT_GET_ACCOUNT: RequestFrameIds = RequestFrameIds { + request_id: 22, + response_id: 23, +}; + +/// Wire discriminants for `account_get_account_alias`. +pub const ACCOUNT_GET_ACCOUNT_ALIAS: RequestFrameIds = RequestFrameIds { + request_id: 24, + response_id: 25, +}; + +/// Wire discriminants for `account_create_account_proof`. +pub const ACCOUNT_CREATE_ACCOUNT_PROOF: RequestFrameIds = RequestFrameIds { + request_id: 26, + response_id: 27, +}; + +/// Wire discriminants for `account_get_legacy_accounts`. +pub const ACCOUNT_GET_LEGACY_ACCOUNTS: RequestFrameIds = RequestFrameIds { + request_id: 28, + response_id: 29, +}; + +/// Wire discriminants for `signing_create_transaction`. +pub const SIGNING_CREATE_TRANSACTION: RequestFrameIds = RequestFrameIds { + request_id: 30, + response_id: 31, +}; + +/// Wire discriminants for `signing_create_transaction_with_legacy_account`. +pub const SIGNING_CREATE_TRANSACTION_WITH_LEGACY_ACCOUNT: RequestFrameIds = RequestFrameIds { + request_id: 32, + response_id: 33, +}; + +/// Wire discriminants for `signing_sign_raw_with_legacy_account`. +pub const SIGNING_SIGN_RAW_WITH_LEGACY_ACCOUNT: RequestFrameIds = RequestFrameIds { + request_id: 34, + response_id: 35, +}; + +/// Wire discriminants for `signing_sign_payload_with_legacy_account`. +pub const SIGNING_SIGN_PAYLOAD_WITH_LEGACY_ACCOUNT: RequestFrameIds = RequestFrameIds { + request_id: 36, + response_id: 37, +}; + +/// Wire discriminants for `chat_create_room`. +pub const CHAT_CREATE_ROOM: RequestFrameIds = RequestFrameIds { + request_id: 38, + response_id: 39, +}; + +/// Wire discriminants for `chat_register_bot`. +pub const CHAT_REGISTER_BOT: RequestFrameIds = RequestFrameIds { + request_id: 40, + response_id: 41, +}; + +/// Wire discriminants for `chat_list_subscribe`. +pub const CHAT_LIST_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 42, + stop_id: 43, + interrupt_id: 44, + receive_id: 45, +}; + +/// Wire discriminants for `chat_post_message`. +pub const CHAT_POST_MESSAGE: RequestFrameIds = RequestFrameIds { + request_id: 46, + response_id: 47, +}; + +/// Wire discriminants for `chat_action_subscribe`. +pub const CHAT_ACTION_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 48, + stop_id: 49, + interrupt_id: 50, + receive_id: 51, +}; + +/// Wire discriminants for `chat_custom_message_render_subscribe`. +pub const CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 52, + stop_id: 53, + interrupt_id: 54, + receive_id: 55, +}; + +/// Wire discriminants for `statement_store_subscribe`. +pub const STATEMENT_STORE_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 56, + stop_id: 57, + interrupt_id: 58, + receive_id: 59, +}; + +/// Wire discriminants for `statement_store_create_proof`. +pub const STATEMENT_STORE_CREATE_PROOF: RequestFrameIds = RequestFrameIds { + request_id: 60, + response_id: 61, +}; + +/// Wire discriminants for `statement_store_submit`. +pub const STATEMENT_STORE_SUBMIT: RequestFrameIds = RequestFrameIds { + request_id: 62, + response_id: 63, +}; + +/// Wire discriminants for `preimage_lookup_subscribe`. +pub const PREIMAGE_LOOKUP_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 64, + stop_id: 65, + interrupt_id: 66, + receive_id: 67, +}; + +/// Wire discriminants for `preimage_submit`. +pub const PREIMAGE_SUBMIT: RequestFrameIds = RequestFrameIds { + request_id: 68, + response_id: 69, +}; + +/// Wire discriminants for `chain_follow_head_subscribe`. +pub const CHAIN_FOLLOW_HEAD_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 76, + stop_id: 77, + interrupt_id: 78, + receive_id: 79, +}; + +/// Wire discriminants for `chain_get_head_header`. +pub const CHAIN_GET_HEAD_HEADER: RequestFrameIds = RequestFrameIds { + request_id: 80, + response_id: 81, +}; + +/// Wire discriminants for `chain_get_head_body`. +pub const CHAIN_GET_HEAD_BODY: RequestFrameIds = RequestFrameIds { + request_id: 82, + response_id: 83, +}; + +/// Wire discriminants for `chain_get_head_storage`. +pub const CHAIN_GET_HEAD_STORAGE: RequestFrameIds = RequestFrameIds { + request_id: 84, + response_id: 85, +}; + +/// Wire discriminants for `chain_call_head`. +pub const CHAIN_CALL_HEAD: RequestFrameIds = RequestFrameIds { + request_id: 86, + response_id: 87, +}; + +/// Wire discriminants for `chain_unpin_head`. +pub const CHAIN_UNPIN_HEAD: RequestFrameIds = RequestFrameIds { + request_id: 88, + response_id: 89, +}; + +/// Wire discriminants for `chain_continue_head`. +pub const CHAIN_CONTINUE_HEAD: RequestFrameIds = RequestFrameIds { + request_id: 90, + response_id: 91, +}; + +/// Wire discriminants for `chain_stop_head_operation`. +pub const CHAIN_STOP_HEAD_OPERATION: RequestFrameIds = RequestFrameIds { + request_id: 92, + response_id: 93, +}; + +/// Wire discriminants for `chain_get_spec_genesis_hash`. +pub const CHAIN_GET_SPEC_GENESIS_HASH: RequestFrameIds = RequestFrameIds { + request_id: 94, + response_id: 95, +}; + +/// Wire discriminants for `chain_get_spec_chain_name`. +pub const CHAIN_GET_SPEC_CHAIN_NAME: RequestFrameIds = RequestFrameIds { + request_id: 96, + response_id: 97, +}; + +/// Wire discriminants for `chain_get_spec_properties`. +pub const CHAIN_GET_SPEC_PROPERTIES: RequestFrameIds = RequestFrameIds { + request_id: 98, + response_id: 99, +}; + +/// Wire discriminants for `chain_broadcast_transaction`. +pub const CHAIN_BROADCAST_TRANSACTION: RequestFrameIds = RequestFrameIds { + request_id: 100, + response_id: 101, +}; + +/// Wire discriminants for `chain_stop_transaction`. +pub const CHAIN_STOP_TRANSACTION: RequestFrameIds = RequestFrameIds { + request_id: 102, + response_id: 103, +}; + +/// Wire discriminants for `theme_subscribe`. +pub const THEME_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 104, + stop_id: 105, + interrupt_id: 106, + receive_id: 107, +}; + +/// Wire discriminants for `entropy_derive`. +pub const ENTROPY_DERIVE: RequestFrameIds = RequestFrameIds { + request_id: 108, + response_id: 109, +}; + +/// Wire discriminants for `account_get_user_id`. +pub const ACCOUNT_GET_USER_ID: RequestFrameIds = RequestFrameIds { + request_id: 110, + response_id: 111, +}; + +/// Wire discriminants for `account_request_login`. +pub const ACCOUNT_REQUEST_LOGIN: RequestFrameIds = RequestFrameIds { + request_id: 112, + response_id: 113, +}; + +/// Wire discriminants for `signing_sign_raw`. +pub const SIGNING_SIGN_RAW: RequestFrameIds = RequestFrameIds { + request_id: 114, + response_id: 115, +}; + +/// Wire discriminants for `signing_sign_payload`. +pub const SIGNING_SIGN_PAYLOAD: RequestFrameIds = RequestFrameIds { + request_id: 116, + response_id: 117, +}; + +/// Wire discriminants for `payment_balance_subscribe`. +pub const PAYMENT_BALANCE_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 118, + stop_id: 119, + interrupt_id: 120, + receive_id: 121, +}; + +/// Wire discriminants for `payment_top_up`. +pub const PAYMENT_TOP_UP: RequestFrameIds = RequestFrameIds { + request_id: 122, + response_id: 123, +}; + +/// Wire discriminants for `payment_request`. +pub const PAYMENT_REQUEST: RequestFrameIds = RequestFrameIds { + request_id: 124, + response_id: 125, +}; + +/// Wire discriminants for `payment_status_subscribe`. +pub const PAYMENT_STATUS_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 126, + stop_id: 127, + interrupt_id: 128, + receive_id: 129, +}; + +/// Wire discriminants for `resource_allocation_request`. +pub const RESOURCE_ALLOCATION_REQUEST: RequestFrameIds = RequestFrameIds { + request_id: 130, + response_id: 131, +}; + +/// Wire discriminants for `statement_store_create_proof_authorized`. +pub const STATEMENT_STORE_CREATE_PROOF_AUTHORIZED: RequestFrameIds = RequestFrameIds { + request_id: 132, + response_id: 133, +}; + +/// Wire discriminants for `notifications_cancel_push_notification`. +pub const NOTIFICATIONS_CANCEL_PUSH_NOTIFICATION: RequestFrameIds = RequestFrameIds { + request_id: 134, + response_id: 135, +}; + +/// Wire discriminants for `coin_payment_create_purse`. +pub const COIN_PAYMENT_CREATE_PURSE: RequestFrameIds = RequestFrameIds { + request_id: 136, + response_id: 137, +}; + +/// Wire discriminants for `coin_payment_query_purse`. +pub const COIN_PAYMENT_QUERY_PURSE: RequestFrameIds = RequestFrameIds { + request_id: 138, + response_id: 139, +}; + +/// Wire discriminants for `coin_payment_rebalance_purse`. +pub const COIN_PAYMENT_REBALANCE_PURSE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 140, + stop_id: 141, + interrupt_id: 142, + receive_id: 143, +}; + +/// Wire discriminants for `coin_payment_delete_purse`. +pub const COIN_PAYMENT_DELETE_PURSE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 144, + stop_id: 145, + interrupt_id: 146, + receive_id: 147, +}; + +/// Wire discriminants for `coin_payment_create_receivable`. +pub const COIN_PAYMENT_CREATE_RECEIVABLE: RequestFrameIds = RequestFrameIds { + request_id: 148, + response_id: 149, +}; + +/// Wire discriminants for `coin_payment_create_cheque`. +pub const COIN_PAYMENT_CREATE_CHEQUE: RequestFrameIds = RequestFrameIds { + request_id: 150, + response_id: 151, +}; + +/// Wire discriminants for `coin_payment_deposit`. +pub const COIN_PAYMENT_DEPOSIT: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 152, + stop_id: 153, + interrupt_id: 154, + receive_id: 155, +}; + +/// Wire discriminants for `coin_payment_refund`. +pub const COIN_PAYMENT_REFUND: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 156, + stop_id: 157, + interrupt_id: 158, + receive_id: 159, +}; + +/// Wire discriminants for `coin_payment_listen_for_payment`. +pub const COIN_PAYMENT_LISTEN_FOR_PAYMENT: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 160, + stop_id: 161, + interrupt_id: 162, + receive_id: 163, +}; + +#[cfg(debug_assertions)] +/// Wire discriminants for `testing_version_probe`. +pub const TESTING_VERSION_PROBE: RequestFrameIds = RequestFrameIds { + request_id: 164, + response_id: 165, +}; + +#[cfg(debug_assertions)] +/// Wire discriminants for `testing_echo_error`. +pub const TESTING_ECHO_ERROR: RequestFrameIds = RequestFrameIds { + request_id: 166, + response_id: 167, +}; + +/// The full wire table. Ordering is part of the wire protocol; +/// only ever append. Removed methods leave their slot empty. +pub const WIRE_TABLE: &[WireEntry] = &[ + WireEntry { + method: "system_handshake", + kind: WireKind::Request(SYSTEM_HANDSHAKE), + }, + WireEntry { + method: "system_feature_supported", + kind: WireKind::Request(SYSTEM_FEATURE_SUPPORTED), + }, + WireEntry { + method: "notifications_send_push_notification", + kind: WireKind::Request(NOTIFICATIONS_SEND_PUSH_NOTIFICATION), + }, + WireEntry { + method: "system_navigate_to", + kind: WireKind::Request(SYSTEM_NAVIGATE_TO), + }, + WireEntry { + method: "permissions_request_device_permission", + kind: WireKind::Request(PERMISSIONS_REQUEST_DEVICE_PERMISSION), + }, + WireEntry { + method: "permissions_request_remote_permission", + kind: WireKind::Request(PERMISSIONS_REQUEST_REMOTE_PERMISSION), + }, + WireEntry { + method: "local_storage_read", + kind: WireKind::Request(LOCAL_STORAGE_READ), + }, + WireEntry { + method: "local_storage_write", + kind: WireKind::Request(LOCAL_STORAGE_WRITE), + }, + WireEntry { + method: "local_storage_clear", + kind: WireKind::Request(LOCAL_STORAGE_CLEAR), + }, + WireEntry { + method: "account_connection_status_subscribe", + kind: WireKind::Subscription(ACCOUNT_CONNECTION_STATUS_SUBSCRIBE), + }, + WireEntry { + method: "account_get_account", + kind: WireKind::Request(ACCOUNT_GET_ACCOUNT), + }, + WireEntry { + method: "account_get_account_alias", + kind: WireKind::Request(ACCOUNT_GET_ACCOUNT_ALIAS), + }, + WireEntry { + method: "account_create_account_proof", + kind: WireKind::Request(ACCOUNT_CREATE_ACCOUNT_PROOF), + }, + WireEntry { + method: "account_get_legacy_accounts", + kind: WireKind::Request(ACCOUNT_GET_LEGACY_ACCOUNTS), + }, + WireEntry { + method: "signing_create_transaction", + kind: WireKind::Request(SIGNING_CREATE_TRANSACTION), + }, + WireEntry { + method: "signing_create_transaction_with_legacy_account", + kind: WireKind::Request(SIGNING_CREATE_TRANSACTION_WITH_LEGACY_ACCOUNT), + }, + WireEntry { + method: "signing_sign_raw_with_legacy_account", + kind: WireKind::Request(SIGNING_SIGN_RAW_WITH_LEGACY_ACCOUNT), + }, + WireEntry { + method: "signing_sign_payload_with_legacy_account", + kind: WireKind::Request(SIGNING_SIGN_PAYLOAD_WITH_LEGACY_ACCOUNT), + }, + WireEntry { + method: "chat_create_room", + kind: WireKind::Request(CHAT_CREATE_ROOM), + }, + WireEntry { + method: "chat_register_bot", + kind: WireKind::Request(CHAT_REGISTER_BOT), + }, + WireEntry { + method: "chat_list_subscribe", + kind: WireKind::Subscription(CHAT_LIST_SUBSCRIBE), + }, + WireEntry { + method: "chat_post_message", + kind: WireKind::Request(CHAT_POST_MESSAGE), + }, + WireEntry { + method: "chat_action_subscribe", + kind: WireKind::Subscription(CHAT_ACTION_SUBSCRIBE), + }, + WireEntry { + method: "chat_custom_message_render_subscribe", + kind: WireKind::Subscription(CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE), + }, + WireEntry { + method: "statement_store_subscribe", + kind: WireKind::Subscription(STATEMENT_STORE_SUBSCRIBE), + }, + WireEntry { + method: "statement_store_create_proof", + kind: WireKind::Request(STATEMENT_STORE_CREATE_PROOF), + }, + WireEntry { + method: "statement_store_submit", + kind: WireKind::Request(STATEMENT_STORE_SUBMIT), + }, + WireEntry { + method: "preimage_lookup_subscribe", + kind: WireKind::Subscription(PREIMAGE_LOOKUP_SUBSCRIBE), + }, + WireEntry { + method: "preimage_submit", + kind: WireKind::Request(PREIMAGE_SUBMIT), + }, + WireEntry { + method: "chain_follow_head_subscribe", + kind: WireKind::Subscription(CHAIN_FOLLOW_HEAD_SUBSCRIBE), + }, + WireEntry { + method: "chain_get_head_header", + kind: WireKind::Request(CHAIN_GET_HEAD_HEADER), + }, + WireEntry { + method: "chain_get_head_body", + kind: WireKind::Request(CHAIN_GET_HEAD_BODY), + }, + WireEntry { + method: "chain_get_head_storage", + kind: WireKind::Request(CHAIN_GET_HEAD_STORAGE), + }, + WireEntry { + method: "chain_call_head", + kind: WireKind::Request(CHAIN_CALL_HEAD), + }, + WireEntry { + method: "chain_unpin_head", + kind: WireKind::Request(CHAIN_UNPIN_HEAD), + }, + WireEntry { + method: "chain_continue_head", + kind: WireKind::Request(CHAIN_CONTINUE_HEAD), + }, + WireEntry { + method: "chain_stop_head_operation", + kind: WireKind::Request(CHAIN_STOP_HEAD_OPERATION), + }, + WireEntry { + method: "chain_get_spec_genesis_hash", + kind: WireKind::Request(CHAIN_GET_SPEC_GENESIS_HASH), + }, + WireEntry { + method: "chain_get_spec_chain_name", + kind: WireKind::Request(CHAIN_GET_SPEC_CHAIN_NAME), + }, + WireEntry { + method: "chain_get_spec_properties", + kind: WireKind::Request(CHAIN_GET_SPEC_PROPERTIES), + }, + WireEntry { + method: "chain_broadcast_transaction", + kind: WireKind::Request(CHAIN_BROADCAST_TRANSACTION), + }, + WireEntry { + method: "chain_stop_transaction", + kind: WireKind::Request(CHAIN_STOP_TRANSACTION), + }, + WireEntry { + method: "theme_subscribe", + kind: WireKind::Subscription(THEME_SUBSCRIBE), + }, + WireEntry { + method: "entropy_derive", + kind: WireKind::Request(ENTROPY_DERIVE), + }, + WireEntry { + method: "account_get_user_id", + kind: WireKind::Request(ACCOUNT_GET_USER_ID), + }, + WireEntry { + method: "account_request_login", + kind: WireKind::Request(ACCOUNT_REQUEST_LOGIN), + }, + WireEntry { + method: "signing_sign_raw", + kind: WireKind::Request(SIGNING_SIGN_RAW), + }, + WireEntry { + method: "signing_sign_payload", + kind: WireKind::Request(SIGNING_SIGN_PAYLOAD), + }, + WireEntry { + method: "payment_balance_subscribe", + kind: WireKind::Subscription(PAYMENT_BALANCE_SUBSCRIBE), + }, + WireEntry { + method: "payment_top_up", + kind: WireKind::Request(PAYMENT_TOP_UP), + }, + WireEntry { + method: "payment_request", + kind: WireKind::Request(PAYMENT_REQUEST), + }, + WireEntry { + method: "payment_status_subscribe", + kind: WireKind::Subscription(PAYMENT_STATUS_SUBSCRIBE), + }, + WireEntry { + method: "resource_allocation_request", + kind: WireKind::Request(RESOURCE_ALLOCATION_REQUEST), + }, + WireEntry { + method: "statement_store_create_proof_authorized", + kind: WireKind::Request(STATEMENT_STORE_CREATE_PROOF_AUTHORIZED), + }, + WireEntry { + method: "notifications_cancel_push_notification", + kind: WireKind::Request(NOTIFICATIONS_CANCEL_PUSH_NOTIFICATION), + }, + WireEntry { + method: "coin_payment_create_purse", + kind: WireKind::Request(COIN_PAYMENT_CREATE_PURSE), + }, + WireEntry { + method: "coin_payment_query_purse", + kind: WireKind::Request(COIN_PAYMENT_QUERY_PURSE), + }, + WireEntry { + method: "coin_payment_rebalance_purse", + kind: WireKind::Subscription(COIN_PAYMENT_REBALANCE_PURSE), + }, + WireEntry { + method: "coin_payment_delete_purse", + kind: WireKind::Subscription(COIN_PAYMENT_DELETE_PURSE), + }, + WireEntry { + method: "coin_payment_create_receivable", + kind: WireKind::Request(COIN_PAYMENT_CREATE_RECEIVABLE), + }, + WireEntry { + method: "coin_payment_create_cheque", + kind: WireKind::Request(COIN_PAYMENT_CREATE_CHEQUE), + }, + WireEntry { + method: "coin_payment_deposit", + kind: WireKind::Subscription(COIN_PAYMENT_DEPOSIT), + }, + WireEntry { + method: "coin_payment_refund", + kind: WireKind::Subscription(COIN_PAYMENT_REFUND), + }, + WireEntry { + method: "coin_payment_listen_for_payment", + kind: WireKind::Subscription(COIN_PAYMENT_LISTEN_FOR_PAYMENT), + }, + #[cfg(debug_assertions)] + WireEntry { + method: "testing_version_probe", + kind: WireKind::Request(TESTING_VERSION_PROBE), + }, + #[cfg(debug_assertions)] + WireEntry { + method: "testing_echo_error", + kind: WireKind::Request(TESTING_ECHO_ERROR), + }, +]; diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts new file mode 100644 index 00000000..34275dbf --- /dev/null +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -0,0 +1,117 @@ +// Auto-generated by truapi-codegen. Do not edit. +// +// Worker-side metadata and proxy functions for the raw WASM callback +// surface. The worker transport/lifecycle remains hand-written; this +// 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"; + +export const CALLBACK_NAMES = [ + "authStateChanged", + "readCoreStorage", + "writeCoreStorage", + "clearCoreStorage", + "featureSupported", + "navigateTo", + "pushNotification", + "cancelNotification", + "devicePermission", + "remotePermission", + "submitPreimage", + "read", + "write", + "clear", + "confirmUserAction", +] as const; +export type CallbackName = typeof CALLBACK_NAMES[number]; + +export const SUBSCRIPTION_NAMES = [ + "lookupPreimage", + "subscribeTheme", +] as const; +export type SubscriptionName = typeof SUBSCRIPTION_NAMES[number]; + +export interface WorkerCallbackBridge { + callbackRequest(name: CallbackName, args: readonly unknown[]): Promise; + startSubscription( + name: SubscriptionName, + payload: Uint8Array | null, + sendItem: (value: T) => void, + ): () => void; + chainConnect: ChainConnect; +} + +function rawCallbacks(bridge: WorkerCallbackBridge): Required> { + return { + authStateChanged: (state) => + void bridge.callbackRequest("authStateChanged", [state]).catch(() => {}), + readCoreStorage: (key) => + bridge.callbackRequest("readCoreStorage", [key]) as ReturnType, + writeCoreStorage: (key, value) => + bridge.callbackRequest("writeCoreStorage", [key, value]) as ReturnType, + clearCoreStorage: (key) => + bridge.callbackRequest("clearCoreStorage", [key]) as ReturnType, + featureSupported: (request) => + bridge.callbackRequest("featureSupported", [request]) as ReturnType, + navigateTo: (url) => + bridge.callbackRequest("navigateTo", [url]) as ReturnType, + pushNotification: (notification) => + bridge.callbackRequest("pushNotification", [notification]) as ReturnType, + cancelNotification: (id) => + bridge.callbackRequest("cancelNotification", [id]) as ReturnType, + devicePermission: (request) => + bridge.callbackRequest("devicePermission", [request]) as ReturnType, + remotePermission: (request) => + bridge.callbackRequest("remotePermission", [request]) as ReturnType, + submitPreimage: (value) => + bridge.callbackRequest("submitPreimage", [value]) as ReturnType, + read: (key) => + bridge.callbackRequest("read", [key]) as ReturnType, + write: (key, value) => + bridge.callbackRequest("write", [key, value]) as ReturnType, + clear: (key) => + bridge.callbackRequest("clear", [key]) as ReturnType, + confirmUserAction: (review) => + bridge.callbackRequest("confirmUserAction", [review]) as ReturnType, + }; +} + +function subscriptionRawCallbacks(bridge: WorkerCallbackBridge): Required> { + return { + lookupPreimage: (key, sendItem) => + bridge.startSubscription("lookupPreimage", key, sendItem), + subscribeTheme: (sendItem) => + bridge.startSubscription("subscribeTheme", null, sendItem), + }; +} + +export function createWorkerRawCallbacks( + bridge: WorkerCallbackBridge, +): Record { + const callbacks: Record = { + ...rawCallbacks(bridge), + ...subscriptionRawCallbacks(bridge), + chainConnect: bridge.chainConnect, + }; + return callbacks; +} + +export function startRawSubscription( + callbacks: RawCallbacks, + name: SubscriptionName, + payload: Uint8Array | null, + sendItem: (value?: unknown) => void, +): (() => void) | void { + switch (name) { + case "lookupPreimage": + if (payload === null) { + console.warn(`[truapi worker] ${name} requires payload`); + return undefined; + } + return callbacks.lookupPreimage(payload, sendItem); + case "subscribeTheme": + return callbacks.subscribeTheme(sendItem); + } +} diff --git a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs new file mode 100644 index 00000000..98f7a095 --- /dev/null +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -0,0 +1,286 @@ +//! Golden snapshot test for the Rust dispatcher emitter. +//! +//! Each test runs `cargo +nightly rustdoc -p truapi` into its own +//! `--target-dir` under a per-test tempdir so concurrent test execution +//! cannot race on the shared `target/doc/truapi.json` path. Nightly Rust +//! is required; if it is not available the test panics rather than +//! silently passing (set up rustup with `rustup toolchain install nightly`). + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn quoted_strings_in_const_array(src: &str, const_name: &str) -> Vec { + let marker = format!("export const {const_name} = ["); + let start = src + .find(&marker) + .unwrap_or_else(|| panic!("missing {const_name}")); + let rest = &src[start + marker.len()..]; + let end = rest + .find("] as const") + .unwrap_or_else(|| panic!("unterminated {const_name}")); + rest[..end] + .lines() + .filter_map(|line| { + let trimmed = line.trim().trim_end_matches(','); + trimmed + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .map(str::to_string) + }) + .collect() +} + +fn wasm_optional_callback_names(workspace: &Path) -> Vec { + let wasm_rs = workspace.join("rust/crates/truapi-server/src/wasm.rs"); + if !wasm_rs.exists() { + return Vec::new(); + } + let src = fs::read_to_string(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 +/// pass vacuously. +fn produce_rustdoc_json(workspace_root: &Path, target_dir: &Path) -> PathBuf { + produce_rustdoc_json_for_package(workspace_root, target_dir, "truapi") +} + +fn produce_rustdoc_json_for_package( + workspace_root: &Path, + target_dir: &Path, + package: &str, +) -> PathBuf { + let output = Command::new("cargo") + .args(["+nightly", "rustdoc", "-p", package, "--target-dir"]) + .arg(target_dir) + .args(["--", "-Z", "unstable-options", "--output-format", "json"]) + .current_dir(workspace_root) + .output() + .expect( + "failed to spawn `cargo +nightly rustdoc`; install nightly via \ + `rustup toolchain install nightly`", + ); + assert!( + output.status.success(), + "`cargo +nightly rustdoc -p {package}` failed (status {}); nightly toolchain is required.\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + let json_name = package.replace('-', "_"); + let json = target_dir.join(format!("doc/{json_name}.json")); + assert!( + json.exists(), + "rustdoc JSON not found at {} after successful rustdoc invocation", + json.display(), + ); + json +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .expect("workspace root above rust/crates/truapi-codegen") + .to_path_buf() +} + +#[test] +fn golden_dispatcher_and_wire_table() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace = workspace_root(); + + let tempdir = tempfile::tempdir().expect("tempdir"); + let rustdoc_json = produce_rustdoc_json(&workspace, &tempdir.path().join("rustdoc-target")); + + let out = Command::new(env!("CARGO_BIN_EXE_truapi-codegen")) + .args([ + "--input", + rustdoc_json.to_str().unwrap(), + "--output", + tempdir.path().join("ts").to_str().unwrap(), + "--rust-output", + tempdir.path().join("rust").to_str().unwrap(), + ]) + .output() + .expect("run truapi-codegen"); + assert!( + out.status.success(), + "codegen failed: stdout=\n{}\nstderr=\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + + // Compare both emitted files against the goldens. We assert on + // wire_table.rs first because it's small and the diff is easy to + // read when the wire ids drift. + let golden_dir = manifest_dir.join("tests/golden"); + let cases = [ + ("wire_table.rs", "wire_table.rs"), + ("dispatcher.rs", "dispatcher.rs"), + ]; + for (golden_name, output_name) in cases { + let golden = fs::read_to_string(golden_dir.join(golden_name)) + .unwrap_or_else(|e| panic!("read {golden_name}: {e}")); + let actual = fs::read_to_string(tempdir.path().join("rust").join(output_name)) + .unwrap_or_else(|e| panic!("read generated {output_name}: {e}")); + if golden != actual { + // Dump actual to a sibling file for easy inspection + // when running locally. + let dump = manifest_dir.join(format!("tests/golden/{output_name}.actual")); + let _ = fs::write(&dump, &actual); + panic!( + "golden mismatch for {output_name}; wrote actual to {}", + dump.display() + ); + } + } +} + +/// Idempotence guard at the integration level: running the binary twice +/// against the same input must produce identical output. This catches +/// non-determinism (HashMap iteration order, timestamps, etc.) that the +/// inline unit tests might miss because they exercise smaller APIs. +#[test] +fn binary_emission_is_idempotent() { + let workspace = workspace_root(); + let tempdir = tempfile::tempdir().expect("tempdir"); + let rustdoc_json = produce_rustdoc_json(&workspace, &tempdir.path().join("rustdoc-target")); + + let run_once = || -> (String, String) { + let tmp = tempfile::tempdir().unwrap(); + let status = Command::new(env!("CARGO_BIN_EXE_truapi-codegen")) + .args([ + "--input", + rustdoc_json.to_str().unwrap(), + "--output", + tmp.path().join("ts").to_str().unwrap(), + "--rust-output", + tmp.path().join("rust").to_str().unwrap(), + ]) + .status() + .expect("run truapi-codegen"); + assert!(status.success(), "codegen run failed"); + let dispatcher = + fs::read_to_string(tmp.path().join("rust/dispatcher.rs")).expect("read dispatcher"); + let wire_table = + fs::read_to_string(tmp.path().join("rust/wire_table.rs")).expect("read wire_table"); + (dispatcher, wire_table) + }; + + let (a_disp, a_wire) = run_once(); + let (b_disp, b_wire) = run_once(); + assert_eq!(a_disp, b_disp, "dispatcher.rs differs between runs"); + assert_eq!(a_wire, b_wire, "wire_table.rs differs between runs"); +} + +#[test] +fn golden_host_callbacks_ts() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace = workspace_root(); + + let tempdir = tempfile::tempdir().expect("tempdir"); + let truapi_json = produce_rustdoc_json(&workspace, &tempdir.path().join("rustdoc-target")); + let platform_json = produce_rustdoc_json_for_package( + &workspace, + &tempdir.path().join("rustdoc-platform-target"), + "truapi-platform", + ); + + let out = Command::new(env!("CARGO_BIN_EXE_truapi-codegen")) + .args([ + "--input", + truapi_json.to_str().unwrap(), + "--output", + tempdir.path().join("ts").to_str().unwrap(), + "--platform-input", + platform_json.to_str().unwrap(), + "--platform-ts-output", + tempdir.path().join("host").to_str().unwrap(), + "--platform-wasm-adapter-output", + tempdir.path().join("wasm").to_str().unwrap(), + ]) + .output() + .expect("run truapi-codegen"); + assert!( + out.status.success(), + "codegen failed: stdout=\n{}\nstderr=\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + + let golden_path = manifest_dir.join("tests/golden/host-callbacks.ts"); + let golden = + fs::read_to_string(&golden_path).unwrap_or_else(|e| panic!("read host-callbacks.ts: {e}")); + let actual = fs::read_to_string(tempdir.path().join("host/host-callbacks.ts")) + .expect("read generated host-callbacks.ts"); + if golden != actual { + let dump = manifest_dir.join("tests/golden/host-callbacks.ts.actual"); + let _ = fs::write(&dump, &actual); + panic!( + "golden mismatch for host-callbacks.ts; wrote actual to {}", + dump.display() + ); + } + + let adapter_golden_path = manifest_dir.join("tests/golden/host-callbacks-adapter.ts"); + let adapter_actual = fs::read_to_string(tempdir.path().join("wasm/host-callbacks-adapter.ts")) + .expect("read generated host-callbacks-adapter.ts"); + let adapter_golden = fs::read_to_string(&adapter_golden_path).unwrap_or_default(); + if adapter_golden != adapter_actual { + let dump = manifest_dir.join("tests/golden/host-callbacks-adapter.ts.actual"); + let _ = fs::write(&dump, &adapter_actual); + panic!( + "golden mismatch for host-callbacks-adapter.ts; wrote actual to {}", + dump.display() + ); + } + + let worker_golden_path = manifest_dir.join("tests/golden/worker-callbacks.ts"); + let worker_actual = fs::read_to_string(tempdir.path().join("wasm/worker-callbacks.ts")) + .expect("read generated worker-callbacks.ts"); + let worker_golden = fs::read_to_string(&worker_golden_path).unwrap_or_default(); + if worker_golden != worker_actual { + let dump = manifest_dir.join("tests/golden/worker-callbacks.ts.actual"); + let _ = fs::write(&dump, &worker_actual); + panic!( + "golden mismatch for worker-callbacks.ts; wrote actual to {}", + dump.display() + ); + } + + assert!( + !worker_actual.contains("OPTIONAL_CALLBACK_NAMES"), + "worker callback generation should not expose an optional callback manifest" + ); + let mut generated_names = quoted_strings_in_const_array(&worker_actual, "CALLBACK_NAMES"); + generated_names.extend(quoted_strings_in_const_array( + &worker_actual, + "SUBSCRIPTION_NAMES", + )); + let wasm_optional = wasm_optional_callback_names(&workspace); + for name in wasm_optional { + assert!( + generated_names.contains(&name), + "generated worker names must include JsBridge optional callback `{name}`" + ); + } +} diff --git a/rust/crates/truapi-server/src/generated/dispatcher.rs b/rust/crates/truapi-server/src/generated/dispatcher.rs new file mode 100644 index 00000000..4a89f611 --- /dev/null +++ b/rust/crates/truapi-server/src/generated/dispatcher.rs @@ -0,0 +1,2182 @@ +//! Wire dispatcher for the unified `TrUApi` trait. +//! +//! Auto-generated by truapi-codegen. Do not edit. + +use std::sync::Arc; + +use parity_scale_codec::Decode; + +use truapi::CallContext; +use truapi::api::{ + Account, Chain, Chat, CoinPayment, Entropy, LocalStorage, Notifications, Payment, Permissions, + Preimage, ResourceAllocation, Signing, StatementStore, System, Theme, +}; +use truapi::versioned::{self, Versioned}; + +use crate::dispatcher::Dispatcher; +use crate::frame::encode_raw_err_payload; +use crate::frame::encode_raw_unit_ok_payload; +use crate::frame::encode_versioned_err_payload; +use crate::frame::encode_versioned_interrupt_payload; +use crate::frame::encode_versioned_ok_payload; +use crate::frame::encode_versioned_unit_ok_payload; +use crate::generated::wire_table; +use crate::subscription::subscription_stream; +#[cfg(debug_assertions)] +use truapi::api::Testing; + +/// Register every TrUAPI method with the dispatcher. +pub fn register

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: truapi::api::TrUApi + 'static, +{ + register_account(dispatcher, host.clone()); + register_chain(dispatcher, host.clone()); + register_chat(dispatcher, host.clone()); + register_coin_payment(dispatcher, host.clone()); + register_entropy(dispatcher, host.clone()); + register_local_storage(dispatcher, host.clone()); + register_notifications(dispatcher, host.clone()); + register_payment(dispatcher, host.clone()); + register_permissions(dispatcher, host.clone()); + register_preimage(dispatcher, host.clone()); + register_resource_allocation(dispatcher, host.clone()); + register_signing(dispatcher, host.clone()); + register_statement_store(dispatcher, host.clone()); + register_system(dispatcher, host.clone()); + #[cfg(debug_assertions)] + register_testing(dispatcher, host.clone()); + register_theme(dispatcher, host); +} + +fn register_account

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Account + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_subscription( + wire_table::ACCOUNT_CONNECTION_STATUS_SUBSCRIBE, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let _ = bytes; + let cx = CallContext::with_request_id(request_id.clone()); + let stream = host.connection_status_subscribe(&cx).await; + Ok(subscription_stream::< + versioned::account::HostAccountConnectionStatusSubscribeItem, + _, + >(stream)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::ACCOUNT_GET_ACCOUNT, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountGetRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::account::HostAccountGetError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountGetResponse = + match host.get_account(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::ACCOUNT_GET_ACCOUNT_ALIAS, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountGetAliasRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::account::HostAccountGetAliasError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountGetAliasResponse = + match host.get_account_alias(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::ACCOUNT_CREATE_ACCOUNT_PROOF, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountCreateProofRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::account::HostAccountCreateProofError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountCreateProofResponse = + match host.create_account_proof(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::ACCOUNT_GET_LEGACY_ACCOUNTS, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostGetLegacyAccountsRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::account::HostGetLegacyAccountsError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostGetLegacyAccountsResponse = + match host.get_legacy_accounts(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::ACCOUNT_GET_USER_ID, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostGetUserIdRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::account::HostGetUserIdError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostGetUserIdResponse = + match host.get_user_id(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host; + dispatcher.on_request( + wire_table::ACCOUNT_REQUEST_LOGIN, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostRequestLoginRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::account::HostRequestLoginError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostRequestLoginResponse = + match host.request_login(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } +} + +fn register_chain

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Chain + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_subscription( + wire_table::CHAIN_FOLLOW_HEAD_SUBSCRIBE, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainHeadFollowRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(_) => return Err(Vec::new()), + }; + let cx = CallContext::with_request_id(request_id.clone()); + let stream = host.follow_head_subscribe(&cx, request).await; + Ok(subscription_stream::< + versioned::chain::RemoteChainHeadFollowItem, + _, + >(stream)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::CHAIN_GET_HEAD_HEADER, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainHeadHeaderRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::chain::RemoteChainHeadHeaderError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainHeadHeaderResponse = + match host.get_head_header(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::CHAIN_GET_HEAD_BODY, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainHeadBodyRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::chain::RemoteChainHeadBodyError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainHeadBodyResponse = + match host.get_head_body(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::CHAIN_GET_HEAD_STORAGE, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainHeadStorageRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::chain::RemoteChainHeadStorageError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainHeadStorageResponse = + match host.get_head_storage(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::CHAIN_CALL_HEAD, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainHeadCallRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::chain::RemoteChainHeadCallError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainHeadCallResponse = + match host.call_head(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::CHAIN_UNPIN_HEAD, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainHeadUnpinRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::chain::RemoteChainHeadUnpinError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainHeadUnpinResponse = + match host.unpin_head(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::CHAIN_CONTINUE_HEAD, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainHeadContinueRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::chain::RemoteChainHeadContinueError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainHeadContinueResponse = + match host.continue_head(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAIN_STOP_HEAD_OPERATION, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainHeadStopOperationRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainHeadStopOperationResponse = match host.stop_head_operation(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAIN_GET_SPEC_GENESIS_HASH, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainSpecGenesisHashRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainSpecGenesisHashResponse = match host.get_spec_genesis_hash(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::CHAIN_GET_SPEC_CHAIN_NAME, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainSpecChainNameRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::chain::RemoteChainSpecChainNameError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainSpecChainNameResponse = + match host.get_spec_chain_name(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::CHAIN_GET_SPEC_PROPERTIES, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainSpecPropertiesRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::chain::RemoteChainSpecPropertiesError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainSpecPropertiesResponse = + match host.get_spec_properties(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::CHAIN_BROADCAST_TRANSACTION, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainTransactionBroadcastRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainTransactionBroadcastResponse = match host.broadcast_transaction(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host; + dispatcher.on_request(wire_table::CHAIN_STOP_TRANSACTION, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chain::RemoteChainTransactionStopRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chain::RemoteChainTransactionStopResponse = match host.stop_transaction(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } +} + +fn register_chat

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Chat + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_request( + wire_table::CHAT_CREATE_ROOM, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chat::HostChatCreateRoomRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::chat::HostChatCreateRoomError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chat::HostChatCreateRoomResponse = + match host.create_room(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::CHAT_REGISTER_BOT, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chat::HostChatRegisterBotRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::chat::HostChatRegisterBotError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chat::HostChatRegisterBotResponse = + match host.register_bot(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_subscription( + wire_table::CHAT_LIST_SUBSCRIBE, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let _ = bytes; + let cx = CallContext::with_request_id(request_id.clone()); + let stream = host.list_subscribe(&cx).await; + Ok(subscription_stream::< + versioned::chat::HostChatListSubscribeItem, + _, + >(stream)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::CHAT_POST_MESSAGE, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chat::HostChatPostMessageRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::chat::HostChatPostMessageError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::chat::HostChatPostMessageResponse = + match host.post_message(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_subscription( + wire_table::CHAT_ACTION_SUBSCRIBE, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let _ = bytes; + let cx = CallContext::with_request_id(request_id.clone()); + let stream = host.action_subscribe(&cx).await; + Ok(subscription_stream::< + versioned::chat::HostChatActionSubscribeItem, + _, + >(stream)) + }) + }, + ); + } + { + let host = host; + dispatcher.on_subscription( + wire_table::CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::chat::ProductChatCustomMessageRenderSubscribeRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(_) => return Err(Vec::new()), + }; + let cx = CallContext::with_request_id(request_id.clone()); + let stream = host.custom_message_render_subscribe(&cx, request).await; + Ok(subscription_stream::< + versioned::chat::ProductChatCustomMessageRenderSubscribeItem, + _, + >(stream)) + }) + }, + ); + } +} + +fn register_coin_payment

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: CoinPayment + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_request(wire_table::COIN_PAYMENT_CREATE_PURSE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentCreatePurseRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::coin_payment::HostCoinPaymentCreatePurseResponse = match host.create_purse(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::COIN_PAYMENT_QUERY_PURSE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentQueryPurseRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::coin_payment::HostCoinPaymentQueryPurseResponse = match host.query_purse(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::COIN_PAYMENT_REBALANCE_PURSE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentRebalancePurseRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let stream = match host.rebalance_purse(&cx, request).await { + Ok(sub) => sub, + Err(err) => { + return Err(encode_versioned_interrupt_payload(err, target_version)); + } + }; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::COIN_PAYMENT_DELETE_PURSE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentDeletePurseRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let stream = match host.delete_purse(&cx, request).await { + Ok(sub) => sub, + Err(err) => { + return Err(encode_versioned_interrupt_payload(err, target_version)); + } + }; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::COIN_PAYMENT_CREATE_RECEIVABLE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentCreateReceivableRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::coin_payment::HostCoinPaymentCreateReceivableResponse = match host.create_receivable(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::COIN_PAYMENT_CREATE_CHEQUE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentCreateChequeRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::coin_payment::HostCoinPaymentCreateChequeResponse = match host.create_cheque(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::COIN_PAYMENT_DEPOSIT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentDepositRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let stream = match host.deposit(&cx, request).await { + Ok(sub) => sub, + Err(err) => { + return Err(encode_versioned_interrupt_payload(err, target_version)); + } + }; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::COIN_PAYMENT_REFUND, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentRefundRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let stream = match host.refund(&cx, request).await { + Ok(sub) => sub, + Err(err) => { + return Err(encode_versioned_interrupt_payload(err, target_version)); + } + }; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host; + dispatcher.on_subscription(wire_table::COIN_PAYMENT_LISTEN_FOR_PAYMENT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::coin_payment::HostCoinPaymentListenForRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let stream = match host.listen_for_payment(&cx, request).await { + Ok(sub) => sub, + Err(err) => { + return Err(encode_versioned_interrupt_payload(err, target_version)); + } + }; + Ok(subscription_stream::(stream)) + }) + }); + } +} + +fn register_entropy

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Entropy + Send + Sync + 'static, +{ + { + let host = host; + dispatcher.on_request( + wire_table::ENTROPY_DERIVE, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::entropy::HostDeriveEntropyRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::entropy::HostDeriveEntropyError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::entropy::HostDeriveEntropyResponse = + match host.derive(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } +} + +fn register_local_storage

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: LocalStorage + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_request(wire_table::LOCAL_STORAGE_READ, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::local_storage::HostLocalStorageReadRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::local_storage::HostLocalStorageReadResponse = match host.read(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::LOCAL_STORAGE_WRITE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::local_storage::HostLocalStorageWriteRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::local_storage::HostLocalStorageWriteResponse = match host.write(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host; + dispatcher.on_request(wire_table::LOCAL_STORAGE_CLEAR, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::local_storage::HostLocalStorageClearRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::local_storage::HostLocalStorageClearResponse = match host.clear(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } +} + +fn register_notifications

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Notifications + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_request(wire_table::NOTIFICATIONS_SEND_PUSH_NOTIFICATION, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::notifications::HostPushNotificationRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::notifications::HostPushNotificationResponse = match host.send_push_notification(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host; + dispatcher.on_request(wire_table::NOTIFICATIONS_CANCEL_PUSH_NOTIFICATION, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::notifications::HostPushNotificationCancelRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::notifications::HostPushNotificationCancelResponse = match host.cancel_push_notification(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } +} + +fn register_payment

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Payment + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::PAYMENT_BALANCE_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::payment::HostPaymentBalanceSubscribeRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let stream = match host.balance_subscribe(&cx, request).await { + Ok(sub) => sub, + Err(err) => { + return Err(encode_versioned_interrupt_payload(err, target_version)); + } + }; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::PAYMENT_REQUEST, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::payment::HostPaymentRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::payment::HostPaymentResponse = + match host.request(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::PAYMENT_STATUS_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::payment::HostPaymentStatusSubscribeRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let stream = match host.status_subscribe(&cx, request).await { + Ok(sub) => sub, + Err(err) => { + return Err(encode_versioned_interrupt_payload(err, target_version)); + } + }; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host; + dispatcher.on_request( + wire_table::PAYMENT_TOP_UP, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::payment::HostPaymentTopUpRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::payment::HostPaymentTopUpError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::payment::HostPaymentTopUpResponse = + match host.top_up(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } +} + +fn register_permissions

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Permissions + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_request(wire_table::PERMISSIONS_REQUEST_DEVICE_PERMISSION, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::permissions::HostDevicePermissionRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::permissions::HostDevicePermissionResponse = match host.request_device_permission(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host; + dispatcher.on_request( + wire_table::PERMISSIONS_REQUEST_REMOTE_PERMISSION, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::permissions::RemotePermissionRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::permissions::RemotePermissionError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::permissions::RemotePermissionResponse = + match host.request_remote_permission(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } +} + +fn register_preimage

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Preimage + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_subscription( + wire_table::PREIMAGE_LOOKUP_SUBSCRIBE, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::preimage::RemotePreimageLookupSubscribeRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(_) => return Err(Vec::new()), + }; + let cx = CallContext::with_request_id(request_id.clone()); + let stream = host.lookup_subscribe(&cx, request).await; + Ok(subscription_stream::< + versioned::preimage::RemotePreimageLookupSubscribeItem, + _, + >(stream)) + }) + }, + ); + } + { + let host = host; + dispatcher.on_request( + wire_table::PREIMAGE_SUBMIT, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::preimage::RemotePreimageSubmitRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::preimage::RemotePreimageSubmitError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::preimage::RemotePreimageSubmitResponse = + match host.submit(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } +} + +fn register_resource_allocation

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: ResourceAllocation + Send + Sync + 'static, +{ + { + let host = host; + dispatcher.on_request(wire_table::RESOURCE_ALLOCATION_REQUEST, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::resource_allocation::HostRequestResourceAllocationRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::resource_allocation::HostRequestResourceAllocationResponse = match host.request(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } +} + +fn register_signing

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Signing + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_request( + wire_table::SIGNING_CREATE_TRANSACTION, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::signing::HostCreateTransactionRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::signing::HostCreateTransactionError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::signing::HostCreateTransactionResponse = + match host.create_transaction(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::SIGNING_CREATE_TRANSACTION_WITH_LEGACY_ACCOUNT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::signing::HostCreateTransactionWithLegacyAccountRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::signing::HostCreateTransactionWithLegacyAccountResponse = match host.create_transaction_with_legacy_account(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::SIGNING_SIGN_RAW_WITH_LEGACY_ACCOUNT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::signing::HostSignRawWithLegacyAccountRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::signing::HostSignRawWithLegacyAccountResponse = match host.sign_raw_with_legacy_account(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::SIGNING_SIGN_PAYLOAD_WITH_LEGACY_ACCOUNT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::signing::HostSignPayloadWithLegacyAccountRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::signing::HostSignPayloadWithLegacyAccountResponse = match host.sign_payload_with_legacy_account(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::SIGNING_SIGN_RAW, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::signing::HostSignRawRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::signing::HostSignRawResponse = + match host.sign_raw(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host; + dispatcher.on_request( + wire_table::SIGNING_SIGN_PAYLOAD, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::signing::HostSignPayloadRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::signing::HostSignPayloadError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::signing::HostSignPayloadResponse = + match host.sign_payload(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } +} + +fn register_statement_store

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: StatementStore + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_subscription(wire_table::STATEMENT_STORE_SUBSCRIBE, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::statement_store::RemoteStatementStoreSubscribeRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Err(encode_versioned_interrupt_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let stream = match host.subscribe(&cx, request).await { + Ok(sub) => sub, + Err(err) => { + return Err(encode_versioned_interrupt_payload(err, target_version)); + } + }; + Ok(subscription_stream::(stream)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::STATEMENT_STORE_CREATE_PROOF, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::statement_store::RemoteStatementStoreCreateProofRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::statement_store::RemoteStatementStoreCreateProofResponse = match host.create_proof(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::STATEMENT_STORE_CREATE_PROOF_AUTHORIZED, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedResponse = match host.create_proof_authorized(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host; + dispatcher.on_request(wire_table::STATEMENT_STORE_SUBMIT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::statement_store::RemoteStatementStoreSubmitRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + match host.submit(&cx, request).await { + Ok(()) => Ok(encode_versioned_unit_ok_payload(target_version)), + Err(err) => { + Ok(encode_versioned_err_payload(err, target_version)) + } + } + }) + }); + } +} + +fn register_system

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: System + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_request( + wire_table::SYSTEM_HANDSHAKE, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::system::HostHandshakeRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::system::HostHandshakeError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::system::HostHandshakeResponse = + match host.handshake(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::SYSTEM_FEATURE_SUPPORTED, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::system::HostFeatureSupportedRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::system::HostFeatureSupportedError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::system::HostFeatureSupportedResponse = + match host.feature_supported(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host; + dispatcher.on_request( + wire_table::SYSTEM_NAVIGATE_TO, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::system::HostNavigateToRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::system::HostNavigateToError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::system::HostNavigateToResponse = + match host.navigate_to(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } +} + +#[cfg(debug_assertions)] +fn register_testing

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Testing + Send + Sync + 'static, +{ + { + let host = host.clone(); + dispatcher.on_request( + wire_table::TESTING_VERSION_PROBE, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::testing::TestingVersionProbeRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::testing::TestingVersionProbeError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::testing::TestingVersionProbeResponse = + match host.version_probe(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } + { + let host = host; + dispatcher.on_request( + wire_table::TESTING_ECHO_ERROR, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: truapi::v01::EchoErrorRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + truapi::v01::TestingVersionProbeError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_raw_err_payload(error)); + } + }; + let cx = CallContext::with_request_id(request_id.clone()); + match host.echo_error(&cx, request).await { + Ok(()) => Ok(encode_raw_unit_ok_payload()), + Err(err) => Ok(encode_raw_err_payload(err)), + } + }) + }, + ); + } +} + +fn register_theme

(dispatcher: &mut Dispatcher, host: Arc

) +where + P: Theme + Send + Sync + 'static, +{ + { + let host = host; + dispatcher.on_subscription( + wire_table::THEME_SUBSCRIBE, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let _ = bytes; + let cx = CallContext::with_request_id(request_id.clone()); + let stream = host.subscribe(&cx).await; + Ok(subscription_stream::< + versioned::theme::HostThemeSubscribeItem, + _, + >(stream)) + }) + }, + ); + } +} diff --git a/rust/crates/truapi-server/src/generated/mod.rs b/rust/crates/truapi-server/src/generated/mod.rs new file mode 100644 index 00000000..770a015d --- /dev/null +++ b/rust/crates/truapi-server/src/generated/mod.rs @@ -0,0 +1,4 @@ +//! Generated by truapi-codegen. Do not edit. + +pub mod dispatcher; +pub mod wire_table; diff --git a/rust/crates/truapi-server/src/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs new file mode 100644 index 00000000..1d529c9f --- /dev/null +++ b/rust/crates/truapi-server/src/generated/wire_table.rs @@ -0,0 +1,746 @@ +//! Wire-protocol discriminant table. +//! +//! Auto-generated by truapi-codegen. Do not edit. +//! +//! Each method reserves either two ids (request/response) or four +//! (start/stop/interrupt/receive). The ids for each method are exposed +//! as a named const (`PREIMAGE_SUBMIT`, ...); [`WIRE_TABLE`] and the +//! generated dispatcher both reference those consts so the numbers live +//! in exactly one place. The table is sorted by request/start id. + +/// Request method wire discriminants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RequestFrameIds { + /// Discriminant for the request frame. + pub request_id: u8, + /// Discriminant for the response frame. + pub response_id: u8, +} + +/// Subscription method wire discriminants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SubscriptionFrameIds { + /// Discriminant for the start frame. + pub start_id: u8, + /// Discriminant for the stop frame. + pub stop_id: u8, + /// Discriminant for the interrupt frame (server-initiated termination). + pub interrupt_id: u8, + /// Discriminant for each receive frame (a streamed item). + pub receive_id: u8, +} + +/// A single wire-table row. +pub struct WireEntry { + /// Method name from the Rust trait. + pub method: &'static str, + /// What kind of slot this entry describes. + pub kind: WireKind, +} + +/// Wire-slot shape: request/response pair or subscription quartet. +pub enum WireKind { + /// Request/response method. + Request(RequestFrameIds), + /// Subscription method. + Subscription(SubscriptionFrameIds), +} + +/// Wire discriminants for `system_handshake`. +pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { + request_id: 0, + response_id: 1, +}; + +/// Wire discriminants for `system_feature_supported`. +pub const SYSTEM_FEATURE_SUPPORTED: RequestFrameIds = RequestFrameIds { + request_id: 2, + response_id: 3, +}; + +/// Wire discriminants for `notifications_send_push_notification`. +pub const NOTIFICATIONS_SEND_PUSH_NOTIFICATION: RequestFrameIds = RequestFrameIds { + request_id: 4, + response_id: 5, +}; + +/// Wire discriminants for `system_navigate_to`. +pub const SYSTEM_NAVIGATE_TO: RequestFrameIds = RequestFrameIds { + request_id: 6, + response_id: 7, +}; + +/// Wire discriminants for `permissions_request_device_permission`. +pub const PERMISSIONS_REQUEST_DEVICE_PERMISSION: RequestFrameIds = RequestFrameIds { + request_id: 8, + response_id: 9, +}; + +/// Wire discriminants for `permissions_request_remote_permission`. +pub const PERMISSIONS_REQUEST_REMOTE_PERMISSION: RequestFrameIds = RequestFrameIds { + request_id: 10, + response_id: 11, +}; + +/// Wire discriminants for `local_storage_read`. +pub const LOCAL_STORAGE_READ: RequestFrameIds = RequestFrameIds { + request_id: 12, + response_id: 13, +}; + +/// Wire discriminants for `local_storage_write`. +pub const LOCAL_STORAGE_WRITE: RequestFrameIds = RequestFrameIds { + request_id: 14, + response_id: 15, +}; + +/// Wire discriminants for `local_storage_clear`. +pub const LOCAL_STORAGE_CLEAR: RequestFrameIds = RequestFrameIds { + request_id: 16, + response_id: 17, +}; + +/// Wire discriminants for `account_connection_status_subscribe`. +pub const ACCOUNT_CONNECTION_STATUS_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 18, + stop_id: 19, + interrupt_id: 20, + receive_id: 21, +}; + +/// Wire discriminants for `account_get_account`. +pub const ACCOUNT_GET_ACCOUNT: RequestFrameIds = RequestFrameIds { + request_id: 22, + response_id: 23, +}; + +/// Wire discriminants for `account_get_account_alias`. +pub const ACCOUNT_GET_ACCOUNT_ALIAS: RequestFrameIds = RequestFrameIds { + request_id: 24, + response_id: 25, +}; + +/// Wire discriminants for `account_create_account_proof`. +pub const ACCOUNT_CREATE_ACCOUNT_PROOF: RequestFrameIds = RequestFrameIds { + request_id: 26, + response_id: 27, +}; + +/// Wire discriminants for `account_get_legacy_accounts`. +pub const ACCOUNT_GET_LEGACY_ACCOUNTS: RequestFrameIds = RequestFrameIds { + request_id: 28, + response_id: 29, +}; + +/// Wire discriminants for `signing_create_transaction`. +pub const SIGNING_CREATE_TRANSACTION: RequestFrameIds = RequestFrameIds { + request_id: 30, + response_id: 31, +}; + +/// Wire discriminants for `signing_create_transaction_with_legacy_account`. +pub const SIGNING_CREATE_TRANSACTION_WITH_LEGACY_ACCOUNT: RequestFrameIds = RequestFrameIds { + request_id: 32, + response_id: 33, +}; + +/// Wire discriminants for `signing_sign_raw_with_legacy_account`. +pub const SIGNING_SIGN_RAW_WITH_LEGACY_ACCOUNT: RequestFrameIds = RequestFrameIds { + request_id: 34, + response_id: 35, +}; + +/// Wire discriminants for `signing_sign_payload_with_legacy_account`. +pub const SIGNING_SIGN_PAYLOAD_WITH_LEGACY_ACCOUNT: RequestFrameIds = RequestFrameIds { + request_id: 36, + response_id: 37, +}; + +/// Wire discriminants for `chat_create_room`. +pub const CHAT_CREATE_ROOM: RequestFrameIds = RequestFrameIds { + request_id: 38, + response_id: 39, +}; + +/// Wire discriminants for `chat_register_bot`. +pub const CHAT_REGISTER_BOT: RequestFrameIds = RequestFrameIds { + request_id: 40, + response_id: 41, +}; + +/// Wire discriminants for `chat_list_subscribe`. +pub const CHAT_LIST_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 42, + stop_id: 43, + interrupt_id: 44, + receive_id: 45, +}; + +/// Wire discriminants for `chat_post_message`. +pub const CHAT_POST_MESSAGE: RequestFrameIds = RequestFrameIds { + request_id: 46, + response_id: 47, +}; + +/// Wire discriminants for `chat_action_subscribe`. +pub const CHAT_ACTION_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 48, + stop_id: 49, + interrupt_id: 50, + receive_id: 51, +}; + +/// Wire discriminants for `chat_custom_message_render_subscribe`. +pub const CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 52, + stop_id: 53, + interrupt_id: 54, + receive_id: 55, +}; + +/// Wire discriminants for `statement_store_subscribe`. +pub const STATEMENT_STORE_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 56, + stop_id: 57, + interrupt_id: 58, + receive_id: 59, +}; + +/// Wire discriminants for `statement_store_create_proof`. +pub const STATEMENT_STORE_CREATE_PROOF: RequestFrameIds = RequestFrameIds { + request_id: 60, + response_id: 61, +}; + +/// Wire discriminants for `statement_store_submit`. +pub const STATEMENT_STORE_SUBMIT: RequestFrameIds = RequestFrameIds { + request_id: 62, + response_id: 63, +}; + +/// Wire discriminants for `preimage_lookup_subscribe`. +pub const PREIMAGE_LOOKUP_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 64, + stop_id: 65, + interrupt_id: 66, + receive_id: 67, +}; + +/// Wire discriminants for `preimage_submit`. +pub const PREIMAGE_SUBMIT: RequestFrameIds = RequestFrameIds { + request_id: 68, + response_id: 69, +}; + +/// Wire discriminants for `chain_follow_head_subscribe`. +pub const CHAIN_FOLLOW_HEAD_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 76, + stop_id: 77, + interrupt_id: 78, + receive_id: 79, +}; + +/// Wire discriminants for `chain_get_head_header`. +pub const CHAIN_GET_HEAD_HEADER: RequestFrameIds = RequestFrameIds { + request_id: 80, + response_id: 81, +}; + +/// Wire discriminants for `chain_get_head_body`. +pub const CHAIN_GET_HEAD_BODY: RequestFrameIds = RequestFrameIds { + request_id: 82, + response_id: 83, +}; + +/// Wire discriminants for `chain_get_head_storage`. +pub const CHAIN_GET_HEAD_STORAGE: RequestFrameIds = RequestFrameIds { + request_id: 84, + response_id: 85, +}; + +/// Wire discriminants for `chain_call_head`. +pub const CHAIN_CALL_HEAD: RequestFrameIds = RequestFrameIds { + request_id: 86, + response_id: 87, +}; + +/// Wire discriminants for `chain_unpin_head`. +pub const CHAIN_UNPIN_HEAD: RequestFrameIds = RequestFrameIds { + request_id: 88, + response_id: 89, +}; + +/// Wire discriminants for `chain_continue_head`. +pub const CHAIN_CONTINUE_HEAD: RequestFrameIds = RequestFrameIds { + request_id: 90, + response_id: 91, +}; + +/// Wire discriminants for `chain_stop_head_operation`. +pub const CHAIN_STOP_HEAD_OPERATION: RequestFrameIds = RequestFrameIds { + request_id: 92, + response_id: 93, +}; + +/// Wire discriminants for `chain_get_spec_genesis_hash`. +pub const CHAIN_GET_SPEC_GENESIS_HASH: RequestFrameIds = RequestFrameIds { + request_id: 94, + response_id: 95, +}; + +/// Wire discriminants for `chain_get_spec_chain_name`. +pub const CHAIN_GET_SPEC_CHAIN_NAME: RequestFrameIds = RequestFrameIds { + request_id: 96, + response_id: 97, +}; + +/// Wire discriminants for `chain_get_spec_properties`. +pub const CHAIN_GET_SPEC_PROPERTIES: RequestFrameIds = RequestFrameIds { + request_id: 98, + response_id: 99, +}; + +/// Wire discriminants for `chain_broadcast_transaction`. +pub const CHAIN_BROADCAST_TRANSACTION: RequestFrameIds = RequestFrameIds { + request_id: 100, + response_id: 101, +}; + +/// Wire discriminants for `chain_stop_transaction`. +pub const CHAIN_STOP_TRANSACTION: RequestFrameIds = RequestFrameIds { + request_id: 102, + response_id: 103, +}; + +/// Wire discriminants for `theme_subscribe`. +pub const THEME_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 104, + stop_id: 105, + interrupt_id: 106, + receive_id: 107, +}; + +/// Wire discriminants for `entropy_derive`. +pub const ENTROPY_DERIVE: RequestFrameIds = RequestFrameIds { + request_id: 108, + response_id: 109, +}; + +/// Wire discriminants for `account_get_user_id`. +pub const ACCOUNT_GET_USER_ID: RequestFrameIds = RequestFrameIds { + request_id: 110, + response_id: 111, +}; + +/// Wire discriminants for `account_request_login`. +pub const ACCOUNT_REQUEST_LOGIN: RequestFrameIds = RequestFrameIds { + request_id: 112, + response_id: 113, +}; + +/// Wire discriminants for `signing_sign_raw`. +pub const SIGNING_SIGN_RAW: RequestFrameIds = RequestFrameIds { + request_id: 114, + response_id: 115, +}; + +/// Wire discriminants for `signing_sign_payload`. +pub const SIGNING_SIGN_PAYLOAD: RequestFrameIds = RequestFrameIds { + request_id: 116, + response_id: 117, +}; + +/// Wire discriminants for `payment_balance_subscribe`. +pub const PAYMENT_BALANCE_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 118, + stop_id: 119, + interrupt_id: 120, + receive_id: 121, +}; + +/// Wire discriminants for `payment_top_up`. +pub const PAYMENT_TOP_UP: RequestFrameIds = RequestFrameIds { + request_id: 122, + response_id: 123, +}; + +/// Wire discriminants for `payment_request`. +pub const PAYMENT_REQUEST: RequestFrameIds = RequestFrameIds { + request_id: 124, + response_id: 125, +}; + +/// Wire discriminants for `payment_status_subscribe`. +pub const PAYMENT_STATUS_SUBSCRIBE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 126, + stop_id: 127, + interrupt_id: 128, + receive_id: 129, +}; + +/// Wire discriminants for `resource_allocation_request`. +pub const RESOURCE_ALLOCATION_REQUEST: RequestFrameIds = RequestFrameIds { + request_id: 130, + response_id: 131, +}; + +/// Wire discriminants for `statement_store_create_proof_authorized`. +pub const STATEMENT_STORE_CREATE_PROOF_AUTHORIZED: RequestFrameIds = RequestFrameIds { + request_id: 132, + response_id: 133, +}; + +/// Wire discriminants for `notifications_cancel_push_notification`. +pub const NOTIFICATIONS_CANCEL_PUSH_NOTIFICATION: RequestFrameIds = RequestFrameIds { + request_id: 134, + response_id: 135, +}; + +/// Wire discriminants for `coin_payment_create_purse`. +pub const COIN_PAYMENT_CREATE_PURSE: RequestFrameIds = RequestFrameIds { + request_id: 136, + response_id: 137, +}; + +/// Wire discriminants for `coin_payment_query_purse`. +pub const COIN_PAYMENT_QUERY_PURSE: RequestFrameIds = RequestFrameIds { + request_id: 138, + response_id: 139, +}; + +/// Wire discriminants for `coin_payment_rebalance_purse`. +pub const COIN_PAYMENT_REBALANCE_PURSE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 140, + stop_id: 141, + interrupt_id: 142, + receive_id: 143, +}; + +/// Wire discriminants for `coin_payment_delete_purse`. +pub const COIN_PAYMENT_DELETE_PURSE: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 144, + stop_id: 145, + interrupt_id: 146, + receive_id: 147, +}; + +/// Wire discriminants for `coin_payment_create_receivable`. +pub const COIN_PAYMENT_CREATE_RECEIVABLE: RequestFrameIds = RequestFrameIds { + request_id: 148, + response_id: 149, +}; + +/// Wire discriminants for `coin_payment_create_cheque`. +pub const COIN_PAYMENT_CREATE_CHEQUE: RequestFrameIds = RequestFrameIds { + request_id: 150, + response_id: 151, +}; + +/// Wire discriminants for `coin_payment_deposit`. +pub const COIN_PAYMENT_DEPOSIT: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 152, + stop_id: 153, + interrupt_id: 154, + receive_id: 155, +}; + +/// Wire discriminants for `coin_payment_refund`. +pub const COIN_PAYMENT_REFUND: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 156, + stop_id: 157, + interrupt_id: 158, + receive_id: 159, +}; + +/// Wire discriminants for `coin_payment_listen_for_payment`. +pub const COIN_PAYMENT_LISTEN_FOR_PAYMENT: SubscriptionFrameIds = SubscriptionFrameIds { + start_id: 160, + stop_id: 161, + interrupt_id: 162, + receive_id: 163, +}; + +#[cfg(debug_assertions)] +/// Wire discriminants for `testing_version_probe`. +pub const TESTING_VERSION_PROBE: RequestFrameIds = RequestFrameIds { + request_id: 164, + response_id: 165, +}; + +#[cfg(debug_assertions)] +/// Wire discriminants for `testing_echo_error`. +pub const TESTING_ECHO_ERROR: RequestFrameIds = RequestFrameIds { + request_id: 166, + response_id: 167, +}; + +/// The full wire table. Ordering is part of the wire protocol; +/// only ever append. Removed methods leave their slot empty. +pub const WIRE_TABLE: &[WireEntry] = &[ + WireEntry { + method: "system_handshake", + kind: WireKind::Request(SYSTEM_HANDSHAKE), + }, + WireEntry { + method: "system_feature_supported", + kind: WireKind::Request(SYSTEM_FEATURE_SUPPORTED), + }, + WireEntry { + method: "notifications_send_push_notification", + kind: WireKind::Request(NOTIFICATIONS_SEND_PUSH_NOTIFICATION), + }, + WireEntry { + method: "system_navigate_to", + kind: WireKind::Request(SYSTEM_NAVIGATE_TO), + }, + WireEntry { + method: "permissions_request_device_permission", + kind: WireKind::Request(PERMISSIONS_REQUEST_DEVICE_PERMISSION), + }, + WireEntry { + method: "permissions_request_remote_permission", + kind: WireKind::Request(PERMISSIONS_REQUEST_REMOTE_PERMISSION), + }, + WireEntry { + method: "local_storage_read", + kind: WireKind::Request(LOCAL_STORAGE_READ), + }, + WireEntry { + method: "local_storage_write", + kind: WireKind::Request(LOCAL_STORAGE_WRITE), + }, + WireEntry { + method: "local_storage_clear", + kind: WireKind::Request(LOCAL_STORAGE_CLEAR), + }, + WireEntry { + method: "account_connection_status_subscribe", + kind: WireKind::Subscription(ACCOUNT_CONNECTION_STATUS_SUBSCRIBE), + }, + WireEntry { + method: "account_get_account", + kind: WireKind::Request(ACCOUNT_GET_ACCOUNT), + }, + WireEntry { + method: "account_get_account_alias", + kind: WireKind::Request(ACCOUNT_GET_ACCOUNT_ALIAS), + }, + WireEntry { + method: "account_create_account_proof", + kind: WireKind::Request(ACCOUNT_CREATE_ACCOUNT_PROOF), + }, + WireEntry { + method: "account_get_legacy_accounts", + kind: WireKind::Request(ACCOUNT_GET_LEGACY_ACCOUNTS), + }, + WireEntry { + method: "signing_create_transaction", + kind: WireKind::Request(SIGNING_CREATE_TRANSACTION), + }, + WireEntry { + method: "signing_create_transaction_with_legacy_account", + kind: WireKind::Request(SIGNING_CREATE_TRANSACTION_WITH_LEGACY_ACCOUNT), + }, + WireEntry { + method: "signing_sign_raw_with_legacy_account", + kind: WireKind::Request(SIGNING_SIGN_RAW_WITH_LEGACY_ACCOUNT), + }, + WireEntry { + method: "signing_sign_payload_with_legacy_account", + kind: WireKind::Request(SIGNING_SIGN_PAYLOAD_WITH_LEGACY_ACCOUNT), + }, + WireEntry { + method: "chat_create_room", + kind: WireKind::Request(CHAT_CREATE_ROOM), + }, + WireEntry { + method: "chat_register_bot", + kind: WireKind::Request(CHAT_REGISTER_BOT), + }, + WireEntry { + method: "chat_list_subscribe", + kind: WireKind::Subscription(CHAT_LIST_SUBSCRIBE), + }, + WireEntry { + method: "chat_post_message", + kind: WireKind::Request(CHAT_POST_MESSAGE), + }, + WireEntry { + method: "chat_action_subscribe", + kind: WireKind::Subscription(CHAT_ACTION_SUBSCRIBE), + }, + WireEntry { + method: "chat_custom_message_render_subscribe", + kind: WireKind::Subscription(CHAT_CUSTOM_MESSAGE_RENDER_SUBSCRIBE), + }, + WireEntry { + method: "statement_store_subscribe", + kind: WireKind::Subscription(STATEMENT_STORE_SUBSCRIBE), + }, + WireEntry { + method: "statement_store_create_proof", + kind: WireKind::Request(STATEMENT_STORE_CREATE_PROOF), + }, + WireEntry { + method: "statement_store_submit", + kind: WireKind::Request(STATEMENT_STORE_SUBMIT), + }, + WireEntry { + method: "preimage_lookup_subscribe", + kind: WireKind::Subscription(PREIMAGE_LOOKUP_SUBSCRIBE), + }, + WireEntry { + method: "preimage_submit", + kind: WireKind::Request(PREIMAGE_SUBMIT), + }, + WireEntry { + method: "chain_follow_head_subscribe", + kind: WireKind::Subscription(CHAIN_FOLLOW_HEAD_SUBSCRIBE), + }, + WireEntry { + method: "chain_get_head_header", + kind: WireKind::Request(CHAIN_GET_HEAD_HEADER), + }, + WireEntry { + method: "chain_get_head_body", + kind: WireKind::Request(CHAIN_GET_HEAD_BODY), + }, + WireEntry { + method: "chain_get_head_storage", + kind: WireKind::Request(CHAIN_GET_HEAD_STORAGE), + }, + WireEntry { + method: "chain_call_head", + kind: WireKind::Request(CHAIN_CALL_HEAD), + }, + WireEntry { + method: "chain_unpin_head", + kind: WireKind::Request(CHAIN_UNPIN_HEAD), + }, + WireEntry { + method: "chain_continue_head", + kind: WireKind::Request(CHAIN_CONTINUE_HEAD), + }, + WireEntry { + method: "chain_stop_head_operation", + kind: WireKind::Request(CHAIN_STOP_HEAD_OPERATION), + }, + WireEntry { + method: "chain_get_spec_genesis_hash", + kind: WireKind::Request(CHAIN_GET_SPEC_GENESIS_HASH), + }, + WireEntry { + method: "chain_get_spec_chain_name", + kind: WireKind::Request(CHAIN_GET_SPEC_CHAIN_NAME), + }, + WireEntry { + method: "chain_get_spec_properties", + kind: WireKind::Request(CHAIN_GET_SPEC_PROPERTIES), + }, + WireEntry { + method: "chain_broadcast_transaction", + kind: WireKind::Request(CHAIN_BROADCAST_TRANSACTION), + }, + WireEntry { + method: "chain_stop_transaction", + kind: WireKind::Request(CHAIN_STOP_TRANSACTION), + }, + WireEntry { + method: "theme_subscribe", + kind: WireKind::Subscription(THEME_SUBSCRIBE), + }, + WireEntry { + method: "entropy_derive", + kind: WireKind::Request(ENTROPY_DERIVE), + }, + WireEntry { + method: "account_get_user_id", + kind: WireKind::Request(ACCOUNT_GET_USER_ID), + }, + WireEntry { + method: "account_request_login", + kind: WireKind::Request(ACCOUNT_REQUEST_LOGIN), + }, + WireEntry { + method: "signing_sign_raw", + kind: WireKind::Request(SIGNING_SIGN_RAW), + }, + WireEntry { + method: "signing_sign_payload", + kind: WireKind::Request(SIGNING_SIGN_PAYLOAD), + }, + WireEntry { + method: "payment_balance_subscribe", + kind: WireKind::Subscription(PAYMENT_BALANCE_SUBSCRIBE), + }, + WireEntry { + method: "payment_top_up", + kind: WireKind::Request(PAYMENT_TOP_UP), + }, + WireEntry { + method: "payment_request", + kind: WireKind::Request(PAYMENT_REQUEST), + }, + WireEntry { + method: "payment_status_subscribe", + kind: WireKind::Subscription(PAYMENT_STATUS_SUBSCRIBE), + }, + WireEntry { + method: "resource_allocation_request", + kind: WireKind::Request(RESOURCE_ALLOCATION_REQUEST), + }, + WireEntry { + method: "statement_store_create_proof_authorized", + kind: WireKind::Request(STATEMENT_STORE_CREATE_PROOF_AUTHORIZED), + }, + WireEntry { + method: "notifications_cancel_push_notification", + kind: WireKind::Request(NOTIFICATIONS_CANCEL_PUSH_NOTIFICATION), + }, + WireEntry { + method: "coin_payment_create_purse", + kind: WireKind::Request(COIN_PAYMENT_CREATE_PURSE), + }, + WireEntry { + method: "coin_payment_query_purse", + kind: WireKind::Request(COIN_PAYMENT_QUERY_PURSE), + }, + WireEntry { + method: "coin_payment_rebalance_purse", + kind: WireKind::Subscription(COIN_PAYMENT_REBALANCE_PURSE), + }, + WireEntry { + method: "coin_payment_delete_purse", + kind: WireKind::Subscription(COIN_PAYMENT_DELETE_PURSE), + }, + WireEntry { + method: "coin_payment_create_receivable", + kind: WireKind::Request(COIN_PAYMENT_CREATE_RECEIVABLE), + }, + WireEntry { + method: "coin_payment_create_cheque", + kind: WireKind::Request(COIN_PAYMENT_CREATE_CHEQUE), + }, + WireEntry { + method: "coin_payment_deposit", + kind: WireKind::Subscription(COIN_PAYMENT_DEPOSIT), + }, + WireEntry { + method: "coin_payment_refund", + kind: WireKind::Subscription(COIN_PAYMENT_REFUND), + }, + WireEntry { + method: "coin_payment_listen_for_payment", + kind: WireKind::Subscription(COIN_PAYMENT_LISTEN_FOR_PAYMENT), + }, + #[cfg(debug_assertions)] + WireEntry { + method: "testing_version_probe", + kind: WireKind::Request(TESTING_VERSION_PROBE), + }, + #[cfg(debug_assertions)] + WireEntry { + method: "testing_echo_error", + kind: WireKind::Request(TESTING_ECHO_ERROR), + }, +]; diff --git a/scripts/codegen.sh b/scripts/codegen.sh index 98d7006b..86c17f78 100755 --- a/scripts/codegen.sh +++ b/scripts/codegen.sh @@ -7,6 +7,10 @@ # --output js/packages/truapi/src/generated # --playground-output js/packages/truapi/src/playground # --client-examples-output playground/test/generated/examples +# --rust-output rust/crates/truapi-server/src/generated +# --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 # --codec-version 1 # # The client surface defaults to the latest wire version any versioned @@ -20,11 +24,16 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" cargo +nightly rustdoc -p truapi -- -Z unstable-options --output-format json +cargo +nightly rustdoc -p truapi-platform -- -Z unstable-options --output-format json cargo run -p truapi-codegen -- \ --input target/doc/truapi.json \ --output js/packages/truapi/src/generated \ --playground-output js/packages/truapi/src/playground \ --client-examples-output playground/test/generated/examples \ + --rust-output rust/crates/truapi-server/src/generated \ + --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 \ --explorer-output js/packages/truapi/src/explorer \ --codec-version 1 @@ -34,7 +43,8 @@ npm exec --yes -- prettier --write \ "js/packages/truapi/src/generated/**/*.ts" \ "js/packages/truapi/src/playground/**/*.ts" \ "js/packages/truapi/src/explorer/**/*.ts" \ - "playground/test/generated/examples/**/*.ts" + "playground/test/generated/examples/**/*.ts" \ + "js/packages/truapi-host-wasm/src/generated/**/*.ts" # Rebuild dist/ so downstream consumers (in particular the playground, # which picks up @parity/truapi via yarn 1.x file: snapshot) see the @@ -58,3 +68,5 @@ fi echo "Generated client at js/packages/truapi/src/generated/" 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/" From a37abae339e592ac311c25113597fd765c03eec3 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 2 Jul 2026 10:11:25 +0200 Subject: [PATCH 09/56] fixup! feat(truapi-codegen): emit Rust dispatcher, wire table, and host callbacks --- js/packages/truapi/src/client.test.ts | 59 -------------- .../truapi-codegen/src/rust/dispatcher.rs | 67 +++++++++++----- .../truapi-codegen/src/rust/wire_table.rs | 19 ++--- .../truapi-codegen/tests/golden/dispatcher.rs | 62 --------------- .../truapi-codegen/tests/golden/wire_table.rs | 24 ------ .../truapi-server/src/generated/dispatcher.rs | 77 ------------------- .../truapi-server/src/generated/wire_table.rs | 24 ------ 7 files changed, 53 insertions(+), 279 deletions(-) diff --git a/js/packages/truapi/src/client.test.ts b/js/packages/truapi/src/client.test.ts index 5e1db02d..5d971886 100644 --- a/js/packages/truapi/src/client.test.ts +++ b/js/packages/truapi/src/client.test.ts @@ -82,14 +82,6 @@ function accountGetResponsePayload( ).enc({ tag: "V1", value }); } -/** Encode a raw testing echo error response payload. */ -function testingEchoErrorPayload(reason: string): Uint8Array { - return ScaleResult(_void, CallError(T.V01TestingVersionProbeError)).enc({ - success: false, - value: { tag: "HostFailure", value: { reason } }, - }); -} - describe("generated client transport", () => { it("encodes unit-only enums as a single-byte SCALE discriminant", () => { // Unit-only enums expose a string union on the public API while @@ -117,29 +109,6 @@ describe("generated client transport", () => { expect(toHex(fixture.sent[0])).toBe(toHex(expectedFrame)); }); - it("uses the latest generated request version for testing probes", () => { - const fixture = providerFixture(); - const transport = createTransport(fixture.provider); - const client = createClient(transport); - - const request = { - message: "hello from test", - marker: 42, - }; - void client.testing.versionProbe(request); - - const expectedPayload = T.VersionedTestingVersionProbeRequest.enc({ - tag: "V2", - value: request, - }); - const expectedFrame = new Uint8Array(str.enc("p:1").length + 1 + expectedPayload.length); - expectedFrame.set(str.enc("p:1"), 0); - expectedFrame[str.enc("p:1").length] = W.TESTING_VERSION_PROBE.request; - expectedFrame.set(expectedPayload, str.enc("p:1").length + 1); - - expect(toHex(fixture.sent[0])).toBe(toHex(expectedFrame)); - }); - it("uses the transport codec version for generated handshake calls", () => { const fixture = providerFixture(); const transport = createTransport(fixture.provider); @@ -210,34 +179,6 @@ describe("generated client transport", () => { expect(result._unsafeUnwrapErr()).toEqual({ tag: "Domain", value: reason }); }); - it("returns framework call errors as typed Err values", async () => { - const fixture = providerFixture(); - const transport = createTransport(fixture.provider); - const client = createClient(transport); - - const response = client.testing.echoError({ - error: { tag: "HostFailure", value: { reason: "forced by test" } }, - }); - const frame = unwrap( - encodeWireMessage({ - requestId: "p:1", - payload: { - id: W.TESTING_ECHO_ERROR.response, - value: testingEchoErrorPayload("forced by test"), - }, - }), - "encode testing framework error response", - ); - fixture.receive(frame); - - const result = await response; - expect(result.isErr()).toBe(true); - expect(result._unsafeUnwrapErr()).toEqual({ - tag: "HostFailure", - value: { reason: "forced by test" }, - }); - }); - it("auto-responds to an inbound handshake with the versioned-result shape", () => { const fixture = providerFixture(); createTransport(fixture.provider); diff --git a/rust/crates/truapi-codegen/src/rust/dispatcher.rs b/rust/crates/truapi-codegen/src/rust/dispatcher.rs index 5155e99c..15486b1d 100644 --- a/rust/crates/truapi-codegen/src/rust/dispatcher.rs +++ b/rust/crates/truapi-codegen/src/rust/dispatcher.rs @@ -45,13 +45,23 @@ pub fn generate_dispatcher(api: &ApiDefinition) -> Result { } let mut modules = Vec::with_capacity(traits.len()); + let mut uses_raw_err_payload = false; + let mut uses_raw_unit_ok_payload = false; for trait_def in &traits { - modules.push(build_module(api, trait_def)?); + let module = build_module(api, trait_def)?; + uses_raw_err_payload |= module.uses_raw_err_payload; + uses_raw_unit_ok_payload |= module.uses_raw_unit_ok_payload; + modules.push(module.code); } let mut out = String::new(); write_header(&mut out); - write_imports(&mut out, &traits); + write_imports( + &mut out, + &traits, + uses_raw_err_payload, + uses_raw_unit_ok_payload, + ); writeln!(out).unwrap(); write_top_register(&mut out, &traits); @@ -84,8 +94,14 @@ fn order_traits(api: &ApiDefinition) -> Result> { Ok(ordered) } +struct ModuleEmission { + code: String, + uses_raw_err_payload: bool, + uses_raw_unit_ok_payload: bool, +} + /// Emit the `register_{module}` function for a single trait. -fn build_module(api: &ApiDefinition, trait_def: &TraitDef) -> Result { +fn build_module(api: &ApiDefinition, trait_def: &TraitDef) -> Result { let module = module_for_trait(&trait_def.name); let mut methods = Vec::with_capacity(trait_def.methods.len()); @@ -93,13 +109,12 @@ fn build_module(api: &ApiDefinition, trait_def: &TraitDef) -> Result { let wire_method = wire_method_name(&trait_def.name, &method.name); methods.push(MethodEmission::build(api, &module, &wire_method, method)?); } + let uses_raw_err_payload = methods.iter().any(MethodEmission::uses_raw_err_payload); + let uses_raw_unit_ok_payload = methods.iter().any(MethodEmission::uses_raw_unit_ok_payload); let fn_name = format!("register_{module}"); let trait_name = &trait_def.name; let mut code = String::new(); - if trait_name == "Testing" { - writeln!(code, "#[cfg(debug_assertions)]").unwrap(); - } writedoc!( code, r#" @@ -117,7 +132,11 @@ fn build_module(api: &ApiDefinition, trait_def: &TraitDef) -> Result { } writeln!(code, "}}").unwrap(); - Ok(code) + Ok(ModuleEmission { + code, + uses_raw_err_payload, + uses_raw_unit_ok_payload, + }) } struct MethodEmission { @@ -233,6 +252,16 @@ impl MethodEmission { } } + fn uses_raw_err_payload(&self) -> bool { + matches!(self.request_payload, Some(WirePayload::Raw(_))) || self.uses_raw_unit_ok_payload() + } + + fn uses_raw_unit_ok_payload(&self) -> bool { + matches!(self.kind, MethodKind::Request) + && self.response_wrapper.is_none() + && matches!(self.error_payload, WirePayload::Raw(_)) + } + fn write_request(&self, out: &mut String, host_expr: &str) { let module = &self.module; let method = &self.name; @@ -671,8 +700,12 @@ fn write_header(out: &mut String) { .unwrap(); } -fn write_imports(out: &mut String, traits: &[&TraitDef]) { - let has_testing = traits.iter().any(|trait_def| trait_def.name == "Testing"); +fn write_imports( + out: &mut String, + traits: &[&TraitDef], + uses_raw_err_payload: bool, + uses_raw_unit_ok_payload: bool, +) { writedoc!( out, r#" @@ -686,9 +719,6 @@ fn write_imports(out: &mut String, traits: &[&TraitDef]) { ) .unwrap(); for trait_def in traits { - if trait_def.name == "Testing" { - continue; - } writeln!(out, " {},", trait_def.name).unwrap(); } writedoc!( @@ -698,8 +728,6 @@ fn write_imports(out: &mut String, traits: &[&TraitDef]) { use truapi::versioned::{{self, Versioned}}; use crate::dispatcher::Dispatcher; - use crate::frame::encode_raw_err_payload; - use crate::frame::encode_raw_unit_ok_payload; use crate::frame::encode_versioned_err_payload; use crate::frame::encode_versioned_interrupt_payload; use crate::frame::encode_versioned_ok_payload; @@ -709,9 +737,11 @@ fn write_imports(out: &mut String, traits: &[&TraitDef]) { "# ) .unwrap(); - if has_testing { - writeln!(out, "#[cfg(debug_assertions)]").unwrap(); - writeln!(out, "use truapi::api::Testing;").unwrap(); + if uses_raw_err_payload { + writeln!(out, "use crate::frame::encode_raw_err_payload;").unwrap(); + } + if uses_raw_unit_ok_payload { + writeln!(out, "use crate::frame::encode_raw_unit_ok_payload;").unwrap(); } } @@ -731,9 +761,6 @@ fn write_top_register(out: &mut String, traits: &[&TraitDef]) { for (idx, trait_def) in traits.iter().enumerate() { let host_expr = if idx == last { "host" } else { "host.clone()" }; let module = module_for_trait(&trait_def.name); - if trait_def.name == "Testing" { - writeln!(out, " #[cfg(debug_assertions)]").unwrap(); - } writeln!(out, " register_{module}(dispatcher, {host_expr});").unwrap(); } writeln!(out, "}}").unwrap(); diff --git a/rust/crates/truapi-codegen/src/rust/wire_table.rs b/rust/crates/truapi-codegen/src/rust/wire_table.rs index 6c63a4f8..8696b575 100644 --- a/rust/crates/truapi-codegen/src/rust/wire_table.rs +++ b/rust/crates/truapi-codegen/src/rust/wire_table.rs @@ -40,7 +40,7 @@ enum MethodEntry { /// Emit the contents of `wire_table.rs`. pub fn generate_wire_table(api: &ApiDefinition) -> Result { - let mut method_entries: Vec<(String, MethodEntry, bool)> = Vec::new(); + let mut method_entries: Vec<(String, MethodEntry)> = Vec::new(); let mut seen: BTreeMap = BTreeMap::new(); let mut seen_methods: BTreeMap = BTreeMap::new(); @@ -59,11 +59,11 @@ pub fn generate_wire_table(api: &ApiDefinition) -> Result { ); } insert_entry(&mut seen, &wire_method, entry)?; - method_entries.push((wire_method, entry, trait_def.name == "Testing")); + method_entries.push((wire_method, entry)); } } - method_entries.sort_by_key(|(_, entry, _)| match entry { + method_entries.sort_by_key(|(_, entry)| match entry { MethodEntry::Request(WireEntry { request_id, .. }) => *request_id, MethodEntry::Subscription(SubEntry { start_id, .. }) => *start_id, }); @@ -169,7 +169,7 @@ fn insert_entry( Ok(()) } -fn render(methods: &[(String, MethodEntry, bool)]) -> Result { +fn render(methods: &[(String, MethodEntry)]) -> Result { let mut out = String::new(); writedoc!( out, @@ -226,12 +226,8 @@ fn render(methods: &[(String, MethodEntry, bool)]) -> Result { .unwrap(); // Per-method consts: the single source of truth for each method's ids. - for (name, entry, debug_only) in methods { + for (name, entry) in methods { let konst = const_name(name); - if *debug_only { - out.push('\n'); - out.push_str("#[cfg(debug_assertions)]"); - } let block = match entry { MethodEntry::Request(WireEntry { request_id, @@ -276,7 +272,7 @@ fn render(methods: &[(String, MethodEntry, bool)]) -> Result { "# ) .unwrap(); - for (name, entry, debug_only) in methods { + for (name, entry) in methods { let konst = const_name(name); let variant = match entry { MethodEntry::Request(_) => "Request", @@ -290,9 +286,6 @@ fn render(methods: &[(String, MethodEntry, bool)]) -> Result { }}, "# }; - if *debug_only { - writeln!(out, " #[cfg(debug_assertions)]").unwrap(); - } for line in block.lines() { writeln!(out, " {line}").unwrap(); } diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs index bc2eef63..25f2fb13 100644 --- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs +++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs @@ -27,16 +27,12 @@ use truapi::api::{ use truapi::versioned::{self, Versioned}; use crate::dispatcher::Dispatcher; -use crate::frame::encode_raw_err_payload; -use crate::frame::encode_raw_unit_ok_payload; use crate::frame::encode_versioned_err_payload; use crate::frame::encode_versioned_interrupt_payload; use crate::frame::encode_versioned_ok_payload; use crate::frame::encode_versioned_unit_ok_payload; use crate::generated::wire_table; use crate::subscription::subscription_stream; -#[cfg(debug_assertions)] -use truapi::api::Testing; /// Register every TrUAPI method with the dispatcher. pub fn register

(dispatcher: &mut Dispatcher, host: Arc

) @@ -57,8 +53,6 @@ where register_signing(dispatcher, host.clone()); register_statement_store(dispatcher, host.clone()); register_system(dispatcher, host.clone()); - #[cfg(debug_assertions)] - register_testing(dispatcher, host.clone()); register_theme(dispatcher, host); } @@ -1838,62 +1832,6 @@ where } } -#[cfg(debug_assertions)] -fn register_testing

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Testing + Send + Sync + 'static, -{ - { - let host = host.clone(); - dispatcher.on_request(wire_table::TESTING_VERSION_PROBE, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::testing::TestingVersionProbeRequest = match Decode::decode(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Ok(encode_versioned_err_payload( - error, - ::LATEST, - )); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id.clone()); - let response: versioned::testing::TestingVersionProbeResponse = match host.version_probe(&cx, request).await { - Ok(value) => value, - Err(err) => { - return Ok(encode_versioned_err_payload(err, target_version)); - } - }; - Ok(encode_versioned_ok_payload(response)) - }) - }); - } - { - let host = host; - dispatcher.on_request(wire_table::TESTING_ECHO_ERROR, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: truapi::v01::EchoErrorRequest = match Decode::decode(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Ok(encode_raw_err_payload(error)); - } - }; - let cx = CallContext::with_request_id(request_id.clone()); - match host.echo_error(&cx, request).await { - Ok(()) => Ok(encode_raw_unit_ok_payload()), - Err(err) => Ok(encode_raw_err_payload(err)), - } - }) - }); - } -} - fn register_theme

(dispatcher: &mut Dispatcher, host: Arc

) where P: Theme + Send + Sync + 'static, diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 1d529c9f..12a72a94 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -460,20 +460,6 @@ pub const COIN_PAYMENT_LISTEN_FOR_PAYMENT: SubscriptionFrameIds = SubscriptionFr receive_id: 163, }; -#[cfg(debug_assertions)] -/// Wire discriminants for `testing_version_probe`. -pub const TESTING_VERSION_PROBE: RequestFrameIds = RequestFrameIds { - request_id: 164, - response_id: 165, -}; - -#[cfg(debug_assertions)] -/// Wire discriminants for `testing_echo_error`. -pub const TESTING_ECHO_ERROR: RequestFrameIds = RequestFrameIds { - request_id: 166, - response_id: 167, -}; - /// The full wire table. Ordering is part of the wire protocol; /// only ever append. Removed methods leave their slot empty. pub const WIRE_TABLE: &[WireEntry] = &[ @@ -733,14 +719,4 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "coin_payment_listen_for_payment", kind: WireKind::Subscription(COIN_PAYMENT_LISTEN_FOR_PAYMENT), }, - #[cfg(debug_assertions)] - WireEntry { - method: "testing_version_probe", - kind: WireKind::Request(TESTING_VERSION_PROBE), - }, - #[cfg(debug_assertions)] - WireEntry { - method: "testing_echo_error", - kind: WireKind::Request(TESTING_ECHO_ERROR), - }, ]; diff --git a/rust/crates/truapi-server/src/generated/dispatcher.rs b/rust/crates/truapi-server/src/generated/dispatcher.rs index 4a89f611..231dd362 100644 --- a/rust/crates/truapi-server/src/generated/dispatcher.rs +++ b/rust/crates/truapi-server/src/generated/dispatcher.rs @@ -14,16 +14,12 @@ use truapi::api::{ use truapi::versioned::{self, Versioned}; use crate::dispatcher::Dispatcher; -use crate::frame::encode_raw_err_payload; -use crate::frame::encode_raw_unit_ok_payload; use crate::frame::encode_versioned_err_payload; use crate::frame::encode_versioned_interrupt_payload; use crate::frame::encode_versioned_ok_payload; use crate::frame::encode_versioned_unit_ok_payload; use crate::generated::wire_table; use crate::subscription::subscription_stream; -#[cfg(debug_assertions)] -use truapi::api::Testing; /// Register every TrUAPI method with the dispatcher. pub fn register

(dispatcher: &mut Dispatcher, host: Arc

) @@ -44,8 +40,6 @@ where register_signing(dispatcher, host.clone()); register_statement_store(dispatcher, host.clone()); register_system(dispatcher, host.clone()); - #[cfg(debug_assertions)] - register_testing(dispatcher, host.clone()); register_theme(dispatcher, host); } @@ -2086,77 +2080,6 @@ where } } -#[cfg(debug_assertions)] -fn register_testing

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Testing + Send + Sync + 'static, -{ - { - let host = host.clone(); - dispatcher.on_request( - wire_table::TESTING_VERSION_PROBE, - move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::testing::TestingVersionProbeRequest = - match Decode::decode(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError< - versioned::testing::TestingVersionProbeError, - > = truapi::CallError::MalformedFrame { - reason: err.to_string(), - }; - return Ok(encode_versioned_err_payload( - error, - ::LATEST, - )); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id.clone()); - let response: versioned::testing::TestingVersionProbeResponse = - match host.version_probe(&cx, request).await { - Ok(value) => value, - Err(err) => { - return Ok(encode_versioned_err_payload(err, target_version)); - } - }; - Ok(encode_versioned_ok_payload(response)) - }) - }, - ); - } - { - let host = host; - dispatcher.on_request( - wire_table::TESTING_ECHO_ERROR, - move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: truapi::v01::EchoErrorRequest = - match Decode::decode(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError< - truapi::v01::TestingVersionProbeError, - > = truapi::CallError::MalformedFrame { - reason: err.to_string(), - }; - return Ok(encode_raw_err_payload(error)); - } - }; - let cx = CallContext::with_request_id(request_id.clone()); - match host.echo_error(&cx, request).await { - Ok(()) => Ok(encode_raw_unit_ok_payload()), - Err(err) => Ok(encode_raw_err_payload(err)), - } - }) - }, - ); - } -} - fn register_theme

(dispatcher: &mut Dispatcher, host: Arc

) where P: Theme + Send + Sync + 'static, diff --git a/rust/crates/truapi-server/src/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs index 1d529c9f..12a72a94 100644 --- a/rust/crates/truapi-server/src/generated/wire_table.rs +++ b/rust/crates/truapi-server/src/generated/wire_table.rs @@ -460,20 +460,6 @@ pub const COIN_PAYMENT_LISTEN_FOR_PAYMENT: SubscriptionFrameIds = SubscriptionFr receive_id: 163, }; -#[cfg(debug_assertions)] -/// Wire discriminants for `testing_version_probe`. -pub const TESTING_VERSION_PROBE: RequestFrameIds = RequestFrameIds { - request_id: 164, - response_id: 165, -}; - -#[cfg(debug_assertions)] -/// Wire discriminants for `testing_echo_error`. -pub const TESTING_ECHO_ERROR: RequestFrameIds = RequestFrameIds { - request_id: 166, - response_id: 167, -}; - /// The full wire table. Ordering is part of the wire protocol; /// only ever append. Removed methods leave their slot empty. pub const WIRE_TABLE: &[WireEntry] = &[ @@ -733,14 +719,4 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "coin_payment_listen_for_payment", kind: WireKind::Subscription(COIN_PAYMENT_LISTEN_FOR_PAYMENT), }, - #[cfg(debug_assertions)] - WireEntry { - method: "testing_version_probe", - kind: WireKind::Request(TESTING_VERSION_PROBE), - }, - #[cfg(debug_assertions)] - WireEntry { - method: "testing_echo_error", - kind: WireKind::Request(TESTING_ECHO_ERROR), - }, ]; From 99d1b7e42defd5ec1f5ed6d2ea8eb551cce3b308 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 2 Jul 2026 10:51:53 +0200 Subject: [PATCH 10/56] fixup! feat(truapi-codegen): emit Rust dispatcher, wire table, and host callbacks --- rust/crates/truapi-codegen/tests/golden/host-callbacks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 4aab10b0..d111c323 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -488,7 +488,7 @@ export interface UserConfirmation { /** * Confirm a reviewed action before the core asks the SSO peer. */ - confirmUserAction?(review: UserConfirmationReview): Promise; + confirmUserAction(review: UserConfirmationReview): Promise; } /** From 1c463531b568feeef9af1831e8209ab7575ffa7c Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 2 Jul 2026 12:58:24 +0200 Subject: [PATCH 11/56] fixup! feat(truapi-codegen): emit Rust dispatcher, wire table, and host callbacks --- rust/crates/truapi-codegen/src/platform.rs | 119 ++++++++++++++++-- rust/crates/truapi-codegen/src/rust.rs | 103 +++++++++++++++ .../truapi-codegen/src/rust/dispatcher.rs | 48 ++++--- 3 files changed, 241 insertions(+), 29 deletions(-) diff --git a/rust/crates/truapi-codegen/src/platform.rs b/rust/crates/truapi-codegen/src/platform.rs index f37b5e91..0f6a0a3a 100644 --- a/rust/crates/truapi-codegen/src/platform.rs +++ b/rust/crates/truapi-codegen/src/platform.rs @@ -7,7 +7,7 @@ //! module walks the rustdoc index for every public trait in the platform crate //! and produces a [`PlatformDefinition`] the TS emitter can render directly. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use anyhow::{Context, Result, bail}; @@ -175,6 +175,28 @@ fn collect_referenced_local_types( } } + let mut local_type_candidates = BTreeMap::new(); + for (item_id, item_path) in &krate.paths { + if item_path.crate_id != 0 || !matches!(item_path.kind.as_str(), "struct" | "enum") { + continue; + } + let Some(name) = item_path.path.last() else { + continue; + }; + local_type_candidates + .entry(name.clone()) + .or_insert_with(Vec::new) + .push((item_id, item_path)); + } + for candidates in local_type_candidates.values_mut() { + candidates.sort_by(|(left_id, left_path), (right_id, right_path)| { + left_path + .path + .cmp(&right_path.path) + .then_with(|| left_id.cmp(right_id)) + }); + } + // Local types can reference further local types from their fields or // variant payloads (e.g. `AuthState::Connected(SessionUiInfo)`), so keep // extracting until the referenced set stops growing. @@ -182,16 +204,24 @@ fn collect_referenced_local_types( let mut extracted: BTreeSet = BTreeSet::new(); loop { let mut grew = false; - for (item_id, item_path) in &krate.paths { - if item_path.crate_id != 0 || !matches!(item_path.kind.as_str(), "struct" | "enum") { - continue; - } - let Some(name) = item_path.path.last() else { + let pending = referenced + .iter() + .filter(|name| !extracted.contains(*name)) + .cloned() + .collect::>(); + for name in pending { + let Some(candidates) = local_type_candidates.get(&name) else { continue; }; - if !referenced.contains(name) || extracted.contains(name) { - continue; + if candidates.len() > 1 { + let paths = candidates + .iter() + .map(|(_, item_path)| item_path.path.join("::")) + .collect::>() + .join(", "); + bail!("platform type name `{name}` is ambiguous: defined by {paths}"); } + let (item_id, item_path) = candidates[0]; let item = krate.index.get(item_id).with_context(|| { format!( "Missing rustdoc item `{item_id}` for {} `{name}`", @@ -205,7 +235,7 @@ fn collect_referenced_local_types( extract_enum(item_id, item, krate, names, module_path)? }; collect_type_def_references(&type_def, &mut referenced); - extracted.insert(name.clone()); + extracted.insert(name); types.push(type_def); grew = true; } @@ -614,8 +644,12 @@ fn value_to_id(value: &serde_json::Value) -> Result { #[cfg(test)] mod tests { + use std::collections::HashMap; + use serde_json::json; + use crate::rustdoc::ItemPath; + use super::*; #[test] @@ -687,4 +721,71 @@ mod tests { Some(json!({ "primitive": "u8" })) ); } + + #[test] + fn referenced_platform_type_names_must_be_unambiguous() { + let krate = Crate { + format_version: Some(57), + index: HashMap::new(), + paths: HashMap::from([ + ( + "1".to_string(), + ItemPath { + crate_id: 0, + path: vec![ + "truapi_platform".to_string(), + "one".to_string(), + "Shared".to_string(), + ], + kind: "struct".to_string(), + }, + ), + ( + "2".to_string(), + ItemPath { + crate_id: 0, + path: vec![ + "truapi_platform".to_string(), + "two".to_string(), + "Shared".to_string(), + ], + kind: "enum".to_string(), + }, + ), + ]), + }; + let traits = [PlatformTrait { + name: "Storage".to_string(), + docs: None, + methods: vec![PlatformMethod { + name: "write".to_string(), + docs: None, + params: vec![PlatformParam { + name: "value".to_string(), + type_ref: TypeRef::Named { + name: "Shared".to_string(), + args: Vec::new(), + }, + }], + return_shape: PlatformReturn { + is_async: false, + inner: PlatformInner::Unit, + }, + has_default: false, + }], + }]; + + let err = + collect_referenced_local_types(&krate, &traits, &NameContext::default()).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("platform type name `Shared` is ambiguous"), + "unexpected error: {msg}" + ); + assert!( + msg.contains("truapi_platform::one::Shared") + && msg.contains("truapi_platform::two::Shared"), + "unexpected error: {msg}" + ); + } } diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index 15a2d904..1ca6f77c 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -601,4 +601,107 @@ mod tests { "unexpected error message: {msg}", ); } + + #[test] + fn dispatcher_versioned_request_with_raw_error_errors() { + let mut method = make_request_method("alpha", 10); + method.return_type = ReturnType::Result { + ok: TypeRef::Named { + name: "RespWrapper".to_string(), + args: vec![], + }, + err: TypeRef::Named { + name: "CallError".to_string(), + args: vec![TypeRef::Named { + name: "RawError".to_string(), + args: vec![], + }], + }, + }; + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Permissions".to_string(), + module_path: Vec::new(), + methods: vec![method], + docs: None, + }], + public_trait_order: vec!["Permissions".to_string()], + types: vec![ + versioned_test_type("ReqWrapper"), + versioned_test_type("RespWrapper"), + ], + }; + + let err = generate_dispatcher(&api).expect_err("raw error wrapper must error"); + let msg = format!("{err}"); + assert!( + msg.contains("versioned request methods must use versioned errors"), + "unexpected error message: {msg}", + ); + } + + #[test] + fn dispatcher_raw_request_with_versioned_response_errors() { + let mut method = make_request_method("alpha", 10); + method.params[0].type_ref = TypeRef::Named { + name: "RawRequest".to_string(), + args: vec![], + }; + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Permissions".to_string(), + module_path: Vec::new(), + methods: vec![method], + docs: None, + }], + public_trait_order: vec!["Permissions".to_string()], + types: vec![ + versioned_test_type("RespWrapper"), + versioned_test_type("ErrWrapper"), + ], + }; + + let err = generate_dispatcher(&api).expect_err("missing target version must error"); + let msg = format!("{err}"); + assert!( + msg.contains("versioned responses require a target version"), + "unexpected error message: {msg}", + ); + } + + #[test] + fn dispatcher_result_subscription_with_raw_error_errors() { + let mut method = make_subscription_method("alpha_subscribe", 20); + method.kind = MethodKind::ResultSubscription; + method.return_type = ReturnType::ResultSubscription { + item: TypeRef::Named { + name: "ItemWrapper".to_string(), + args: vec![], + }, + err: TypeRef::Named { + name: "CallError".to_string(), + args: vec![TypeRef::Named { + name: "RawError".to_string(), + args: vec![], + }], + }, + }; + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Account".to_string(), + module_path: Vec::new(), + methods: vec![method], + docs: None, + }], + public_trait_order: vec!["Account".to_string()], + types: vec![versioned_test_type("ItemWrapper")], + }; + + let err = generate_dispatcher(&api).expect_err("raw result subscription error must error"); + let msg = format!("{err}"); + assert!( + msg.contains("result subscription methods must have an error wrapper"), + "unexpected error message: {msg}", + ); + } } diff --git a/rust/crates/truapi-codegen/src/rust/dispatcher.rs b/rust/crates/truapi-codegen/src/rust/dispatcher.rs index 15486b1d..183327ed 100644 --- a/rust/crates/truapi-codegen/src/rust/dispatcher.rs +++ b/rust/crates/truapi-codegen/src/rust/dispatcher.rs @@ -16,7 +16,7 @@ use std::collections::BTreeMap; use std::collections::BTreeSet; use std::fmt::Write; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use indoc::{formatdoc, indoc, writedoc}; use crate::rustdoc::*; @@ -128,7 +128,7 @@ fn build_module(api: &ApiDefinition, trait_def: &TraitDef) -> Result Result<()> { match self.kind { MethodKind::Request => self.write_request(out, host_expr), MethodKind::Subscription | MethodKind::ResultSubscription => { @@ -262,7 +262,7 @@ impl MethodEmission { && matches!(self.error_payload, WirePayload::Raw(_)) } - fn write_request(&self, out: &mut String, host_expr: &str) { + fn write_request(&self, out: &mut String, host_expr: &str) -> Result<()> { let module = &self.module; let method = &self.name; let ids = const_name(&self.wire_name); @@ -282,10 +282,9 @@ impl MethodEmission { ); let (call_args, target_version_expr) = match &self.request_payload { Some(WirePayload::Versioned(request)) => { - let error = self - .error_payload - .versioned_name() - .expect("versioned request methods must use versioned errors"); + let Some(error) = self.error_payload.versioned_name() else { + bail!("Method `{method}`: versioned request methods must use versioned errors"); + }; write_indented( out, 16, @@ -312,11 +311,15 @@ impl MethodEmission { ) } Some(WirePayload::Raw(request)) => { - let request_ty = rust_type_ref(request).expect("raw request type"); + let request_ty = rust_type_ref(request).with_context(|| { + format!("Method `{method}`: raw request type cannot be emitted") + })?; let error_ty = self .error_payload .rust_error_type(module) - .expect("raw request methods must have error type"); + .with_context(|| { + format!("Method `{method}`: raw request methods must have error type") + })?; write_indented( out, 16, @@ -351,9 +354,9 @@ impl MethodEmission { .unwrap(); match &self.response_wrapper { Some(response) => { - let target_version_expr = target_version_expr - .as_deref() - .expect("versioned responses require a target version"); + let Some(target_version_expr) = target_version_expr.as_deref() else { + bail!("Method `{method}`: versioned responses require a target version"); + }; write_indented( out, 16, @@ -401,7 +404,9 @@ impl MethodEmission { }, ); } - (WirePayload::Versioned(_), None) => unreachable!("missing versioned target"), + (WirePayload::Versioned(_), None) => { + bail!("Method `{method}`: versioned unit responses require a target version") + } }, } write_indented( @@ -415,16 +420,16 @@ impl MethodEmission { "# }, ); + Ok(()) } - fn write_subscription(&self, out: &mut String, host_expr: &str) { + fn write_subscription(&self, out: &mut String, host_expr: &str) -> Result<()> { let module = &self.module; let method = &self.name; let ids = const_name(&self.wire_name); - let item = self - .item_wrapper - .as_deref() - .expect("subscription methods must have an item wrapper"); + let Some(item) = self.item_wrapper.as_deref() else { + bail!("Method `{method}`: subscription methods must have an item wrapper"); + }; let error = self.error_payload.versioned_name(); let is_result_sub = matches!(self.kind, MethodKind::ResultSubscription); @@ -502,7 +507,9 @@ impl MethodEmission { ) .unwrap(); if is_result_sub { - let _ = error.expect("result subscription methods must have an error wrapper"); + if error.is_none() { + bail!("Method `{method}`: result subscription methods must have an error wrapper"); + } write_indented( out, 16, @@ -540,6 +547,7 @@ impl MethodEmission { "# }, ); + Ok(()) } } From 2c52262ef914183fcf8bbb6e2804d58271705234 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 17:09:09 +0200 Subject: [PATCH 12/56] fixup! feat(truapi-codegen): emit Rust dispatcher, wire table, and host callbacks --- .../tests/golden/host-callbacks.ts | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index d111c323..407e2050 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -304,17 +304,6 @@ export interface CoreAdmin { */ disconnectSession(): Promise; - /** - * Cancel any in-flight pairing request. - */ - cancelPairing(): void; - - /** - * Notify the core that the host-global auth session slot may have - * changed. The core re-reads storage and emits any resulting auth state. - */ - notifySessionStoreChanged(): void; - /** * Read a stored permission authorization status without prompting. */ @@ -418,9 +407,27 @@ export interface Notifications { } /** - * Permission prompts. v0.1 keeps device permissions (camera, mic, NFC, ...) - * separate from remote permissions (domain access, chain submit, ...), so the - * platform surface mirrors that split. + * Pairing-host-only administration API exposed to host UI. + */ +export interface PairingHostAdmin { + /** + * Cancel any in-flight pairing request. + */ + cancelPairing(): void; + + /** + * Notify the core that the persisted auth-session blob may have changed. + * + * The host owns persistence and change detection. The pairing core owns + * decoding that blob into live `SessionState` / `AuthState`. + */ + notifySessionStoreChanged(): void; +} + +/** + * Permission prompts. Device permissions (camera, mic, NFC, ...) are separate + * from remote permissions (domain access, chain submit, ...), so the platform + * surface mirrors that split. */ export interface Permissions { /** @@ -451,8 +458,10 @@ export interface PreimageHost { } /** - * Product-scoped key-value storage. The platform namespaces keys so different - * products cannot read each other's data. + * Product-scoped key-value storage. + * + * The core namespaces product keys before calling this trait. Host + * implementations should treat `key` as an opaque OS-style storage key. */ export interface ProductStorage { /** From 7bd57b863f01a065383b923edebc3d5d655c606a Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 17:20:39 +0200 Subject: [PATCH 13/56] fixup! feat(truapi-codegen): emit Rust dispatcher, wire table, and host callbacks --- rust/crates/truapi-codegen/src/rustdoc.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index 0dd136ee..8901fc0a 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -29,7 +29,6 @@ pub struct Item { /// Local item name as it appears in source. pub name: Option, /// Rustdoc comment on the item, if any. - #[allow(dead_code)] pub docs: Option, /// Kind-dependent rustdoc payload, parsed lazily by helpers in this module. pub inner: serde_json::Value, From 6964df37a1f1bb129f88fbd2f41dfe73dbd34486 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 1 Jul 2026 11:18:30 +0200 Subject: [PATCH 14/56] feat(truapi-server): add host logic primitives --- Cargo.lock | 3869 +++++++++++++++-- Cargo.toml | 1 - deny.toml | 3 + rust/crates/truapi-server/Cargo.toml | 66 + rust/crates/truapi-server/README.md | 33 + rust/crates/truapi-server/src/host_logic.rs | 16 + .../truapi-server/src/host_logic/dotns.rs | 473 ++ .../truapi-server/src/host_logic/entropy.rs | 113 + .../truapi-server/src/host_logic/features.rs | 66 + .../truapi-server/src/host_logic/identity.rs | 123 + .../src/host_logic/permissions.rs | 706 +++ .../src/host_logic/product_account.rs | 175 + .../truapi-server/src/host_logic/session.rs | 416 ++ .../src/host_logic/session_store.rs | 97 + .../truapi-server/src/host_logic/sso.rs | 2 + .../src/host_logic/sso/messages.rs | 772 ++++ .../src/host_logic/sso/pairing.rs | 758 ++++ .../src/host_logic/statement_store.rs | 38 + .../src/host_logic/statement_store/rpc.rs | 115 + .../host_logic/statement_store/statement.rs | 675 +++ rust/crates/truapi-server/src/lib.rs | 9 + 21 files changed, 8090 insertions(+), 436 deletions(-) create mode 100644 rust/crates/truapi-server/Cargo.toml create mode 100644 rust/crates/truapi-server/README.md create mode 100644 rust/crates/truapi-server/src/host_logic.rs create mode 100644 rust/crates/truapi-server/src/host_logic/dotns.rs create mode 100644 rust/crates/truapi-server/src/host_logic/entropy.rs create mode 100644 rust/crates/truapi-server/src/host_logic/features.rs create mode 100644 rust/crates/truapi-server/src/host_logic/identity.rs create mode 100644 rust/crates/truapi-server/src/host_logic/permissions.rs create mode 100644 rust/crates/truapi-server/src/host_logic/product_account.rs create mode 100644 rust/crates/truapi-server/src/host_logic/session.rs create mode 100644 rust/crates/truapi-server/src/host_logic/session_store.rs create mode 100644 rust/crates/truapi-server/src/host_logic/sso.rs create mode 100644 rust/crates/truapi-server/src/host_logic/sso/messages.rs create mode 100644 rust/crates/truapi-server/src/host_logic/sso/pairing.rs create mode 100644 rust/crates/truapi-server/src/host_logic/statement_store.rs create mode 100644 rust/crates/truapi-server/src/host_logic/statement_store/rpc.rs create mode 100644 rust/crates/truapi-server/src/host_logic/statement_store/statement.rs create mode 100644 rust/crates/truapi-server/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index be16e446..23c9c80a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,47 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "1.0.0" @@ -38,7 +79,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,7 +90,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,12 +99,146 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9fd44efafa8690358b7408d253adf110036b88f55672a933f01d616ad9b1b9" +dependencies = [ + "nodrop", +] + [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -75,11 +250,71 @@ dependencies = [ "syn", ] +[[package]] +name = "atomic-take" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8ab6b55fe97976e46f91ddbed8d147d966475dc29b2032757ba47e02376fbc3" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "hex-conservative", +] + [[package]] name = "bitflags" -version = "2.13.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bitvec" @@ -93,18 +328,140 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2-rfc" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d530bdd2d52966a6d03b7a964add7ae1a288d25214066fd4b600f0f796400" +dependencies = [ + "arrayvec 0.4.12", + "constant_time_eq 0.1.5", +] + +[[package]] +name = "blake2b_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +dependencies = [ + "arrayref", + "arrayvec 0.7.6", + "constant_time_eq 0.4.2", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "byte-slice-cast" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -151,6 +508,41 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const_format" version = "0.2.36" @@ -172,6 +564,18 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.6.0" @@ -191,129 +595,136 @@ dependencies = [ ] [[package]] -name = "derive_more" -version = "2.1.1" +name = "core-foundation" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "derive_more-impl", + "core-foundation-sys", + "libc", ] [[package]] -name = "derive_more-impl" -version = "2.1.1" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "convert_case 0.10.0", - "proc-macro2", - "quote", - "rustc_version", - "syn", - "unicode-xid", + "libc", ] [[package]] -name = "displaydoc" -version = "0.2.5" +name = "crossbeam-queue" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" dependencies = [ - "proc-macro2", - "quote", - "syn", + "crossbeam-utils", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "crossbeam-utils" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "errno" -version = "0.3.14" +name = "crunchy" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "libc", - "windows-sys", + "generic-array", + "rand_core", + "subtle", + "zeroize", ] [[package]] -name = "fastrand" -version = "2.4.1" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "percent-encoding", + "generic-array", + "rand_core", + "typenum", ] [[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futures" -version = "0.3.32" +name = "crypto-mac" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab" dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", + "generic-array", + "subtle", ] [[package]] -name = "futures-channel" -version = "0.3.32" +name = "ctr" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "futures-core", - "futures-sink", + "cipher", ] [[package]] -name = "futures-core" -version = "0.3.32" +name = "curve25519-dalek" +version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] [[package]] -name = "futures-executor" -version = "0.3.32" +name = "curve25519-dalek-derive" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ - "futures-core", - "futures-task", - "futures-util", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "futures-io" -version = "0.3.32" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] [[package]] -name = "futures-macro" -version = "0.3.32" +name = "derive-where" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" dependencies = [ "proc-macro2", "quote", @@ -321,656 +732,3098 @@ dependencies = [ ] [[package]] -name = "futures-sink" -version = "0.3.32" +name = "derive_more" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] [[package]] -name = "futures-task" -version = "0.3.32" +name = "derive_more" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl 2.1.1", +] [[package]] -name = "futures-util" -version = "0.3.32" +name = "derive_more-impl" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "getrandom" -version = "0.4.3" +name = "derive_more-impl" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "cfg-if", - "libc", - "r-efi", + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", ] [[package]] -name = "hashbrown" -version = "0.17.0" +name = "digest" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] [[package]] -name = "heck" -version = "0.5.0" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", + "subtle", +] [[package]] -name = "hex" -version = "0.4.3" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "icu_collections" -version = "2.2.0" +name = "downcast-rs" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] -name = "icu_locale_core" -version = "2.2.0" +name = "ed25519" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", + "pkcs8", + "signature", ] [[package]] -name = "icu_normalizer" -version = "2.2.0" +name = "ed25519-zebra" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "775765289f7c6336c18d3d66127527820dd45ffd9eb3b6b8ee4708590e6c20f5" dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", + "curve25519-dalek", + "ed25519", + "hashbrown 0.16.1", + "pkcs8", + "rand_core", + "sha2 0.10.9", + "subtle", + "zeroize", ] [[package]] -name = "icu_normalizer_data" -version = "2.2.0" +name = "either" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] -name = "icu_properties" -version = "2.2.0" +name = "elliptic-curve" +version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "rand_core", + "sec1", + "subtle", + "zeroize", ] [[package]] -name = "icu_properties_data" -version = "2.2.0" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "icu_provider" -version = "2.2.0" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", + "libc", + "windows-sys 0.61.2", ] [[package]] -name = "idna" -version = "1.1.0" +name = "event-listener" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", + "concurrent-queue", + "parking", + "pin-project-lite", ] [[package]] -name = "idna_adapter" -version = "1.2.2" +name = "event-listener-strategy" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "icu_normalizer", - "icu_properties", + "event-listener", + "pin-project-lite", ] [[package]] -name = "impl-trait-for-tuples" -version = "0.2.3" +name = "fastbloom" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" dependencies = [ - "proc-macro2", - "quote", - "syn", + "foldhash 0.2.0", + "libm", + "portable-atomic", + "siphasher", ] [[package]] -name = "indexmap" -version = "2.14.0" +name = "fastrand" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "indoc" -version = "2.0.7" +name = "ff" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rustversion", + "rand_core", + "subtle", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "fiat-crypto" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] -name = "itoa" -version = "1.0.18" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "konst" -version = "0.2.20" +name = "finito" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +checksum = "2384245d85162258a14b43567a9ee3598f5ae746a1581fb5d3d2cb780f0dbf95" dependencies = [ - "konst_macro_rules", + "futures-timer", + "pin-project", ] [[package]] -name = "konst_macro_rules" -version = "0.2.19" +name = "fixed-hash" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand", + "rustc-hex", + "static_assertions", +] [[package]] -name = "libc" -version = "0.2.186" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "litemap" -version = "0.8.2" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] -name = "memchr" -version = "2.8.0" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] [[package]] -name = "once_cell" -version = "1.21.4" +name = "frame-metadata" +version = "23.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" +checksum = "9ba5be0edbdb824843a0f9c6f0906ecfc66c5316218d74457003218b24909ed0" +dependencies = [ + "cfg-if", + "parity-scale-codec", + "scale-info", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +dependencies = [ + "gloo-timers", + "send_wrapper 0.4.0", +] + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "getrandom_or_panic" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea1015b5a70616b688dc230cfe50c8af89d972cb132d5a622814d29773b10b9" +dependencies = [ + "rand_core", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec 0.7.6", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126888268dcc288495a26bf004b38c5fdbb31682f992c84ceb046a1f0fe38840" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac-drbg" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ea0a1394df5b6574da6e0c1ade9e78868c9fb0a4e5ef4428e32da4676b85b1" +dependencies = [ + "digest 0.9.0", + "generic-array", + "hmac 0.8.1", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-codec" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d40b9d5e17727407e55028eafc22b2dc68781786e6d7eb8a21103f5058e3a14" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-serde" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a143eada6a1ec4aefa5049037a26a6d597bfd64f8c026d07b77133e02b7dd0b" +dependencies = [ + "serde", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[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.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonrpsee" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c4b1f204b655b36b24dc4939af20366c649431d4711863bbbae5c495f3eeb4" +dependencies = [ + "jsonrpsee-client-transport", + "jsonrpsee-core", + "jsonrpsee-types", + "jsonrpsee-wasm-client", + "jsonrpsee-ws-client", +] + +[[package]] +name = "jsonrpsee-client-transport" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3e1420b1792cff778e2a1ebaa44115f156ee62a94dd106eaa51163f037d2023" +dependencies = [ + "base64", + "futures-channel", + "futures-util", + "gloo-net", + "http", + "jsonrpsee-core", + "pin-project", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "soketto", + "thiserror 1.0.69", + "tokio", + "tokio-rustls", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "jsonrpsee-core" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49bfa9334963e1c85866b39dff3ffcc81f1c286eb23334267c5cb97677543a4" +dependencies = [ + "async-trait", + "futures-timer", + "futures-util", + "jsonrpsee-types", + "pin-project", + "rustc-hash", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "wasm-bindgen-futures", +] + +[[package]] +name = "jsonrpsee-types" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d86fc943f81dab0ecdd6c0240b6e0f55ad57a2ea9ad8ad7efe8456fb9cc7a4" +dependencies = [ + "http", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonrpsee-wasm-client" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "735df2088674c87f7fecdf51c80878a7aa19a8116b32d703b000f5b1a7acf95a" +dependencies = [ + "jsonrpsee-client-transport", + "jsonrpsee-core", + "jsonrpsee-types", +] + +[[package]] +name = "jsonrpsee-ws-client" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df5bd5c38c0906a6e8b3a38c8c22cc8525fda25fd1a03a3fe010686aea66b70" +dependencies = [ + "http", + "jsonrpsee-client-transport", + "jsonrpsee-core", + "jsonrpsee-types", + "url", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsecp256k1" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79019718125edc905a079a70cfa5f3820bc76139fc91d6f9abc27ea2a887139" +dependencies = [ + "arrayref", + "base64", + "digest 0.9.0", + "hmac-drbg", + "libsecp256k1-core", + "libsecp256k1-gen-ecmult", + "libsecp256k1-gen-genmult", + "rand", + "serde", + "sha2 0.9.9", + "typenum", +] + +[[package]] +name = "libsecp256k1-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be9b9bb642d8522a44d533eab56c16c738301965504753b03ad1de3425d5451" +dependencies = [ + "crunchy", + "digest 0.9.0", + "subtle", +] + +[[package]] +name = "libsecp256k1-gen-ecmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3038c808c55c87e8a172643a7d87187fc6c4174468159cb3090659d55bcb4809" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "libsecp256k1-gen-genmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db8d6ba2cec9eacc40e6e8ccc98931840301f1006e95647ceb2dd5c3aa06f7c" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core", + "zeroize", +] + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multi-stash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685a9ac4b61f4e728e1d2c6a7844609c16527aeb5e6c865915c08e619c16410f" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "elliptic-curve", + "primeorder", +] + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec 0.7.6", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "primitive-types" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15600a7d856470b7d278b3fe0e311fe28c2526348549f8ef2ff7db3299c87f5" +dependencies = [ + "fixed-hash", + "impl-codec", + "impl-serde", + "uint", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs 0.26.11", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ruzstd" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scale-info" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b" +dependencies = [ + "cfg-if", + "derive_more 1.0.0", + "parity-scale-codec", + "scale-info-derive", +] + +[[package]] +name = "scale-info-derive" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6630024bf739e2179b91fb424b28898baf819414262c5d376677dbff1fe7ebf" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schnorrkel" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9fcb6c2e176e86ec703e22560d99d65a5ee9056ae45a08e13e84ebf796296f" +dependencies = [ + "aead", + "arrayref", + "arrayvec 0.7.6", + "curve25519-dalek", + "getrandom_or_panic", + "merlin", + "rand_core", + "serde_bytes", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f638d531eccd6e23b980caf34876660d38e265409d8e99b397ab71eb3612fad0" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smol" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" +dependencies = [ + "async-channel", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite", +] + +[[package]] +name = "smoldot" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e8b3be57abd860ec235a62084ad8a72772d4bf799ba26aa3aefc282273fcf5e" +dependencies = [ + "arrayvec 0.7.6", + "async-lock", + "atomic-take", + "base32", + "base64", + "bip39", + "blake2-rfc", + "bs58", + "chacha20", + "crossbeam-queue", + "derive_more 2.1.1", + "ed25519-zebra", + "either", + "event-listener", + "fastbloom", + "fnv", + "futures-lite", + "futures-util", + "hashbrown 0.16.1", + "hex", + "hmac 0.12.1", + "itertools", + "libm", + "libsecp256k1", + "merlin", + "nom", + "num-bigint", + "num-rational", + "num-traits", + "pbkdf2", + "pin-project", + "poly1305", + "rand", + "rand_chacha", + "ruzstd", + "schnorrkel", + "serde", + "serde_json", + "sha2 0.10.9", + "sha3", + "siphasher", + "slab", + "smallvec", + "soketto", + "twox-hash 2.1.2", + "wasmi", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "smoldot" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f753549b68687bf8e27cda1e26dfbe8762701216ea722d43bd992a3a2576daa1" +dependencies = [ + "arrayvec 0.7.6", + "async-lock", + "atomic-take", + "base32", + "base64", + "bip39", + "blake2-rfc", + "bs58", + "chacha20", + "crossbeam-queue", + "derive_more 2.1.1", + "ed25519-zebra", + "either", + "event-listener", + "fastbloom", + "fnv", + "futures-lite", + "futures-util", + "hashbrown 0.16.1", + "hex", + "hmac 0.12.1", + "itertools", + "libm", + "libsecp256k1", + "merlin", + "nom", + "num-bigint", + "num-rational", + "num-traits", + "pbkdf2", + "pin-project", + "poly1305", + "rand", + "rand_chacha", + "ruzstd", + "schnorrkel", + "serde", + "serde_json", + "sha2 0.10.9", + "sha3", + "siphasher", + "slab", + "smallvec", + "soketto", + "twox-hash 2.1.2", + "wasmi", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "smoldot-light" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50345b88d93e5fd400b04cc9c64ab34fa87235e2c0aba4f1973916d7547feb4f" +dependencies = [ + "async-channel", + "async-lock", + "base64", + "blake2-rfc", + "bs58", + "derive_more 2.1.1", + "either", + "event-listener", + "fnv", + "futures-channel", + "futures-lite", + "futures-util", + "hashbrown 0.16.1", + "hex", + "itertools", + "log", + "lru", + "parking_lot", + "pin-project", + "rand", + "rand_chacha", + "serde", + "serde_json", + "siphasher", + "slab", + "smol", + "smoldot 2.0.0", + "zeroize", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "soketto" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e859df029d160cb88608f5d7df7fb4753fd20fdfb4de5644f3d8b8440841721" +dependencies = [ + "base64", + "bytes", + "futures", + "httparse", + "log", + "rand", + "sha1", +] + +[[package]] +name = "sp-crypto-hashing" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9927a7f81334ed5b8a98a4a978c81324d12bd9713ec76b5c68fd410174c5eb" +dependencies = [ + "blake2b_simd", + "byteorder", + "digest 0.10.7", + "sha2 0.10.9", + "sha3", + "twox-hash 1.6.3", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "subxt-lightclient" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961bd1b7d5531f7a6b8086364eb4c6c09b21675e5e8b29b56ea281187d151eef" +dependencies = [ + "futures", + "futures-timer", + "futures-util", + "getrandom 0.2.17", + "js-sys", + "pin-project", + "send_wrapper 0.6.0", + "serde", + "serde_json", + "smoldot 1.2.0", + "smoldot-light", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", +] + +[[package]] +name = "subxt-rpcs" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e4b23044ba59654f30e25cc48f8865e70439ee137764793cdfb0f8452dc638" +dependencies = [ + "derive-where", + "finito", + "frame-metadata", + "futures", + "getrandom 0.2.17", + "hex", + "impl-serde", + "jsonrpsee", + "parity-scale-codec", + "primitive-types", + "serde", + "serde_json", + "subxt-lightclient", + "thiserror 2.0.18", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] [[package]] -name = "parity-scale-codec" -version = "3.7.5" +name = "tracing" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "arrayvec", - "bitvec", - "byte-slice-cast", - "const_format", - "impl-trait-for-tuples", - "parity-scale-codec-derive", - "rustversion", - "serde", + "pin-project-lite", + "tracing-attributes", + "tracing-core", ] [[package]] -name = "parity-scale-codec-derive" -version = "3.7.5" +name = "tracing-attributes" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ - "proc-macro-crate", "proc-macro2", "quote", "syn", ] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "tracing-core" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "tracing-subscriber" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] [[package]] -name = "potential_utf" -version = "0.1.5" +name = "truapi" +version = "0.3.1" +dependencies = [ + "derive_more 2.1.1", + "futures", + "hex", + "parity-scale-codec", + "truapi-macros", +] + +[[package]] +name = "truapi-codegen" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "convert_case 0.6.0", + "indoc", + "serde", + "serde_json", + "tempfile", + "truapi", +] + +[[package]] +name = "truapi-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "truapi-platform" +version = "0.1.0" +dependencies = [ + "async-trait", + "derive_more 2.1.1", + "futures", + "parity-scale-codec", + "truapi", + "unicode-normalization", + "url", +] + +[[package]] +name = "truapi-server" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "async-trait", + "blake2-rfc", + "bs58", + "console_error_panic_hook", + "derive_more 2.1.1", + "futures", + "futures-timer", + "futures-util", + "getrandom 0.2.17", + "hex", + "hkdf", + "js-sys", + "p256", + "parity-scale-codec", + "pin-project", + "primitive-types", + "schnorrkel", + "send_wrapper 0.6.0", + "serde", + "serde_json", + "sha2 0.10.9", + "sp-crypto-hashing", + "subxt-rpcs", + "thiserror 1.0.69", + "tracing", + "tracing-subscriber", + "truapi", + "truapi-platform", + "unicode-normalization", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", + "web-time", +] + +[[package]] +name = "twox-hash" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ - "zerovec", + "cfg-if", + "digest 0.10.7", + "static_assertions", ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "twox-hash" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "uint" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909988d098b2f738727b161a106cfc7cab00c539c2687a8836f8e565976fb53e" dependencies = [ - "toml_edit", + "byteorder", + "crunchy", + "hex", + "static_assertions", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "unicode-ident" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ - "unicode-ident", + "tinyvec", ] [[package]] -name = "quote" -version = "1.0.45" +name = "unicode-segmentation" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "proc-macro2", + "crypto-common", + "subtle", ] [[package]] -name = "r-efi" -version = "6.0.0" +name = "untrusted" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "radium" -version = "0.7.0" +name = "url" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] [[package]] -name = "rustc_version" -version = "0.4.1" +name = "utf8_iter" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ - "semver", + "same-file", + "winapi-util", ] [[package]] -name = "rustix" -version = "1.1.4" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "wit-bindgen 0.57.1", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] [[package]] -name = "semver" -version = "1.0.28" +name = "wasm-bindgen" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] [[package]] -name = "serde" -version = "1.0.228" +name = "wasm-bindgen-futures" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ - "serde_core", - "serde_derive", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "wasm-bindgen-macro" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ - "serde_derive", + "quote", + "wasm-bindgen-macro-support", ] [[package]] -name = "serde_derive" -version = "1.0.228" +name = "wasm-bindgen-macro-support" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", + "wasm-bindgen-shared", ] [[package]] -name = "serde_json" -version = "1.0.149" +name = "wasm-bindgen-shared" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ - "itoa", - "memchr", + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af5ec93229ad9ccd0a545a516dec76dc276613f278f6a91aa6b463d5b33d42d0" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", "serde", - "serde_core", - "zmij", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", ] [[package]] -name = "slab" -version = "0.4.12" +name = "wasm-bindgen-test-macro" +version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "3c81b9fef827e575e0e54431736d1baa0d700315d8c62cfef1f61fa3aad0cbeb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "smallvec" -version = "1.15.1" +name = "wasm-bindgen-test-shared" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "4f4d8ae7ad5440360e9799dfd42857d126454a88441ddf72d288ef83fa47f527" + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasmi" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19af97fcb96045dd1d6b4d23e2b4abdbbe81723dbc5c9f016eb52145b320063" +dependencies = [ + "arrayvec 0.7.6", + "multi-stash", + "smallvec", + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser 0.221.3", +] [[package]] -name = "stable_deref_trait" -version = "1.2.1" +name = "wasmi_collections" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +checksum = "e80d6b275b1c922021939d561574bf376613493ae2b61c6963b15db0e8813562" [[package]] -name = "strsim" -version = "0.11.1" +name = "wasmi_core" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "3a8c51482cc32d31c2c7ff211cd2bedd73c5bd057ba16a2ed0110e7a96097c33" +dependencies = [ + "downcast-rs", + "libm", +] [[package]] -name = "syn" -version = "2.0.117" +name = "wasmi_ir" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "6e431a14c186db59212a88516788bd68ed51f87aa1e08d1df742522867b5289a" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "wasmi_core", ] [[package]] -name = "synstructure" -version = "0.13.2" +name = "wasmparser" +version = "0.221.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" dependencies = [ - "proc-macro2", - "quote", - "syn", + "bitflags", ] [[package]] -name = "tap" -version = "1.0.1" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] [[package]] -name = "tempfile" -version = "3.27.0" +name = "web-sys" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "tinystr" -version = "0.8.3" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "displaydoc", - "zerovec", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "tinyvec" -version = "1.11.0" +name = "webpki-root-certs" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" dependencies = [ - "tinyvec_macros", + "webpki-root-certs 1.0.8", ] [[package]] -name = "tinyvec_macros" -version = "0.1.1" +name = "webpki-root-certs" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] [[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +name = "winapi-util" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "serde_core", + "windows-sys 0.61.2", ] [[package]] -name = "toml_edit" -version = "0.25.11+spec-1.1.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", + "windows-targets 0.42.2", ] [[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "winnow", + "windows-targets 0.52.6", ] [[package]] -name = "truapi" -version = "0.3.1" +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "derive_more", - "futures", - "hex", - "parity-scale-codec", - "truapi-macros", + "windows-targets 0.52.6", ] [[package]] -name = "truapi-codegen" -version = "0.1.0" +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "anyhow", - "clap", - "convert_case 0.6.0", - "indoc", - "serde", - "serde_json", - "tempfile", - "truapi", + "windows-link", ] [[package]] -name = "truapi-macros" -version = "0.1.0" +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", ] [[package]] -name = "truapi-platform" -version = "0.1.0" +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "async-trait", - "derive_more", - "futures", - "parity-scale-codec", - "truapi", - "unicode-normalization", - "url", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "windows_aarch64_gnullvm" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" [[package]] -name = "unicode-normalization" -version = "0.1.25" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "unicode-segmentation" -version = "1.13.2" +name = "windows_aarch64_msvc" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "url" -version = "2.5.8" +name = "windows_i686_gnu" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" [[package]] -name = "utf8_iter" -version = "1.0.4" +name = "windows_i686_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] -name = "utf8parse" -version = "0.2.2" +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "windows-link" -version = "0.2.1" +name = "windows_i686_msvc" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" [[package]] -name = "windows-sys" -version = "0.61.2" +name = "windows_i686_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" @@ -981,6 +3834,100 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser 0.244.0", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] + [[package]] name = "writeable" version = "0.6.3" @@ -996,6 +3943,18 @@ dependencies = [ "tap", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + [[package]] name = "yoke" version = "0.8.2" @@ -1019,6 +3978,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -1040,6 +4019,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index d657d267..042aff82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] resolver = "2" members = ["rust/crates/*"] -exclude = ["rust/crates/truapi-server"] [workspace.package] edition = "2024" diff --git a/deny.toml b/deny.toml index 707c6f77..4fdc03c6 100644 --- a/deny.toml +++ b/deny.toml @@ -5,6 +5,9 @@ allow = [ "MIT", "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", "Unicode-3.0", "Unlicense", "Zlib", diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml new file mode 100644 index 00000000..6cecb5f6 --- /dev/null +++ b/rust/crates/truapi-server/Cargo.toml @@ -0,0 +1,66 @@ +[package] +name = "truapi-server" +version = "0.1.0" +edition.workspace = true +description = "TrUAPI server runtime: dispatcher, frames, SCALE, streams" +license = "MIT" + +[lib] +crate-type = ["rlib", "cdylib"] + +[features] +default = [] + +[dependencies] +truapi = { path = "../truapi" } +truapi-platform = { path = "../truapi-platform" } +async-trait = "0.1" +derive_more = { version = "2", features = ["display"] } +futures = "0.3" +futures-timer = { version = "3", features = ["wasm-bindgen"] } +parity-scale-codec = { version = "3", features = ["derive"] } +primitive-types = { version = "0.13", default-features = false, features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +unicode-normalization = "0.1" +url = "2" +hex = "0.4" +blake2-rfc = { version = "0.2", default-features = false } +sp-crypto-hashing = { version = "0.1", default-features = false } +bs58 = { version = "0.5", default-features = false, features = ["alloc"] } +schnorrkel = { version = "0.11.5", default-features = false, features = ["alloc", "getrandom"] } +getrandom = { version = "0.2", features = ["js"] } +p256 = { version = "0.13", default-features = false, features = ["ecdh"] } +hkdf = "0.12" +sha2 = "0.10" +aes-gcm = { version = "0.10", default-features = false, features = ["aes", "alloc"] } +tracing = "0.1" +# `registry` + `std` only: pulls the Registry + per-layer filter/reload, but +# not `env-filter` (which drags in `regex`, heavy on wasm). +tracing-subscriber = { version = "0.3", default-features = false, features = ["registry", "std"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +subxt-rpcs = { version = "0.50.1", default-features = false, features = ["native"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" +subxt-rpcs = { version = "0.50.1", default-features = false, features = ["web"] } +wasm-bindgen = "0.2.118" +wasm-bindgen-futures = "0.4" +console_error_panic_hook = "0.1" +futures-util = "0.3" +pin-project = "1" +send_wrapper = { version = "0.6", features = ["futures"] } +web-time = "1" +web-sys = { version = "0.3", features = [ + "BinaryType", + "CloseEvent", + "console", + "Event", + "MessageEvent", + "WebSocket", +] } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md new file mode 100644 index 00000000..c4a91856 --- /dev/null +++ b/rust/crates/truapi-server/README.md @@ -0,0 +1,33 @@ +# truapi-server + +_Runtime core for TrUAPI: dispatcher, protocol frames, SCALE-coded wire envelope._ + +## What this crate is for + +`truapi-server` is the runtime that turns trait implementations of the +`truapi` API into a working host. It owns: + +- the [`ProtocolMessage`] wire envelope and SCALE codec +- the [`Dispatcher`] that routes incoming frames to per-method handlers +- the subscription lifecycle (start/receive/stop/interrupt) +- the [`Transport`] trait that platform-specific IPC backends implement +- the auto-generated dispatcher/wire-table tables shipped under + [`crate::generated`] + +## Wire envelope + +Every frame on the wire is encoded as: + +```text +[requestId: SCALE str][discriminant: u8][payload bytes...] +``` + +The discriminant identifies a method + frame kind via the auto-generated +[`crate::generated::wire_table::WIRE_TABLE`]. Each method's ids are exposed +as a named const (`PREIMAGE_SUBMIT`, ...); both `WIRE_TABLE` and the generated +dispatcher reference those consts. Method ordering is part of the wire +protocol; only ever append. + +The payload bytes are the SCALE-encoded inner value, inlined without a +length prefix. The discriminant is carried directly as `Payload::id`, and the +dispatcher routes on that numeric id via id-keyed tables. diff --git a/rust/crates/truapi-server/src/host_logic.rs b/rust/crates/truapi-server/src/host_logic.rs new file mode 100644 index 00000000..183688fa --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic.rs @@ -0,0 +1,16 @@ +//! Host-agnostic logic the Rust core owns on behalf of every platform host. +//! +//! Platform callbacks are a syscall layer for OS primitives (modals, native +//! storage, URL handler, notification center). Everything else lives here so +//! iOS, Android, and web hosts share one canonical implementation. + +pub mod dotns; +pub mod entropy; +pub mod features; +pub mod identity; +pub mod permissions; +pub mod product_account; +pub mod session; +pub mod session_store; +pub mod sso; +pub mod statement_store; diff --git a/rust/crates/truapi-server/src/host_logic/dotns.rs b/rust/crates/truapi-server/src/host_logic/dotns.rs new file mode 100644 index 00000000..67f93351 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/dotns.rs @@ -0,0 +1,473 @@ +//! dotns URL parsing, normalization, and classification. +//! +//! The Rust core owns the whole decision so every platform host sees the +//! same categorization and the `navigate_to` callback only receives +//! already-validated input. + +use unicode_normalization::UnicodeNormalization; +use url::Url; + +/// How the input URL should be opened. Kept in one enum rather than passing +/// a raw string so the dispatcher can reject invalid input before reaching +/// any platform callback. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NavigateDecision { + /// A `.dot` identifier plus path/query/hash suffix (no leading `/`). + DotName { + /// Lower-cased `.dot` host (e.g. `mytestapp.dot`). + identifier: String, + /// Path/query/hash suffix without a leading `/`. + path: String, + }, + /// A `localhost[:port]` URL plus path/query/hash suffix (no leading `/`). + Localhost { + /// `localhost` with optional `:port` suffix. + host: String, + /// Path/query/hash suffix without a leading `/`. + path: String, + }, + /// An absolute external URL with an `http(s):` scheme prepended if missing. + External { + /// Canonical URL string. + url: String, + }, + /// Input that fails every branch: empty, unparseable, or a `.dot` URL + /// carrying port/userinfo (both forbidden since dotns resolves via the + /// chain and has no notion of either). + Reject { + /// Human-readable reason for the rejection. + reason: String, + }, +} + +impl NavigateDecision { + /// Canonical URL string for the three `Open*` variants; `None` for + /// `Reject`. `DotName` and `Localhost` keep the dotns/localhost identity + /// visible so env-aware hosts (e.g. dotli rewriting `.dot` to `.dot.li`) + /// can re-parse and do their own assembly without losing information. + pub fn canonical_url(&self) -> Option { + match self { + Self::DotName { identifier, path } => Some(join_url("https://", identifier, path)), + Self::Localhost { host, path } => Some(join_url("http://", host, path)), + Self::External { url } => Some(url.clone()), + Self::Reject { .. } => None, + } + } +} + +fn join_url(scheme: &str, host: &str, path: &str) -> String { + if path.is_empty() { + format!("{scheme}{host}") + } else { + format!("{scheme}{host}/{path}") + } +} + +/// Classify a URL the way dotli's `handleNavigateTo` does: try `.dot` first, +/// then `localhost`, then normalize as external. +pub fn parse_navigate(input: &str) -> NavigateDecision { + let trimmed = input.trim(); + if trimmed.is_empty() { + return NavigateDecision::Reject { + reason: "empty input".to_string(), + }; + } + + if let Some(decision) = classify_dot(trimmed) { + return decision; + } + + if let Some(decision) = classify_localhost(trimmed) { + return decision; + } + + match normalize_external(trimmed) { + Ok(url) => NavigateDecision::External { url }, + Err(reason) => NavigateDecision::Reject { reason }, + } +} + +/// Canonical host form: case-folded and NFC-normalized (belt-and-suspenders; +/// `url` already applies IDNA to parsed hosts), with a trailing root dot +/// dropped so the absolute form `example.dot.` keys identically to +/// `example.dot`. +fn normalize_host(host: &str) -> String { + let normalized: String = host.nfc().collect::().to_lowercase(); + normalized + .strip_suffix('.') + .unwrap_or(&normalized) + .to_string() +} + +/// `.dot` TLD check, applied to the [`normalize_host`] form so `Example.DOT` +/// and the trailing-dot FQDN `example.dot.` classify like `example.dot`. +fn is_dot_domain(host: &str) -> bool { + normalize_host(host).ends_with(".dot") +} + +fn parse_with_explicit_https(input: &str) -> Option { + if let Ok(direct) = Url::parse(input) { + return Some(direct); + } + Url::parse(&format!("https://{input}")).ok() +} + +/// Recognize `.dot` URLs (including the `polkadot://` scheme). Returns: +/// - `Some(DotName)` for a clean `.dot` URL +/// - `Some(Reject)` for a `.dot` URL with port or userinfo +/// - `None` when the input isn't a `.dot` URL (caller falls through to +/// localhost / external) +fn classify_dot(input: &str) -> Option { + let parsed = if input.starts_with("polkadot://") { + Url::parse(input).ok()? + } else { + parse_with_explicit_https(input)? + }; + + let hostname = parsed.host_str()?; + if !is_dot_domain(hostname) { + return None; + } + + if parsed.port().is_some() || !parsed.username().is_empty() || parsed.password().is_some() { + return Some(NavigateDecision::Reject { + reason: format!("{hostname} carries port or userinfo; dotns forbids both"), + }); + } + + Some(NavigateDecision::DotName { + identifier: normalize_host(hostname), + path: strip_leading_slash(parsed.path()) + &suffix(&parsed), + }) +} + +/// Recognize `localhost[:port]` URLs, with or without an explicit scheme. +fn classify_localhost(input: &str) -> Option { + let with_scheme = if input.starts_with("localhost") { + format!("http://{input}") + } else { + input.to_string() + }; + + let parsed = Url::parse(&with_scheme).ok()?; + if parsed.host_str()? != "localhost" { + return None; + } + + let host = match parsed.port() { + Some(port) => format!("localhost:{port}"), + None => "localhost".to_string(), + }; + + Some(NavigateDecision::Localhost { + host, + path: strip_leading_slash(parsed.path()) + &suffix(&parsed), + }) +} + +/// External URL scheme allowlist. Anything outside this set is treated as +/// a [`NavigateDecision::Reject`] so dangerous schemes (`javascript:`, +/// `data:`, `file:`, `vbscript:`, ...) cannot reach `Platform::navigate_to`. +const ALLOWED_EXTERNAL_SCHEMES: &[&str] = &["http", "https", "mailto", "tel", "polkadot", "dot"]; + +/// Mirrors `normalizeUrl`: prepend `https://` if missing, otherwise pass the +/// URL through as its canonical string form. Returns `Err(reason)` for an +/// unparseable input or a scheme outside [`ALLOWED_EXTERNAL_SCHEMES`]. +fn normalize_external(input: &str) -> Result { + // `parse_with_explicit_https` returns a successful direct parse as-is and + // only prepends `https://` when the direct parse fails, so a disallowed + // scheme (e.g. `javascript:`) is never rewritten to https: the single + // scheme check below rejects it. + let url = parse_with_explicit_https(input) + .ok_or_else(|| "URL constructor rejected input".to_string())?; + if !ALLOWED_EXTERNAL_SCHEMES.contains(&url.scheme()) { + return Err(format!("scheme `{}` is not allowed", url.scheme())); + } + Ok(url.to_string()) +} + +fn strip_leading_slash(path: &str) -> String { + path.strip_prefix('/').unwrap_or(path).to_string() +} + +fn suffix(url: &Url) -> String { + let mut out = String::new(); + if let Some(q) = url.query() { + out.push('?'); + out.push_str(q); + } + if let Some(f) = url.fragment() { + out.push('#'); + out.push_str(f); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + enum Expected { + Decision(NavigateDecision), + AnyExternalOrReject, + Reject, + } + + struct TestCase { + name: &'static str, + input: &'static str, + expected: Expected, + } + + fn dot(identifier: &str, path: &str) -> Expected { + Expected::Decision(NavigateDecision::DotName { + identifier: identifier.to_string(), + path: path.to_string(), + }) + } + + fn localhost(host: &str, path: &str) -> Expected { + Expected::Decision(NavigateDecision::Localhost { + host: host.to_string(), + path: path.to_string(), + }) + } + + fn external(url: &str) -> Expected { + Expected::Decision(NavigateDecision::External { + url: url.to_string(), + }) + } + + #[test] + fn parse_navigate_cases() { + let cases = vec![ + TestCase { + name: "dot bare", + input: "mytestapp.dot", + expected: dot("mytestapp.dot", ""), + }, + TestCase { + name: "dot trailing root dot", + input: "example.dot.", + expected: dot("example.dot", ""), + }, + TestCase { + name: "dot trailing root dot with path", + input: "https://example.dot./path", + expected: dot("example.dot", "path"), + }, + TestCase { + name: "dot li is external", + input: "mytestapp.dot.li", + expected: external("https://mytestapp.dot.li/"), + }, + TestCase { + name: "dot with https", + input: "https://mytestapp.dot", + expected: dot("mytestapp.dot", ""), + }, + TestCase { + name: "dot with http", + input: "http://mytestapp.dot", + expected: dot("mytestapp.dot", ""), + }, + TestCase { + name: "dot with path", + input: "mytestapp.dot/some/path", + expected: dot("mytestapp.dot", "some/path"), + }, + TestCase { + name: "dot with query only", + input: "pr508.faucet.dot?embed=1", + expected: dot("pr508.faucet.dot", "?embed=1"), + }, + TestCase { + name: "dot with hash only", + input: "pr508.faucet.dot#section=main", + expected: dot("pr508.faucet.dot", "#section=main"), + }, + TestCase { + name: "dot with path query hash", + input: "pr508.faucet.dot/nested/path?embed=1#frame=compact", + expected: dot("pr508.faucet.dot", "nested/path?embed=1#frame=compact"), + }, + TestCase { + name: "polkadot scheme dot host", + input: "polkadot://currenthost.dot/mytestapp.dot", + expected: dot("currenthost.dot", "mytestapp.dot"), + }, + TestCase { + name: "polkadot scheme non dot host falls through", + input: "polkadot://example.com/settings", + expected: Expected::AnyExternalOrReject, + }, + TestCase { + name: "polkadot scheme with path", + input: "polkadot://currenthost.dot/mytestapp.dot/settings", + expected: dot("currenthost.dot", "mytestapp.dot/settings"), + }, + TestCase { + name: "polkadot scheme with query and hash", + input: "polkadot://currenthost.dot/mytestapp.dot?embed=1#frame=compact", + expected: dot("currenthost.dot", "mytestapp.dot?embed=1#frame=compact"), + }, + TestCase { + name: "dot subdomain", + input: "sub.acme.dot/path", + expected: dot("sub.acme.dot", "path"), + }, + TestCase { + name: "dot mixed case", + input: "Example.DOT/Path", + expected: dot("example.dot", "Path"), + }, + TestCase { + name: "dot with port is rejected", + input: "https://x.dot:8080/path", + expected: Expected::Reject, + }, + TestCase { + name: "dot with userinfo is rejected", + input: "https://user:pass@x.dot/path", + expected: Expected::Reject, + }, + TestCase { + name: "trim whitespace", + input: " mytestapp.dot/path ", + expected: dot("mytestapp.dot", "path"), + }, + TestCase { + name: "localhost bare with port", + input: "localhost:3000", + expected: localhost("localhost:3000", ""), + }, + TestCase { + name: "localhost with port and path", + input: "localhost:3000/some/path", + expected: localhost("localhost:3000", "some/path"), + }, + TestCase { + name: "localhost with explicit http", + input: "http://localhost:5000", + expected: localhost("localhost:5000", ""), + }, + TestCase { + name: "localhost with http and path", + input: "http://localhost:5000/path", + expected: localhost("localhost:5000", "path"), + }, + TestCase { + name: "localhost with query and hash", + input: "localhost:3000/path?q=1#h", + expected: localhost("localhost:3000", "path?q=1#h"), + }, + TestCase { + name: "localhost without port", + input: "localhost", + expected: localhost("localhost", ""), + }, + TestCase { + name: "localhost without port with path", + input: "localhost/path", + expected: localhost("localhost", "path"), + }, + TestCase { + name: "external bare domain", + input: "google.com", + expected: external("https://google.com/"), + }, + TestCase { + name: "external bare domain with path", + input: "google.com/search?q=test", + expected: external("https://google.com/search?q=test"), + }, + TestCase { + name: "external preserves https", + input: "https://example.com/page", + expected: external("https://example.com/page"), + }, + TestCase { + name: "external preserves http", + input: "http://example.com/page", + expected: external("http://example.com/page"), + }, + TestCase { + name: "external dot li", + input: "acme.dot.li/path/1", + expected: external("https://acme.dot.li/path/1"), + }, + TestCase { + name: "reject empty", + input: "", + expected: Expected::Reject, + }, + TestCase { + name: "reject whitespace", + input: " ", + expected: Expected::Reject, + }, + TestCase { + name: "reject unparseable", + input: ":::invalid", + expected: Expected::Reject, + }, + TestCase { + name: "reject javascript URI", + input: "javascript:alert(1)", + expected: Expected::Reject, + }, + TestCase { + name: "reject file URI", + input: "file:///etc/passwd", + expected: Expected::Reject, + }, + TestCase { + name: "reject data URI", + input: "data:text/html,", + expected: Expected::Reject, + }, + TestCase { + name: "reject vbscript URI", + input: "vbscript:msgbox(1)", + expected: Expected::Reject, + }, + ]; + + for case in cases { + let actual = parse_navigate(case.input); + match case.expected { + Expected::Decision(expected) => assert_eq!(actual, expected, "{}", case.name), + Expected::AnyExternalOrReject => assert!( + matches!( + actual, + NavigateDecision::External { .. } | NavigateDecision::Reject { .. } + ), + "{}: expected External or Reject, got {actual:?}", + case.name, + ), + Expected::Reject => assert!( + matches!(actual, NavigateDecision::Reject { .. }), + "{}: expected Reject, got {actual:?}", + case.name, + ), + } + } + + let nfc = parse_navigate("café.dot"); + let nfd = parse_navigate("cafe\u{0301}.dot"); + match (&nfc, &nfd) { + ( + NavigateDecision::DotName { + identifier: a, + path: _, + }, + NavigateDecision::DotName { + identifier: b, + path: _, + }, + ) => assert_eq!(a, b, "NFC and NFD inputs must normalize to one identifier"), + other => panic!("expected two DotName decisions, got {other:?}"), + } + } +} diff --git a/rust/crates/truapi-server/src/host_logic/entropy.rs b/rust/crates/truapi-server/src/host_logic/entropy.rs new file mode 100644 index 00000000..68e9e1fb --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/entropy.rs @@ -0,0 +1,113 @@ +//! Product-scoped deterministic entropy derivation. +//! +//! Matches dotli's product entropy contract: three keyed BLAKE2b-256 layers +//! over the session secret, product id, and caller key. + +use blake2_rfc::blake2b::blake2b; +use thiserror::Error; + +const DOMAIN_SEPARATOR: &[u8] = b"product-entropy-derivation"; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ProductEntropyError { + #[error("\"key\" must be between 1 and 32 bytes, got {0}")] + InvalidKeyLength(usize), + #[error("entropy secret is missing")] + MissingSecret, +} + +/// Derive product-scoped entropy from the session root entropy secret. +pub fn derive_product_entropy( + entropy_secret: &[u8], + 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-scoped entropy from an already normalized root entropy source. +pub fn derive_product_entropy_from_source( + root_entropy_source: &[u8; 32], + product_id: &str, + key: &[u8], +) -> Result<[u8; 32], ProductEntropyError> { + if key.is_empty() || key.len() > 32 { + return Err(ProductEntropyError::InvalidKeyLength(key.len())); + } + + let product_id_hash = blake2b256(product_id.as_bytes()); + let per_product_entropy = blake2b256_keyed(root_entropy_source, &product_id_hash); + Ok(blake2b256_keyed(&per_product_entropy, key)) +} + +fn blake2b256_keyed(message: &[u8], key: &[u8]) -> [u8; 32] { + let hash = blake2b(32, key, message); + hash.as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b256_keyed(message, &[]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn secret() -> [u8; 32] { + let mut secret = [0u8; 32]; + for (i, byte) in secret.iter_mut().enumerate() { + *byte = i as u8; + } + secret + } + + #[test] + fn product_entropy_cases() { + struct SuccessCase { + name: &'static str, + product_id: &'static str, + key: Vec, + expected_hex: &'static str, + } + + let success_cases = vec![ + SuccessCase { + name: "single byte key", + product_id: "myapp.dot", + key: vec![1], + expected_hex: "4bafd6a34182959bad8914dcff88c6b6842d551d6f0067afbd407e9584223404", + }, + SuccessCase { + name: "text key", + product_id: "myapp.dot", + key: b"product-key".to_vec(), + expected_hex: "ab1887248c9de3cf4b8c5a255782796d3d35a98c8eb2d7df61a410db8b14da36", + }, + SuccessCase { + name: "localhost product", + product_id: "localhost:3000", + key: (0..32).map(|i| 255 - i).collect(), + expected_hex: "437d0a6236c51fe114cf6a16b79c9c2b5f95b1e105e2d5269cc254a8c593925f", + }, + ]; + + for case in success_cases { + let entropy = derive_product_entropy(&secret(), case.product_id, &case.key).unwrap(); + assert_eq!(hex::encode(entropy), case.expected_hex, "{}", case.name); + } + + let error_cases = vec![ + (Vec::new(), ProductEntropyError::InvalidKeyLength(0)), + (vec![0u8; 33], ProductEntropyError::InvalidKeyLength(33)), + ]; + for (key, expected) in error_cases { + assert_eq!( + derive_product_entropy(&secret(), "myapp.dot", &key).unwrap_err(), + expected, + ); + } + } +} diff --git a/rust/crates/truapi-server/src/host_logic/features.rs b/rust/crates/truapi-server/src/host_logic/features.rs new file mode 100644 index 00000000..a71a106b --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/features.rs @@ -0,0 +1,66 @@ +//! Feature-detection delegation. +//! +//! `feature_supported` is a platform syscall: each host owns the set of +//! chains it can service. This module is a thin shim that forwards the +//! request through to [`truapi_platform::Features`]. + +use truapi::v01::{GenericError, HostFeatureSupportedRequest, HostFeatureSupportedResponse}; +use truapi_platform::Features; + +/// Forward a feature-support query to the platform implementation. +pub async fn feature_supported( + platform: &P, + request: HostFeatureSupportedRequest, +) -> Result { + platform.feature_supported(request).await +} + +#[cfg(test)] +mod tests { + use super::*; + + struct AlwaysSupported; + + #[truapi_platform::async_trait] + impl Features for AlwaysSupported { + async fn feature_supported( + &self, + request: HostFeatureSupportedRequest, + ) -> Result { + assert!(matches!(request, HostFeatureSupportedRequest::Chain { .. })); + Ok(HostFeatureSupportedResponse { supported: true }) + } + } + + struct AlwaysUnsupported; + + #[truapi_platform::async_trait] + impl Features for AlwaysUnsupported { + async fn feature_supported( + &self, + request: HostFeatureSupportedRequest, + ) -> Result { + assert!(matches!(request, HostFeatureSupportedRequest::Chain { .. })); + Ok(HostFeatureSupportedResponse { supported: false }) + } + } + + fn req() -> HostFeatureSupportedRequest { + HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + } + } + + #[test] + fn delegates_supported_to_platform() { + let resp = futures::executor::block_on(feature_supported(&AlwaysSupported, req())).unwrap(); + assert!(resp.supported); + } + + #[test] + fn delegates_unsupported_to_platform() { + let resp = + futures::executor::block_on(feature_supported(&AlwaysUnsupported, req())).unwrap(); + assert!(!resp.supported); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/identity.rs b/rust/crates/truapi-server/src/host_logic/identity.rs new file mode 100644 index 00000000..0fbac76f --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/identity.rs @@ -0,0 +1,123 @@ +//! People-chain identity lookup for paired SSO sessions. +//! +//! dotli's previous host-papp path read `Resources.Consumers[account]` from +//! the People chain and used only the username fields. Keep this module narrow: +//! it builds that storage key and decodes the leading username fields from the +//! SCALE value. The record begins with a fixed identifier public key; credibility +//! and statement-store slots are intentionally ignored. + +use parity_scale_codec::Decode; +use sp_crypto_hashing::{blake2_128, twox_128}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PeopleIdentity { + pub lite_username: Option, + pub full_username: Option, +} + +#[derive(Debug, Decode)] +struct ConsumerUsernamePrefix { + full_username: Option>, + lite_username: Vec, +} + +/// Build the People-chain `Resources.Consumers` storage key for `account_id`. +pub fn resources_consumers_storage_key(account_id: &[u8; 32]) -> Vec { + let mut key = Vec::with_capacity(32 + 16 + account_id.len()); + key.extend_from_slice(&twox_128(b"Resources")); + key.extend_from_slice(&twox_128(b"Consumers")); + key.extend_from_slice(&blake2_128(account_id)); + key.extend_from_slice(account_id); + key +} + +/// Decode the username fields from a `Resources.Consumers` storage value. +pub fn decode_people_identity(value: &[u8]) -> Result { + if value.len() < 65 { + return Err(format!( + "invalid Resources.Consumers record: expected 65-byte identifier key, got {} bytes", + value.len() + )); + } + + // ConsumerInfo starts with a fixed 65-byte P-256 identifier key. The + // username fields follow immediately after it. + let mut input = &value[65..]; + let decoded = ConsumerUsernamePrefix::decode(&mut input) + .map_err(|err| format!("invalid Resources.Consumers record: {err}"))?; + let lite_username = non_empty_string(decoded.lite_username)?; + let full_username = decoded + .full_username + .map(non_empty_string) + .transpose()? + .flatten(); + Ok(PeopleIdentity { + lite_username, + full_username, + }) +} + +fn non_empty_string(bytes: Vec) -> Result, String> { + if bytes.is_empty() { + return Ok(None); + } + let value = String::from_utf8(bytes) + .map_err(|err| format!("Resources.Consumers username is not UTF-8: {err}"))?; + Ok(Some(value)) +} + +#[cfg(test)] +mod tests { + use super::*; + use parity_scale_codec::Encode; + + #[test] + fn resources_consumers_key_uses_expected_prefix() { + let key = resources_consumers_storage_key(&[0x42; 32]); + + assert_eq!(key.len(), 80); + assert_eq!(&key[..16], &twox_128(b"Resources")); + assert_eq!(&key[16..32], &twox_128(b"Consumers")); + assert_eq!(&key[48..], &[0x42; 32]); + } + + #[test] + fn twox128_matches_substrate_storage_prefix_vector() { + assert_eq!( + hex::encode(twox_128(b"System")), + "26aa394eea5630e07c48ae0c9558cef7" + ); + } + + #[test] + fn decodes_username_prefix_and_ignores_trailing_fields() { + let mut value = vec![0x04; 65]; + value.extend((Some(b"Alice Smith".to_vec()), b"alice.01".to_vec()).encode()); + value.extend_from_slice(&[0xff; 8]); + + let decoded = decode_people_identity(&value).expect("identity should decode"); + + assert_eq!(decoded.full_username.as_deref(), Some("Alice Smith")); + assert_eq!(decoded.lite_username.as_deref(), Some("alice.01")); + } + + #[test] + fn empty_full_username_is_none() { + let mut value = vec![0x04; 65]; + value.extend((Some(Vec::::new()), b"alice.01".to_vec()).encode()); + + let decoded = decode_people_identity(&value).expect("identity should decode"); + + assert_eq!(decoded.full_username, None); + assert_eq!(decoded.lite_username.as_deref(), Some("alice.01")); + } + + #[test] + fn rejects_missing_identifier_key() { + let value = (None::>, b"alice.01".to_vec()).encode(); + + let error = decode_people_identity(&value).expect_err("identity should reject"); + + assert!(error.contains("65-byte identifier key")); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs new file mode 100644 index 00000000..b1189689 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -0,0 +1,706 @@ +//! Permission authorization state machine (ask -> authorized | denied), backed +//! by the platform [`CoreStorage`] trait with typed [`CoreStorageKey`] slots. +//! +//! Device permissions (camera, mic, NFC, ...) are separate from remote +//! permissions (domain access, chain submit, ...), so this module exposes two +//! `check_or_prompt` entrypoints that route to the matching platform callback. +//! The cache layer is shared but keys are typed so a device grant cannot +//! authorize a remote operation by accident. Keys are also scoped by product id +//! so one product's authorization never grants another product's request. + +use parity_scale_codec::{Decode, Encode}; + +use truapi::latest::{ + GenericError, HostDevicePermissionRequest, HostDevicePermissionResponse, RemotePermission, + RemotePermissionRequest, RemotePermissionResponse, +}; +use truapi_platform::{ + CoreStorage, CoreStorageKey, PermissionAuthorizationRequest, PermissionAuthorizationStatus, + Permissions, +}; + +/// Persisted answer for a single permission request. Keep `Authorized` at +/// discriminant 0 and `Denied` at 1 to preserve the existing two-variant cache +/// encoding. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +enum StoredAuthorizationStatus { + /// User authorized the permission. + Authorized, + /// User denied the permission. + Denied, +} + +impl From for PermissionAuthorizationStatus { + fn from(status: StoredAuthorizationStatus) -> Self { + match status { + StoredAuthorizationStatus::Authorized => PermissionAuthorizationStatus::Authorized, + StoredAuthorizationStatus::Denied => PermissionAuthorizationStatus::Denied, + } + } +} + +impl From for StoredAuthorizationStatus { + fn from(granted: bool) -> Self { + if granted { + Self::Authorized + } else { + Self::Denied + } + } +} + +/// Coordinator that inspects persisted state first, falls back to the +/// platform's prompt callback, and writes the authorization back so future +/// calls short-circuit. +pub struct PermissionsService<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> { + storage: &'a S, + prompt: &'a P, + product_id: &'a str, +} + +impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a, S, P> { + /// Construct a service backed by the given storage + prompt callbacks. + pub fn new(storage: &'a S, prompt: &'a P, product_id: &'a str) -> Self { + Self { + storage, + prompt, + product_id, + } + } + + /// Returns the stored authorization status for a device permission without prompting. + pub async fn peek_device( + &self, + permission: &HostDevicePermissionRequest, + ) -> Result { + authorization_status( + self.storage, + device_core_storage_key(self.product_id, permission), + ) + .await + } + + /// Returns the stored authorization status for a remote permission without + /// prompting. + pub async fn peek_remote( + &self, + request: &RemotePermissionRequest, + ) -> Result { + authorization_status( + self.storage, + remote_core_storage_key(self.product_id, request), + ) + .await + } + + /// Returns the stored authorization status for a permission request + /// without prompting. + pub async fn authorization_status( + &self, + request: &PermissionAuthorizationRequest, + ) -> Result { + match request { + PermissionAuthorizationRequest::Device(permission) => { + self.peek_device(permission).await + } + PermissionAuthorizationRequest::Remote(request) => self.peek_remote(request).await, + } + } + + /// Returns the stored authorization statuses for permission requests + /// without prompting. Results follow the same order as `requests`. + pub async fn authorization_statuses( + &self, + requests: &[PermissionAuthorizationRequest], + ) -> Result, GenericError> { + let mut statuses = Vec::with_capacity(requests.len()); + for request in requests { + statuses.push(self.authorization_status(request).await?); + } + Ok(statuses) + } + + /// Update the stored authorization status for a permission request. + /// + /// Setting `NotDetermined` clears the stored value so the next product + /// request prompts again. + pub async fn set_authorization_status( + &self, + request: &PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), GenericError> { + let key = match request { + PermissionAuthorizationRequest::Device(permission) => { + device_core_storage_key(self.product_id, permission) + } + PermissionAuthorizationRequest::Remote(request) => { + remote_core_storage_key(self.product_id, request) + } + }; + set_authorization_status(self.storage, key, status).await + } + + /// Returns the cached device authorization if any, otherwise prompts the + /// platform's `device_permission` callback and persists the answer. + pub async fn check_or_prompt_device( + &self, + permission: HostDevicePermissionRequest, + ) -> Result { + let key = device_core_storage_key(self.product_id, &permission); + if let Some(cached) = peek_stored(self.storage, key.clone()).await? { + return Ok(cached.into()); + } + // Only a genuine user authorization is persisted. A prompt-callback error is + // transient (UI unavailable, IPC timeout), not a denial, so fail closed + // for this call but do not cache it — the next request re-prompts rather + // than locking the capability out permanently with no revoke path. + let authorization = match self.prompt.device_permission(permission).await { + Ok(HostDevicePermissionResponse { granted }) => granted.into(), + Err(_) => return Ok(PermissionAuthorizationStatus::Denied), + }; + self.persist_decision(key, authorization).await + } + + /// Returns the cached remote authorization if any, otherwise prompts the + /// platform's `remote_permission` callback and persists the answer. + pub async fn check_or_prompt_remote( + &self, + request: RemotePermissionRequest, + ) -> Result { + let key = remote_core_storage_key(self.product_id, &request); + if let Some(cached) = peek_stored(self.storage, key.clone()).await? { + return Ok(cached.into()); + } + // See `check_or_prompt_device`: persist only a genuine user decision; a + // transient callback error fails closed for this call without caching. + let authorization = match self.prompt.remote_permission(request).await { + Ok(RemotePermissionResponse { granted }) => granted.into(), + Err(_) => return Ok(PermissionAuthorizationStatus::Denied), + }; + self.persist_decision(key, authorization).await + } + + /// Persist a fresh user decision and return its public status. + async fn persist_decision( + &self, + key: CoreStorageKey, + authorization: StoredAuthorizationStatus, + ) -> Result { + self.storage + .write_core_storage(key, authorization.encode()) + .await?; + Ok(authorization.into()) + } +} + +async fn authorization_status( + storage: &S, + key: CoreStorageKey, +) -> Result { + Ok(peek_stored(storage, key) + .await? + .map(Into::into) + .unwrap_or(PermissionAuthorizationStatus::NotDetermined)) +} + +async fn peek_stored( + storage: &S, + key: CoreStorageKey, +) -> Result, GenericError> { + let Some(raw) = storage.read_core_storage(key).await? else { + return Ok(None); + }; + Ok(StoredAuthorizationStatus::decode(&mut &*raw).ok()) +} + +async fn set_authorization_status( + storage: &S, + key: CoreStorageKey, + status: PermissionAuthorizationStatus, +) -> Result<(), GenericError> { + match status_into_stored(status) { + Some(stored) => storage.write_core_storage(key, stored.encode()).await, + None => storage.clear_core_storage(key).await, + } +} + +fn status_into_stored(status: PermissionAuthorizationStatus) -> Option { + match status { + PermissionAuthorizationStatus::NotDetermined => None, + PermissionAuthorizationStatus::Denied => Some(StoredAuthorizationStatus::Denied), + PermissionAuthorizationStatus::Authorized => Some(StoredAuthorizationStatus::Authorized), + } +} + +fn device_core_storage_key( + product_id: &str, + permission: &HostDevicePermissionRequest, +) -> CoreStorageKey { + CoreStorageKey::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::Device(*permission), + } +} + +fn remote_core_storage_key(product_id: &str, request: &RemotePermissionRequest) -> CoreStorageKey { + CoreStorageKey::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::Remote(canonical_remote_request(request)), + } +} + +fn canonical_remote_request(request: &RemotePermissionRequest) -> RemotePermissionRequest { + let permission = match &request.permission { + RemotePermission::Remote { domains } => { + // DNS domains are case-insensitive, so a logically-identical bundle + // requested with different casing or duplicate entries must + // canonicalize to one key (no spurious re-prompt). + let mut canonical: Vec = + domains.iter().map(|d| d.to_ascii_lowercase()).collect(); + canonical.sort(); + canonical.dedup(); + RemotePermission::Remote { domains: canonical } + } + other => other.clone(), + }; + RemotePermissionRequest { permission } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::lock::Mutex; + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + use truapi::v01; + use truapi::v01::GenericError; + + #[derive(Default)] + struct MemStorage { + inner: Mutex>>, + } + + #[truapi_platform::async_trait] + impl CoreStorage for MemStorage { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + Ok(self.inner.lock().await.get(&test_key(key)).cloned()) + } + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + self.inner.lock().await.insert(test_key(key), value); + Ok(()) + } + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), v01::GenericError> { + self.inner.lock().await.remove(&test_key(key)); + Ok(()) + } + } + + fn test_key(key: CoreStorageKey) -> String { + hex::encode(key.encode()) + } + + struct ScriptedPrompt { + device_answers: Mutex>, + remote_answers: Mutex>, + device_calls: AtomicUsize, + remote_calls: AtomicUsize, + } + + impl ScriptedPrompt { + fn new(device_answers: Vec, remote_answers: Vec) -> Self { + Self { + device_answers: Mutex::new(device_answers), + remote_answers: Mutex::new(remote_answers), + device_calls: AtomicUsize::new(0), + remote_calls: AtomicUsize::new(0), + } + } + } + + #[truapi_platform::async_trait] + impl Permissions for ScriptedPrompt { + async fn device_permission( + &self, + _request: HostDevicePermissionRequest, + ) -> Result { + self.device_calls.fetch_add(1, Ordering::SeqCst); + let granted = self + .device_answers + .lock() + .await + .pop() + .expect("ScriptedPrompt ran out of device answers"); + Ok(v01::HostDevicePermissionResponse { granted }) + } + + async fn remote_permission( + &self, + _request: RemotePermissionRequest, + ) -> Result { + self.remote_calls.fetch_add(1, Ordering::SeqCst); + let granted = self + .remote_answers + .lock() + .await + .pop() + .expect("ScriptedPrompt ran out of remote answers"); + Ok(v01::RemotePermissionResponse { granted }) + } + } + + #[test] + fn core_storage_key_separates_product_device_and_remote_variants() { + let camera = device_core_storage_key("product.dot", &HostDevicePermissionRequest::Camera); + let other_product = + device_core_storage_key("other.dot", &HostDevicePermissionRequest::Camera); + let remote = remote_core_storage_key( + "product.dot", + &RemotePermissionRequest { + permission: RemotePermission::ChainSubmit, + }, + ); + + assert_ne!(camera, other_product); + assert_ne!(camera, remote); + } + + #[test] + fn remote_core_storage_key_canonicalizes_domain_sets() { + let unsorted = RemotePermissionRequest { + permission: RemotePermission::Remote { + domains: vec!["b.example.com".into(), "a.example.com".into()], + }, + }; + let sorted = RemotePermissionRequest { + permission: RemotePermission::Remote { + domains: vec!["a.example.com".into(), "b.example.com".into()], + }, + }; + assert_eq!( + remote_core_storage_key("product.dot", &unsorted), + remote_core_storage_key("product.dot", &sorted) + ); + + let mixed = RemotePermissionRequest { + permission: RemotePermission::Remote { + domains: vec!["Example.COM".into(), "a.com".into(), "a.com".into()], + }, + }; + let canonical = RemotePermissionRequest { + permission: RemotePermission::Remote { + domains: vec!["a.com".into(), "example.com".into()], + }, + }; + assert_eq!( + remote_core_storage_key("product.dot", &mixed), + remote_core_storage_key("product.dot", &canonical) + ); + } + + #[test] + fn remote_core_storage_key_handles_separator_chars_in_domains() { + // Domain strings containing separator-looking text must not be able to + // forge a key that matches an unrelated permission. + let injecting = RemotePermissionRequest { + permission: RemotePermission::Remote { + domains: vec!["a|b".into(), "c,d".into(), "remote:web-rtc".into()], + }, + }; + let benign_same_set = RemotePermissionRequest { + permission: RemotePermission::Remote { + domains: vec!["x".into(), "y".into(), "z".into()], + }, + }; + let injecting_key = remote_core_storage_key("product.dot", &injecting); + let benign_key = remote_core_storage_key("product.dot", &benign_same_set); + assert_ne!(injecting_key, benign_key); + + // The injecting permission must also be distinct from the `WebRtc` + // variant it tries to impersonate via crafted strings. + let webrtc = RemotePermissionRequest { + permission: RemotePermission::WebRtc, + }; + assert_ne!( + injecting_key, + remote_core_storage_key("product.dot", &webrtc) + ); + + // Re-ordering the same domains still collapses to a single key + // (canonicalization is order-independent). + let injecting_reordered = RemotePermissionRequest { + permission: RemotePermission::Remote { + domains: vec!["remote:web-rtc".into(), "c,d".into(), "a|b".into()], + }, + }; + assert_eq!( + injecting_key, + remote_core_storage_key("product.dot", &injecting_reordered) + ); + } + + #[test] + fn check_or_prompt_device_caches_grant() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![true], vec![]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let first = futures::executor::block_on( + service.check_or_prompt_device(HostDevicePermissionRequest::Camera), + ) + .unwrap(); + let second = futures::executor::block_on( + service.check_or_prompt_device(HostDevicePermissionRequest::Camera), + ) + .unwrap(); + + assert_eq!(first, PermissionAuthorizationStatus::Authorized); + assert_eq!(second, PermissionAuthorizationStatus::Authorized); + assert_eq!(prompt.device_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn check_or_prompt_remote_caches_denial() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![false]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let request = RemotePermissionRequest { + permission: RemotePermission::ChainSubmit, + }; + let first = + futures::executor::block_on(service.check_or_prompt_remote(request.clone())).unwrap(); + let second = futures::executor::block_on(service.check_or_prompt_remote(request)).unwrap(); + + assert_eq!(first, PermissionAuthorizationStatus::Denied); + assert_eq!(second, PermissionAuthorizationStatus::Denied); + assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn device_and_remote_caches_are_independent() { + let storage = MemStorage::default(); + // Device denies, remote grants. If the caches collided we'd see the + // same answer on the second call. + let prompt = ScriptedPrompt::new(vec![false], vec![true]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let device = futures::executor::block_on( + service.check_or_prompt_device(HostDevicePermissionRequest::Camera), + ) + .unwrap(); + let remote = + futures::executor::block_on(service.check_or_prompt_remote(RemotePermissionRequest { + permission: RemotePermission::ChainSubmit, + })) + .unwrap(); + + assert_eq!(device, PermissionAuthorizationStatus::Denied); + assert_eq!(remote, PermissionAuthorizationStatus::Authorized); + assert_eq!(prompt.device_calls.load(Ordering::SeqCst), 1); + assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn device_prompt_does_not_invoke_remote_callback() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![true], vec![]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let _ = futures::executor::block_on( + service.check_or_prompt_device(HostDevicePermissionRequest::Camera), + ) + .unwrap(); + assert_eq!(prompt.device_calls.load(Ordering::SeqCst), 1); + assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn remote_prompt_does_not_invoke_device_callback() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![true]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let _ = + futures::executor::block_on(service.check_or_prompt_remote(RemotePermissionRequest { + permission: RemotePermission::WebRtc, + })) + .unwrap(); + assert_eq!(prompt.device_calls.load(Ordering::SeqCst), 0); + assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn peek_returns_not_determined_until_authorized() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![true], vec![]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let before = + futures::executor::block_on(service.peek_device(&HostDevicePermissionRequest::Camera)) + .unwrap(); + assert_eq!(before, PermissionAuthorizationStatus::NotDetermined); + + futures::executor::block_on( + service.check_or_prompt_device(HostDevicePermissionRequest::Camera), + ) + .unwrap(); + + let after = + futures::executor::block_on(service.peek_device(&HostDevicePermissionRequest::Camera)) + .unwrap(); + assert_eq!(after, PermissionAuthorizationStatus::Authorized); + } + + #[test] + fn set_authorization_status_writes_and_clears() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + let request = PermissionAuthorizationRequest::Device(HostDevicePermissionRequest::Camera); + + futures::executor::block_on( + service.set_authorization_status(&request, PermissionAuthorizationStatus::Authorized), + ) + .unwrap(); + assert_eq!( + futures::executor::block_on(service.authorization_status(&request)).unwrap(), + PermissionAuthorizationStatus::Authorized + ); + + futures::executor::block_on( + service + .set_authorization_status(&request, PermissionAuthorizationStatus::NotDetermined), + ) + .unwrap(); + assert_eq!( + futures::executor::block_on(service.authorization_status(&request)).unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + } + + /// Prompt callback that always errors, to exercise the transient-failure + /// path (fail closed for the current call, but do not persist the error). + struct FailingPrompt; + + #[truapi_platform::async_trait] + impl Permissions for FailingPrompt { + async fn device_permission( + &self, + _request: HostDevicePermissionRequest, + ) -> Result { + Err(GenericError { + reason: "boom".into(), + }) + } + + async fn remote_permission( + &self, + _request: RemotePermissionRequest, + ) -> Result { + Err(GenericError { + reason: "boom".into(), + }) + } + } + + #[test] + fn prompt_failure_denies_without_persisting() { + let storage = MemStorage::default(); + let prompt = FailingPrompt; + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let decision = futures::executor::block_on( + service.check_or_prompt_device(HostDevicePermissionRequest::Camera), + ) + .unwrap(); + assert_eq!(decision, PermissionAuthorizationStatus::Denied); + + // A transient callback error is fail-closed for this call but NOT + // cached, so peek still sees no authorization and the next request + // re-prompts rather than permanently locking out the capability. + let cached = + futures::executor::block_on(service.peek_device(&HostDevicePermissionRequest::Camera)) + .unwrap(); + assert_eq!( + cached, + PermissionAuthorizationStatus::NotDetermined, + "a transient prompt error must not be persisted" + ); + } + + /// A corrupt SCALE-encoded cache entry must be treated as "no cache", + /// not panic. The service falls back to prompting. + #[test] + fn corrupt_cache_entry_returns_none() { + let storage = MemStorage::default(); + // Write garbage bytes under the canonical key. + futures::executor::block_on(storage.write_core_storage( + device_core_storage_key("product.dot", &HostDevicePermissionRequest::Camera), + vec![0xff, 0xfe, 0xfd], + )) + .unwrap(); + + let prompt = ScriptedPrompt::new(vec![true], vec![]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let peeked = + futures::executor::block_on(service.peek_device(&HostDevicePermissionRequest::Camera)) + .unwrap(); + assert_eq!( + peeked, + PermissionAuthorizationStatus::NotDetermined, + "corrupt entry must decode as absent" + ); + } + + /// Storage failures must propagate to the caller; the service must not + /// swallow them by silently returning a default authorization. + #[derive(Default)] + struct FailingStorage; + + #[truapi_platform::async_trait] + impl CoreStorage for FailingStorage { + async fn read_core_storage( + &self, + _key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + Err(v01::GenericError { + reason: "read failed".into(), + }) + } + async fn write_core_storage( + &self, + _key: CoreStorageKey, + _value: Vec, + ) -> Result<(), v01::GenericError> { + Err(v01::GenericError { + reason: "write failed".into(), + }) + } + async fn clear_core_storage(&self, _key: CoreStorageKey) -> Result<(), v01::GenericError> { + Err(v01::GenericError { + reason: "clear failed".into(), + }) + } + } + + #[test] + fn storage_read_error_propagates() { + let storage = FailingStorage; + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let err = futures::executor::block_on( + service.check_or_prompt_device(HostDevicePermissionRequest::Camera), + ) + .expect_err("read failure must surface"); + assert!(matches!(err, v01::GenericError { .. })); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/product_account.rs b/rust/crates/truapi-server/src/host_logic/product_account.rs new file mode 100644 index 00000000..c4b16ec0 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -0,0 +1,175 @@ +//! Product account derivation shared by all hosts. +//! +//! Mirrors dotli's `packages/auth/src/account.ts`: derive an sr25519 public +//! key through soft HDKD junctions `["product", product_id, derivation_index]`. + +use blake2_rfc::blake2b::blake2b; +use parity_scale_codec::Encode; +use schnorrkel::PublicKey; +use schnorrkel::derive::{ChainCode, Derivation}; +use thiserror::Error; +use unicode_normalization::UnicodeNormalization; + +const JUNCTION_ID_LEN: usize = 32; +const PRODUCT_JUNCTION: &str = "product"; +const SS58_PREFIX: &[u8] = b"SS58PRE"; +const SUBSTRATE_GENERIC_SS58_PREFIX: u8 = 42; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ProductAccountError { + #[error("invalid sr25519 root public key")] + InvalidRootPublicKey, + #[error("numeric derivation junction is outside u64 range")] + NumericJunctionOutOfRange, +} + +/// Whether `identifier` is a product scope the core is allowed to derive for. +pub fn is_product_identifier(identifier: &str) -> bool { + let normalized = normalize_product_identifier(identifier); + normalized.ends_with(".dot") + || normalized == "localhost" + || normalized.starts_with("localhost:") +} + +/// Normalize product identifiers before derivation and policy checks. +pub fn normalize_product_identifier(identifier: &str) -> String { + identifier.nfc().collect::().to_lowercase() +} + +/// Derive a product account public key from the paired root public key. +pub fn derive_product_public_key( + root_public_key: [u8; 32], + product_id: &str, + derivation_index: u32, +) -> Result<[u8; 32], ProductAccountError> { + let mut public_key = PublicKey::from_bytes(&root_public_key) + .map_err(|_| ProductAccountError::InvalidRootPublicKey)?; + + for junction in [ + PRODUCT_JUNCTION.to_string(), + product_id.to_string(), + derivation_index.to_string(), + ] { + let chain_code = ChainCode(create_chain_code(&junction)?); + let (derived, _) = public_key.derived_key_simple(chain_code, []); + public_key = derived; + } + + Ok(public_key.to_bytes()) +} + +/// Encode a product account public key as a generic Substrate SS58 address. +pub fn product_public_key_to_address(public_key: [u8; 32]) -> String { + let mut payload = Vec::with_capacity(35); + payload.push(SUBSTRATE_GENERIC_SS58_PREFIX); + payload.extend_from_slice(&public_key); + + let mut checksum_input = Vec::with_capacity(SS58_PREFIX.len() + payload.len()); + checksum_input.extend_from_slice(SS58_PREFIX); + checksum_input.extend_from_slice(&payload); + let checksum = blake2b(64, &[], &checksum_input); + payload.extend_from_slice(&checksum.as_bytes()[..2]); + + bs58::encode(payload).into_string() +} + +fn create_chain_code(code: &str) -> Result<[u8; 32], ProductAccountError> { + let encoded = if is_numeric_junction(code) { + code.parse::() + .map_err(|_| ProductAccountError::NumericJunctionOutOfRange)? + .encode() + } else { + code.encode() + }; + + let mut chain_code = [0u8; JUNCTION_ID_LEN]; + if encoded.len() > JUNCTION_ID_LEN { + let hash = blake2b(JUNCTION_ID_LEN, &[], &encoded); + chain_code.copy_from_slice(hash.as_bytes()); + } else { + chain_code[..encoded.len()].copy_from_slice(&encoded); + } + Ok(chain_code) +} + +fn is_numeric_junction(code: &str) -> bool { + !code.is_empty() && code.bytes().all(|byte| byte.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ROOT_PUBLIC_KEY: [u8; 32] = [ + 0x80, 0x05, 0x28, 0xc9, 0x55, 0x87, 0x3e, 0x4c, 0x78, 0xb7, 0xdf, 0x24, 0xf7, 0x1d, 0xb8, + 0xf5, 0x81, 0xaa, 0x99, 0xe3, 0x49, 0x3b, 0xf4, 0x96, 0xed, 0xf1, 0x51, 0xab, 0xc1, 0xd7, + 0x20, 0x23, + ]; + + #[test] + fn derives_dotli_product_account_vector() { + let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", 0).unwrap(); + assert_eq!( + hex::encode(derived), + "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + ); + } + + #[test] + fn derives_different_index_vector() { + let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", 1).unwrap(); + assert_eq!( + hex::encode(derived), + "ec8a80808b46e44c1351b68e295eb975c55bda4855e5ea9fc1325be7296a2a4e" + ); + } + + #[test] + fn derives_long_product_id_vector() { + let derived = derive_product_public_key( + ROOT_PUBLIC_KEY, + "w-credentialless-staticblitz-com.local-credentialless.webcontainer-api.io", + 0, + ) + .unwrap(); + assert_eq!( + hex::encode(derived), + "56769a234038defb62a7ad42f251091cc24846c2473a31b5bdd17d366c38c211" + ); + } + + #[test] + fn ss58_address_matches_dotli_vector() { + let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", 0).unwrap(); + assert_eq!( + product_public_key_to_address(derived), + "5CyFsdhwjXy7wWpDEM6isungQ3LfGnu9UXkt7paBQ6DYRxk1" + ); + } + + #[test] + fn accepts_dot_and_localhost_product_identifiers() { + assert!(is_product_identifier("Example.DOT")); + assert!(is_product_identifier("localhost")); + assert!(is_product_identifier("localhost:3000")); + assert!(!is_product_identifier("example.com")); + } + + #[test] + fn chain_code_matches_dotli_encoding_rules() { + let product = create_chain_code("product").unwrap(); + assert_eq!( + &product[..8], + &[0x1c, b'p', b'r', b'o', b'd', b'u', b'c', b't'] + ); + + let zero = create_chain_code("0").unwrap(); + assert_eq!(&zero[..8], &[0; 8]); + + let long = create_chain_code( + "w-credentialless-staticblitz-com.local-credentialless.webcontainer-api.io", + ) + .unwrap(); + assert_ne!(&long[..8], &[0; 8]); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/session.rs b/rust/crates/truapi-server/src/host_logic/session.rs new file mode 100644 index 00000000..e2b1eeb3 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/session.rs @@ -0,0 +1,416 @@ +//! Core-owned active-session state. Platform entrypoints notify the core when +//! pairing or unpairing changes the session, and account-management methods +//! read this state instead of round-tripping a host callback on every product +//! call. + +use futures::channel::mpsc; +use futures::stream::{self, BoxStream, StreamExt}; +use parity_scale_codec::{Decode, Encode}; +use std::sync::{Arc, Mutex}; + +use truapi::v01::HostAccountConnectionStatusSubscribeItem; +use truapi::versioned::account::HostAccountConnectionStatusSubscribeItem as VersionedItem; + +/// Session info pushed by the host. The 32-byte sr25519 public key plus +/// optional usernames sourced from the People-Chain identity record. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SessionInfo { + /// 32-byte sr25519 root public key of the paired session. + pub public_key: [u8; 32], + /// Core-owned SSO channel state. Core-run pairing fills this; unavailable + /// sessions restored from older test fixtures may leave it empty. + pub sso: Option, + /// Wallet-provided source for deterministic product entropy. + pub root_entropy_source: Option<[u8; 32]>, + /// Wallet identity account id used for People-chain username lookup. + pub identity_account_id: Option<[u8; 32]>, + /// Short username (e.g. `alice`). + pub lite_username: Option, + /// Fully qualified username (e.g. `Alice Smith`). + pub full_username: Option, +} + +/// Core-owned SSO session material negotiated with the wallet during pairing. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SsoSessionInfo { + /// Core's own 64-byte expanded sr25519 statement-store secret. + pub ss_secret: [u8; 64], + /// Core's own session sr25519 statement-store public key. + pub ss_public_key: [u8; 32], + /// Core's P-256 ECDH private key. + pub enc_secret: [u8; 32], + /// Wallet persistent P-256 public key. + pub peer_enc_pubkey: [u8; 65], + /// Wallet identity sr25519 account id. + pub identity_account_id: [u8; 32], + /// Core -> wallet topic id. + pub session_id_own: [u8; 32], + /// Wallet -> core topic id. + pub session_id_peer: [u8; 32], + /// Statement channel for core requests. + pub request_channel: [u8; 32], + /// Statement channel for wallet responses to core requests. + pub response_channel: [u8; 32], + /// Statement channel for wallet-initiated requests. + pub peer_request_channel: [u8; 32], +} + +const PERSISTED_SESSION_VERSION: u8 = 3; + +/// Encode the active-session fields the core currently understands into an +/// opaque host-global session blob. Later SSO channel state should bump +/// `PERSISTED_SESSION_VERSION` instead of extending this layout silently. +pub fn encode_persisted_session(info: &SessionInfo) -> Vec { + (PERSISTED_SESSION_VERSION, info).encode() +} + +/// Decode a core-owned persisted session blob. +pub fn decode_persisted_session(blob: &[u8]) -> Result { + let mut input = blob; + let version = u8::decode(&mut input).map_err(|err| format!("invalid session blob: {err}"))?; + let info = match version { + PERSISTED_SESSION_VERSION => { + SessionInfo::decode(&mut input).map_err(|err| format!("invalid session blob: {err}"))? + } + _ => return Err(format!("unsupported session blob version {version}")), + }; + if !input.is_empty() { + return Err("invalid session blob: trailing bytes".to_string()); + } + Ok(info) +} + +/// Holds the currently-active session and broadcasts connection-status +/// transitions to subscribers. Cheap to clone via `Arc`. +#[derive(Default)] +pub struct SessionState { + inner: Mutex, +} + +#[derive(Default)] +struct Inner { + current: Option, + subscribers: Vec>, +} + +impl SessionState { + /// Construct a fresh session holder, starting in the `Disconnected` state. + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Replace the active session with `info`. Emits a `Connected` event to + /// every live subscriber if this is a transition from no-session or an + /// actual session replacement. + pub fn set_session(&self, info: SessionInfo) { + let mut inner = self.inner.lock().expect("session-state mutex poisoned"); + let should_broadcast = inner.current.as_ref() != Some(&info); + inner.current = Some(info); + if should_broadcast { + broadcast( + &mut inner.subscribers, + HostAccountConnectionStatusSubscribeItem::Connected, + ); + } + } + + /// Drop the active session. Emits a `Disconnected` event to every live + /// subscriber if there was a session to clear. + pub fn clear_session(&self) { + let mut inner = self.inner.lock().expect("session-state mutex poisoned"); + if inner.current.take().is_some() { + broadcast( + &mut inner.subscribers, + HostAccountConnectionStatusSubscribeItem::Disconnected, + ); + } + } + + /// Snapshot of the current session, or `None` when nothing is paired. + pub fn current(&self) -> Option { + self.inner + .lock() + .expect("session-state mutex poisoned") + .current + .clone() + } + + /// Stream of connection-status events. The first item emitted is the + /// current state (so subscribers don't have to read it separately); + /// subsequent items reflect every `set_session` / `clear_session` + /// transition. + pub fn subscribe(&self) -> BoxStream<'static, VersionedItem> { + let (tx, rx) = mpsc::unbounded(); + let mut inner = self.inner.lock().expect("session-state mutex poisoned"); + let initial = match inner.current { + Some(_) => HostAccountConnectionStatusSubscribeItem::Connected, + None => HostAccountConnectionStatusSubscribeItem::Disconnected, + }; + inner.subscribers.push(tx); + let initial_item = VersionedItem::V1(initial); + Box::pin(stream::once(async move { initial_item }).chain(rx)) + } +} + +fn broadcast( + subscribers: &mut Vec>, + status: HostAccountConnectionStatusSubscribeItem, +) { + let item = VersionedItem::V1(status); + // `retain` drops senders whose receiver has been dropped, so the + // subscriber list self-prunes on the next broadcast after a reader + // unsubscribes. + subscribers.retain(|tx| tx.unbounded_send(item.clone()).is_ok()); +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::executor::block_on; + use futures::{FutureExt, StreamExt}; + + fn info(pubkey_byte: u8) -> SessionInfo { + SessionInfo { + public_key: [pubkey_byte; 32], + sso: None, + root_entropy_source: None, + identity_account_id: None, + lite_username: Some("alice".to_string()), + full_username: None, + } + } + + #[test] + fn current_starts_empty() { + let state = SessionState::new(); + assert!(state.current().is_none()); + } + + #[test] + fn set_then_current_returns_session() { + let state = SessionState::new(); + state.set_session(info(0x42)); + let got = state.current().expect("session should be present"); + assert_eq!(got.public_key, [0x42; 32]); + assert_eq!(got.lite_username.as_deref(), Some("alice")); + } + + #[test] + fn persisted_session_round_trips() { + let mut session = info(0x42); + session.root_entropy_source = Some([1; 32]); + session.full_username = Some("Alice Smith".to_string()); + + let blob = encode_persisted_session(&session); + let decoded = decode_persisted_session(&blob).expect("session should decode"); + + assert_eq!(decoded, session); + } + + #[test] + fn persisted_sso_session_round_trips() { + let mut session = info(0x42); + session.sso = Some(SsoSessionInfo { + ss_secret: [1; 64], + ss_public_key: [2; 32], + enc_secret: [3; 32], + peer_enc_pubkey: [4; 65], + identity_account_id: [5; 32], + session_id_own: [6; 32], + session_id_peer: [7; 32], + request_channel: [8; 32], + response_channel: [9; 32], + peer_request_channel: [10; 32], + }); + + let blob = encode_persisted_session(&session); + let decoded = decode_persisted_session(&blob).expect("session should decode"); + + assert_eq!(decoded, session); + } + + #[test] + fn persisted_session_rejects_unknown_version() { + let mut blob = encode_persisted_session(&info(0x42)); + blob[0] = 0xff; + + let err = decode_persisted_session(&blob).unwrap_err(); + + assert_eq!(err, "unsupported session blob version 255"); + } + + #[test] + fn persisted_session_rejects_legacy_v2() { + let blob = vec![2]; + + let err = decode_persisted_session(&blob).unwrap_err(); + + assert_eq!(err, "unsupported session blob version 2"); + } + + #[test] + fn persisted_session_rejects_trailing_bytes() { + let mut blob = encode_persisted_session(&info(0x42)); + blob.push(0); + + let err = decode_persisted_session(&blob).unwrap_err(); + + assert_eq!(err, "invalid session blob: trailing bytes"); + } + + #[test] + fn clear_returns_to_empty() { + let state = SessionState::new(); + state.set_session(info(0x01)); + state.clear_session(); + assert!(state.current().is_none()); + } + + #[test] + fn subscribe_emits_current_state_first() { + let state = SessionState::new(); + state.set_session(info(0x01)); + let mut stream = state.subscribe(); + let first = block_on(stream.next()).expect("expected initial item"); + assert_eq!( + first, + VersionedItem::V1(HostAccountConnectionStatusSubscribeItem::Connected) + ); + } + + #[test] + fn subscribe_emits_disconnected_when_no_session() { + let state = SessionState::new(); + let mut stream = state.subscribe(); + let first = block_on(stream.next()).expect("expected initial item"); + assert_eq!( + first, + VersionedItem::V1(HostAccountConnectionStatusSubscribeItem::Disconnected) + ); + } + + #[test] + fn set_session_broadcasts_connected_to_existing_subscribers() { + let state = SessionState::new(); + let mut stream = state.subscribe(); + let _ = block_on(stream.next()); + + state.set_session(info(0x01)); + let next = block_on(stream.next()).expect("expected Connected event"); + assert_eq!( + next, + VersionedItem::V1(HostAccountConnectionStatusSubscribeItem::Connected) + ); + } + + #[test] + fn clear_session_broadcasts_disconnected_to_existing_subscribers() { + let state = SessionState::new(); + state.set_session(info(0x01)); + let mut stream = state.subscribe(); + let _ = block_on(stream.next()); + + state.clear_session(); + let next = block_on(stream.next()).expect("expected Disconnected event"); + assert_eq!( + next, + VersionedItem::V1(HostAccountConnectionStatusSubscribeItem::Disconnected) + ); + } + + #[test] + fn set_session_with_same_info_does_not_re_emit_connected() { + let state = SessionState::new(); + state.set_session(info(0x01)); + let mut stream = state.subscribe(); + let _ = block_on(stream.next()); + + state.set_session(info(0x01)); + + let pending = stream.next().now_or_never(); + assert!( + pending.is_none(), + "no transition event expected for equivalent session" + ); + } + + #[test] + fn set_session_with_replacement_re_emits_connected() { + let state = SessionState::new(); + state.set_session(info(0x01)); + let mut stream = state.subscribe(); + let _ = block_on(stream.next()); + + state.set_session(info(0x02)); + + let next = block_on(stream.next()).expect("expected replacement Connected event"); + assert_eq!( + next, + VersionedItem::V1(HostAccountConnectionStatusSubscribeItem::Connected) + ); + } + + #[test] + fn multi_subscriber_broadcast() { + let state = SessionState::new(); + let mut a = state.subscribe(); + let mut b = state.subscribe(); + // Drain initial Disconnected from both. + let _ = block_on(a.next()); + let _ = block_on(b.next()); + + state.set_session(info(0x77)); + let a_next = block_on(a.next()).expect("a should receive Connected"); + let b_next = block_on(b.next()).expect("b should receive Connected"); + assert_eq!( + a_next, + VersionedItem::V1(HostAccountConnectionStatusSubscribeItem::Connected) + ); + assert_eq!( + b_next, + VersionedItem::V1(HostAccountConnectionStatusSubscribeItem::Connected) + ); + } + + /// Clearing a never-set session is a no-op and must not synthesize a + /// spurious `Disconnected` event for live subscribers. + #[test] + fn clear_when_empty_is_silent_no_op() { + let state = SessionState::new(); + let mut stream = state.subscribe(); + // Drain the initial Disconnected. + let _ = block_on(stream.next()); + + state.clear_session(); + + let pending = stream.next().now_or_never(); + assert!(pending.is_none(), "no event expected when clear is a no-op",); + } + + /// Dropping a subscriber's stream must remove that sender from the + /// broadcast list. The next broadcast prunes it; the surviving stream + /// still receives the event. + #[test] + fn dropped_subscriber_is_pruned() { + let state = SessionState::new(); + let mut survivor = state.subscribe(); + let dropping = state.subscribe(); + let _ = block_on(survivor.next()); + // Drain the initial item from the dropping stream too so we don't + // accidentally test buffered-but-undelivered. + drop(dropping); + + state.set_session(info(0x33)); + let next = block_on(survivor.next()).expect("survivor must receive Connected"); + assert_eq!( + next, + VersionedItem::V1(HostAccountConnectionStatusSubscribeItem::Connected), + ); + + // Internally, `set_session`'s broadcast call `retain`-prunes any + // dropped senders. After the call the subscribers list should have + // exactly one entry (the survivor). + let inner = state.inner.lock().unwrap(); + assert_eq!(inner.subscribers.len(), 1, "dropped subscriber not pruned"); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/session_store.rs b/rust/crates/truapi-server/src/host_logic/session_store.rs new file mode 100644 index 00000000..45cc0edb --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/session_store.rs @@ -0,0 +1,97 @@ +//! Core-side invalidation signal for host-global session storage. +//! +//! The host owns persistence; the core owns decoding and projecting the +//! current blob into `SessionState` and `AuthState`. This notifier is just the +//! "the backing store may have changed" signal that drives a re-read. + +use std::sync::{Arc, Mutex}; + +use futures::channel::mpsc; +use futures::stream::{self, BoxStream, StreamExt}; + +#[derive(Default)] +pub struct SessionStoreChangeNotifier { + subscribers: Mutex>>, +} + +impl SessionStoreChangeNotifier { + /// Create a notifier with no subscribers. + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Broadcast a storage-change tick to current subscribers. + pub fn notify(&self) { + let mut subscribers = self + .subscribers + .lock() + .expect("session-store notifier mutex poisoned"); + subscribers.retain(|tx| tx.unbounded_send(()).is_ok()); + } + + /// Subscribe to storage-change ticks, including one initial tick. + pub fn subscribe(&self) -> BoxStream<'static, ()> { + let (tx, rx) = mpsc::unbounded(); + self.subscribers + .lock() + .expect("session-store notifier mutex poisoned") + .push(tx); + Box::pin(stream::once(async {}).chain(rx)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::executor::block_on; + use futures::{FutureExt, StreamExt}; + + #[test] + fn subscribe_emits_initial_tick() { + let notifier = SessionStoreChangeNotifier::new(); + let mut ticks = notifier.subscribe(); + + assert!(block_on(ticks.next()).is_some()); + } + + #[test] + fn notify_broadcasts_to_subscribers() { + let notifier = SessionStoreChangeNotifier::new(); + let mut first = notifier.subscribe(); + let mut second = notifier.subscribe(); + let _ = block_on(first.next()); + let _ = block_on(second.next()); + + notifier.notify(); + + assert!(block_on(first.next()).is_some()); + assert!(block_on(second.next()).is_some()); + } + + #[test] + fn dropped_subscriber_is_pruned_on_next_notify() { + let notifier = SessionStoreChangeNotifier::new(); + let dropped = notifier.subscribe(); + drop(dropped); + + notifier.notify(); + + assert_eq!( + notifier + .subscribers + .lock() + .expect("session-store notifier mutex poisoned") + .len(), + 0 + ); + } + + #[test] + fn no_tick_without_notify_after_initial() { + let notifier = SessionStoreChangeNotifier::new(); + let mut ticks = notifier.subscribe(); + let _ = block_on(ticks.next()); + + assert!(ticks.next().now_or_never().is_none()); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/sso.rs b/rust/crates/truapi-server/src/host_logic/sso.rs new file mode 100644 index 00000000..05da0780 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/sso.rs @@ -0,0 +1,2 @@ +pub mod messages; +pub mod pairing; diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs new file mode 100644 index 00000000..31b69045 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -0,0 +1,772 @@ +//! SCALE codecs for host-papp 0.7.9 SSO session-channel messages. +//! +//! These are the encrypted payloads carried inside statement-store +//! `SsoStatementData::Request` / `Response` frames. +//! The remote-message and signing codecs mirror host-papp: +//! +//! + +use parity_scale_codec::{Decode, Encode, OptionBool}; +use truapi::latest::{ + AllocatableResource, HostAccountGetAliasResponse, ProductAccountId, ProductAccountTxPayload, + RawPayload, +}; +use truapi::v01::{HostSignPayloadRequest, HostSignRawRequest}; + +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, +}; +use crate::host_logic::statement_store::{ + build_signed_session_request_statement, current_unix_secs, decode_verified_statement_data, + statement_expiry_elapsed, +}; + +const SSO_RESPONSE_CODE_SUCCESS: u8 = 0; + +/// Top-level wallet remote message sent over the encrypted SSO channel. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RemoteMessage { + /// Correlation id used to match wallet responses to host requests. + pub message_id: String, + /// Versioned remote message body. + pub data: RemoteMessageData, +} + +/// Versioned remote message body. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum RemoteMessageData { + V1(RemoteMessageV1), +} + +/// Host-papp v1 remote message variants. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum RemoteMessageV1 { + Disconnected, + SignRequest(Box), + SignResponse(SigningResponse), + RingVrfAliasRequest(RingVrfAliasRequest), + RingVrfAliasResponse(RingVrfAliasResponse), + ResourceAllocationRequest(ResourceAllocationRequest), + ResourceAllocationResponse(ResourceAllocationResponse), + CreateTransactionRequest(CreateTransactionRequest), + CreateTransactionResponse(CreateTransactionResponse), +} + +/// Signing request flavor sent to the wallet. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SigningRequest { + Payload(Box), + Raw(SigningRawRequest), +} + +/// Product-account payload signing request mirrored from host-papp. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SigningPayloadRequest { + pub product_account_id: ProductAccountId, + pub block_hash: Vec, + pub block_number: Vec, + pub era: Vec, + pub genesis_hash: Vec, + pub method: Vec, + pub nonce: Vec, + pub spec_version: Vec, + pub tip: Vec, + pub transaction_version: Vec, + pub signed_extensions: Vec, + pub version: u32, + pub asset_id: Option>, + pub metadata_hash: Option>, + pub mode: Option, + pub with_signed_transaction: OptionBool, +} + +impl From for SigningPayloadRequest { + fn from(value: HostSignPayloadRequest) -> Self { + let payload = value.payload; + Self { + product_account_id: value.account, + block_hash: payload.block_hash, + block_number: payload.block_number, + era: payload.era, + genesis_hash: payload.genesis_hash, + method: payload.method, + nonce: payload.nonce, + spec_version: payload.spec_version, + tip: payload.tip, + transaction_version: payload.transaction_version, + signed_extensions: payload.signed_extensions, + version: payload.version, + asset_id: payload.asset_id, + metadata_hash: payload.metadata_hash, + mode: payload.mode, + with_signed_transaction: OptionBool(payload.with_signed_transaction), + } + } +} + +/// Raw signing request mirrored from host-papp. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SigningRawRequest { + pub product_account_id: ProductAccountId, + pub data: SigningRawPayload, +} + +impl From for SigningRawRequest { + fn from(value: HostSignRawRequest) -> Self { + Self { + product_account_id: value.account, + data: value.payload.into(), + } + } +} + +/// Raw signing payload shape mirrored from host-papp. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SigningRawPayload { + Bytes(Vec), + Payload(String), +} + +impl From for SigningRawPayload { + fn from(value: RawPayload) -> Self { + match value { + RawPayload::Bytes { bytes } => Self::Bytes(bytes), + RawPayload::Payload { payload } => Self::Payload(payload), + } + } +} + +/// Wallet response to a signing request. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SigningResponse { + pub responding_to: String, + pub payload: Result, +} + +/// Successful signing response payload. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SigningPayloadResponseData { + pub signature: Vec, + pub signed_transaction: Option>, +} + +/// Wallet alias request for a product account. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RingVrfAliasRequest { + pub product_account_id: ProductAccountId, + pub product_id: String, +} + +/// Wallet alias response. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RingVrfAliasResponse { + pub responding_to: String, + pub payload: Result, +} + +/// Wallet resource-allocation request. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ResourceAllocationRequest { + pub calling_product_id: String, + pub resources: Vec, + pub on_existing: OnExistingAllowancePolicy, +} + +/// Resources the wallet may allocate for the calling product. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SsoAllocatableResource { + StatementStoreAllowance, + BulletInAllowance, + SmartContractAllowance(u32), + AutoSigning, +} + +impl From for SsoAllocatableResource { + fn from(value: AllocatableResource) -> Self { + match value { + AllocatableResource::StatementStoreAllowance => Self::StatementStoreAllowance, + AllocatableResource::BulletinAllowance => Self::BulletInAllowance, + AllocatableResource::SmartContractAllowance(index) => { + Self::SmartContractAllowance(index) + } + AllocatableResource::AutoSigning => Self::AutoSigning, + } + } +} + +/// Wallet policy for already-existing resource allowance. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum OnExistingAllowancePolicy { + Ignore, + Increase, +} + +/// Wallet resource-allocation response. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ResourceAllocationResponse { + pub responding_to: String, + pub payload: Result, String>, +} + +/// Per-resource allocation result from the wallet. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SsoAllocationOutcome { + Allocated(SsoAllocatedResource), + Rejected, + NotAvailable, +} + +/// Resource material allocated by the wallet. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SsoAllocatedResource { + StatementStoreAllowance { + slot_account_key: Vec, + }, + BulletInAllowance { + slot_account_key: Vec, + }, + SmartContractAllowance, + AutoSigning { + product_derivation_secret: String, + product_root_private_key: Vec, + }, +} + +/// Wallet transaction-creation request. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct CreateTransactionRequest { + pub payload: CreateTransactionPayload, +} + +/// Versioned transaction-creation payload. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum CreateTransactionPayload { + V1(ProductAccountTxPayload), +} + +/// Wallet transaction-creation response. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct CreateTransactionResponse { + pub responding_to: String, + pub signed_transaction: Result, String>, +} + +/// Decoded inbound statement-channel outcome. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SsoSessionStatement { + RequestAccepted, + RemoteResponse(SsoRemoteResponse), + Disconnected, +} + +/// Wallet response variants that can satisfy a pending remote request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SsoRemoteResponse { + Sign(SigningResponse), + RingVrfAlias(RingVrfAliasResponse), + ResourceAllocation(ResourceAllocationResponse), + CreateTransaction(CreateTransactionResponse), +} + +/// Decode and classify an inbound encrypted SSO session statement. +pub fn decode_sso_session_statement( + session: &SsoSessionInfo, + statement: &[u8], + expected_statement_request_id: &str, + expected_remote_message_id: &str, +) -> Result, String> { + let verified = + decode_verified_statement_data(statement, None).map_err(|err| err.to_string())?; + // Freshness gate against replay: a statement whose expiry is in the past + // is ignored. Trusts the local clock. + if verified + .expiry + .is_some_and(|expiry| statement_expiry_elapsed(expiry, current_unix_secs())) + { + return Ok(None); + } + let encrypted = verified.data; + let data = decrypt_session_statement_data(session, &encrypted)?; + if verified.signer == session.ss_public_key { + return match data { + SsoStatementData::Response { + request_id, + response_code, + } if request_id == expected_statement_request_id => { + classify_response_ack(request_id, response_code).map(Some) + } + _ => Ok(None), + }; + } + if verified.signer != session.identity_account_id { + return Err("statement proof signer does not match expected peer".to_string()); + } + match data { + SsoStatementData::Response { + request_id, + response_code, + } if request_id == expected_statement_request_id => { + classify_response_ack(request_id, response_code).map(Some) + } + SsoStatementData::Response { .. } => Ok(None), + SsoStatementData::Request { data, .. } => { + for message in data { + let message = RemoteMessage::decode(&mut message.as_slice()) + .map_err(|err| format!("invalid SSO remote message: {err}"))?; + if matches!( + &message.data, + RemoteMessageData::V1(RemoteMessageV1::Disconnected) + ) { + return Ok(Some(SsoSessionStatement::Disconnected)); + } + if let Some(response) = + remote_response_for_message(message, expected_remote_message_id) + { + return Ok(Some(SsoSessionStatement::RemoteResponse(response))); + } + } + Ok(None) + } + } +} + +fn classify_response_ack( + request_id: String, + response_code: u8, +) -> Result { + if response_code == SSO_RESPONSE_CODE_SUCCESS { + Ok(SsoSessionStatement::RequestAccepted) + } else { + Err(format!( + "SSO request {request_id} was rejected: {}", + sso_response_code_name(response_code) + )) + } +} + +fn remote_response_for_message( + message: RemoteMessage, + expected_remote_message_id: &str, +) -> Option { + let RemoteMessageData::V1(data) = message.data; + match data { + RemoteMessageV1::SignResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::Sign(response)) + } + RemoteMessageV1::RingVrfAliasResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::RingVrfAlias(response)) + } + RemoteMessageV1::ResourceAllocationResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::ResourceAllocation(response)) + } + RemoteMessageV1::CreateTransactionResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::CreateTransaction(response)) + } + _ => None, + } +} + +fn sso_response_code_name(code: u8) -> &'static str { + match code { + 1 => "decodingFailed", + 2 => "decryptionFailed", + 3 => "unknown", + _ => "unrecognized response code", + } +} + +/// Build a wallet payload-signing request message. +pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(RemoteMessageV1::SignRequest(Box::new( + SigningRequest::Payload(Box::new(request.into())), + ))), + } +} + +/// Build a wallet raw-signing request message. +pub fn sign_raw_message(message_id: String, request: HostSignRawRequest) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(RemoteMessageV1::SignRequest(Box::new(SigningRequest::Raw( + request.into(), + )))), + } +} + +/// Build a wallet account-alias request message. +pub fn alias_request_message( + message_id: String, + product_account_id: ProductAccountId, + product_id: String, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(RemoteMessageV1::RingVrfAliasRequest(RingVrfAliasRequest { + product_account_id, + product_id, + })), + } +} + +/// Build a wallet resource-allocation request message. +pub fn resource_allocation_message( + message_id: String, + calling_product_id: String, + resources: Vec, + on_existing: OnExistingAllowancePolicy, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(RemoteMessageV1::ResourceAllocationRequest( + ResourceAllocationRequest { + calling_product_id, + resources: resources.into_iter().map(Into::into).collect(), + on_existing, + }, + )), + } +} + +/// Build a wallet transaction-creation request message. +pub fn create_transaction_message( + message_id: String, + payload: ProductAccountTxPayload, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(RemoteMessageV1::CreateTransactionRequest( + CreateTransactionRequest { + payload: CreateTransactionPayload::V1(payload), + }, + )), + } +} + +/// Build a signed outbound SSO request statement with a random nonce. +pub fn build_outgoing_request_statement( + session: &SsoSessionInfo, + statement_request_id: String, + messages: Vec, + expiry: u64, +) -> Result, String> { + let encrypted = encrypt_outgoing_request_data(session, statement_request_id, messages)?; + build_signed_session_request_statement(session, encrypted, expiry) +} + +/// Build a signed outbound SSO request statement with a caller-supplied nonce. +pub fn build_outgoing_request_statement_with_nonce( + session: &SsoSessionInfo, + statement_request_id: String, + messages: Vec, + expiry: u64, + nonce: [u8; AES_GCM_NONCE_LEN], +) -> Result, String> { + let encrypted = + encrypt_outgoing_request_data_with_nonce(session, statement_request_id, messages, nonce)?; + build_signed_session_request_statement(session, encrypted, expiry) +} + +fn encrypt_outgoing_request_data( + session: &SsoSessionInfo, + statement_request_id: String, + messages: Vec, +) -> Result, String> { + encrypt_session_statement_data( + session, + &outgoing_request_data(statement_request_id, messages), + ) +} + +fn encrypt_outgoing_request_data_with_nonce( + session: &SsoSessionInfo, + statement_request_id: String, + messages: Vec, + nonce: [u8; AES_GCM_NONCE_LEN], +) -> Result, String> { + encrypt_session_statement_data_with_nonce( + session, + &outgoing_request_data(statement_request_id, messages), + nonce, + ) +} + +fn outgoing_request_data( + statement_request_id: String, + messages: Vec, +) -> SsoStatementData { + SsoStatementData::Request { + request_id: statement_request_id, + data: messages + .into_iter() + .map(|message| message.encode()) + .collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_logic::sso::pairing::decrypt_session_statement_data; + use crate::host_logic::statement_store::{ + StatementField, build_signed_statement, decode_statement_data, + }; + use p256::SecretKey as P256SecretKey; + use p256::elliptic_curve::sec1::ToEncodedPoint; + use schnorrkel::{ExpansionMode, MiniSecretKey}; + use truapi::latest::HostSignPayloadData; + + fn account() -> ProductAccountId { + ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 7, + } + } + + fn fresh_expiry() -> u64 { + (current_unix_secs() + 60) << 32 + } + + fn elapsed_expiry() -> u64 { + (current_unix_secs() - 60) << 32 + } + + fn session() -> SsoSessionInfo { + let mini_secret = MiniSecretKey::from_bytes(&[7; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + let core_secret = P256SecretKey::from_slice(&[1; 32]).unwrap(); + let peer_secret = P256SecretKey::from_slice(&[2; 32]).unwrap(); + SsoSessionInfo { + ss_secret: keypair.secret.to_bytes(), + ss_public_key: keypair.public.to_bytes(), + enc_secret: core_secret.to_bytes().into(), + peer_enc_pubkey: peer_secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(), + identity_account_id: [3; 32], + session_id_own: [4; 32], + session_id_peer: [5; 32], + request_channel: [6; 32], + response_channel: [7; 32], + peer_request_channel: [8; 32], + } + } + + #[test] + fn disconnected_message_matches_host_papp_variant_order() { + let message = RemoteMessage { + message_id: String::new(), + data: RemoteMessageData::V1(RemoteMessageV1::Disconnected), + }; + + assert_eq!(message.encode(), vec![0, 0, 0]); + } + + #[test] + fn raw_sign_request_uses_remote_message_variant_indices() { + let message = sign_raw_message( + "m1".to_string(), + HostSignRawRequest { + account: account(), + payload: RawPayload::Bytes { + bytes: vec![0xde, 0xad], + }, + }, + ); + let encoded = message.encode(); + + assert_eq!(&encoded[..3], &[8, b'm', b'1']); + assert_eq!(encoded[3], 0); + assert_eq!(encoded[4], 1); + assert_eq!(encoded[5], 1); + } + + #[test] + fn option_bool_matches_host_papp_option_bool_encoding() { + let mut request = HostSignPayloadRequest { + account: account(), + payload: HostSignPayloadData { + block_hash: vec![], + block_number: vec![], + era: vec![], + genesis_hash: vec![], + method: vec![], + nonce: vec![], + spec_version: vec![], + tip: vec![], + transaction_version: vec![], + signed_extensions: vec![], + version: 4, + asset_id: None, + metadata_hash: None, + mode: None, + with_signed_transaction: Some(true), + }, + }; + let true_encoded = SigningPayloadRequest::from(request.clone()).encode(); + request.payload.with_signed_transaction = Some(false); + let false_encoded = SigningPayloadRequest::from(request.clone()).encode(); + request.payload.with_signed_transaction = None; + let none_encoded = SigningPayloadRequest::from(request).encode(); + + assert_eq!(true_encoded.last(), Some(&1)); + assert_eq!(false_encoded.last(), Some(&2)); + assert_eq!(none_encoded.last(), Some(&0)); + } + + #[test] + fn maps_public_resource_names_to_sso_dialect() { + let message = resource_allocation_message( + "alloc".to_string(), + "myapp.dot".to_string(), + vec![ + AllocatableResource::StatementStoreAllowance, + AllocatableResource::BulletinAllowance, + AllocatableResource::SmartContractAllowance(9), + AllocatableResource::AutoSigning, + ], + OnExistingAllowancePolicy::Increase, + ); + let RemoteMessageData::V1(RemoteMessageV1::ResourceAllocationRequest(request)) = + message.data + else { + panic!("expected resource allocation request"); + }; + + assert_eq!( + request.resources, + vec![ + SsoAllocatableResource::StatementStoreAllowance, + SsoAllocatableResource::BulletInAllowance, + SsoAllocatableResource::SmartContractAllowance(9), + SsoAllocatableResource::AutoSigning, + ] + ); + assert_eq!(request.on_existing, OnExistingAllowancePolicy::Increase); + } + + #[test] + fn builds_signed_encrypted_outgoing_request_statement() { + let session = session(); + let remote_message = sign_raw_message( + "remote-1".to_string(), + HostSignRawRequest { + account: account(), + payload: RawPayload::Payload { + payload: "hello".to_string(), + }, + }, + ); + + let statement = build_outgoing_request_statement_with_nonce( + &session, + "statement-1".to_string(), + vec![remote_message.clone()], + 99, + [9; AES_GCM_NONCE_LEN], + ) + .unwrap(); + let encrypted = decode_statement_data(&statement).unwrap(); + let decrypted = decrypt_session_statement_data(&session, &encrypted).unwrap(); + + let SsoStatementData::Request { request_id, data } = decrypted else { + panic!("expected request statement data"); + }; + assert_eq!(request_id, "statement-1"); + assert_eq!(data.len(), 1); + assert_eq!( + RemoteMessage::decode(&mut data[0].as_slice()).unwrap(), + remote_message + ); + + let fields = Vec::::decode(&mut statement.as_slice()).unwrap(); + assert_eq!(fields[1], StatementField::Expiry(99)); + assert_eq!(fields[2], StatementField::Channel(session.request_channel)); + assert_eq!(fields[3], StatementField::Topic1(session.session_id_own)); + } + + #[test] + fn ignores_own_echoed_session_request_statement() { + let session = session(); + let remote_message = sign_raw_message( + "remote-1".to_string(), + HostSignRawRequest { + account: account(), + payload: RawPayload::Payload { + payload: "hello".to_string(), + }, + }, + ); + let statement = build_outgoing_request_statement_with_nonce( + &session, + "statement-1".to_string(), + vec![remote_message], + fresh_expiry(), + [9; AES_GCM_NONCE_LEN], + ) + .unwrap(); + + let decoded = + decode_sso_session_statement(&session, &statement, "statement-1", "remote-1").unwrap(); + + assert_eq!(decoded, None); + } + + fn response_ack_statement(session: &SsoSessionInfo, expiry: u64) -> Vec { + let encrypted = encrypt_session_statement_data_with_nonce( + session, + &SsoStatementData::Response { + request_id: "statement-1".to_string(), + response_code: SSO_RESPONSE_CODE_SUCCESS, + }, + [9; AES_GCM_NONCE_LEN], + ) + .unwrap(); + build_signed_statement( + session, + session.response_channel, + session.session_id_own, + encrypted, + expiry, + ) + .unwrap() + } + + #[test] + fn accepts_own_echoed_session_response_ack() { + let session = session(); + let statement = response_ack_statement(&session, fresh_expiry()); + + let decoded = + decode_sso_session_statement(&session, &statement, "statement-1", "remote-1").unwrap(); + + assert_eq!(decoded, Some(SsoSessionStatement::RequestAccepted)); + } + + /// A statement whose expiry is in the past must be ignored even when it + /// would otherwise match the pending request (replay protection). + #[test] + fn ignores_expired_session_response_ack() { + let session = session(); + let statement = response_ack_statement(&session, elapsed_expiry()); + + let decoded = + decode_sso_session_statement(&session, &statement, "statement-1", "remote-1").unwrap(); + + assert_eq!(decoded, None); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs new file mode 100644 index 00000000..3efecba5 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -0,0 +1,758 @@ +//! SSO pairing bootstrap helpers. +//! +//! This module owns the byte shape of the QR/deeplink payload described in +//! `docs/design/host-contract-and-core-impl/H - sso-pairing-protocol.md`. +//! The SCALE handshake codecs mirror host-papp's v2 handshake codec: +//! + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use blake2_rfc::blake2b::blake2b; +use hkdf::Hkdf; +use p256::ecdh::diffie_hellman; +use p256::elliptic_curve::sec1::ToEncodedPoint; +use p256::{PublicKey, SecretKey}; +use parity_scale_codec::{Decode, Encode}; +use schnorrkel::{ExpansionMode, MiniSecretKey}; +use sha2::Sha256; +use thiserror::Error; +use truapi_platform::RuntimeConfig; +#[cfg(test)] +use truapi_platform::{HostInfo, PlatformInfo}; + +use crate::host_logic::session::SsoSessionInfo; + +const HANDSHAKE_TOPIC_SUFFIX: &[u8] = b"topic"; +const MAX_P256_SECRET_ATTEMPTS: usize = 64; +/// Byte length of the AES-GCM nonce prepended to encrypted SSO payloads. +pub const AES_GCM_NONCE_LEN: usize = 12; +const SESSION_PREFIX: &[u8] = b"session"; +const PIN_SEPARATOR: &[u8] = b"/"; +const REQUEST_CHANNEL_SUFFIX: &[u8] = b"request"; +const RESPONSE_CHANNEL_SUFFIX: &[u8] = b"response"; + +/// QR/deeplink bootstrap material generated by the host for one pairing flow. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairingBootstrap { + pub deeplink: String, + pub topic: [u8; 32], + pub statement_store_public_key: [u8; 32], + pub statement_store_secret: [u8; 64], + pub encryption_public_key: [u8; 65], + pub encryption_secret_key: [u8; 32], +} + +/// Persistable device identity reused across pairing attempts. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct PairingDeviceIdentity { + pub statement_store_secret: [u8; 64], + pub statement_store_public_key: [u8; 32], + pub encryption_secret_key: [u8; 32], + pub encryption_public_key: [u8; 65], +} + +/// Errors that can occur while generating pairing bootstrap material. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum PairingBootstrapError { + #[error("failed to generate random pairing material: {0}")] + Random(String), + #[error("failed to generate P-256 pairing key")] + InvalidP256Secret, +} + +/// Versioned SCALE payload embedded in the pairing deeplink. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum VersionedHandshakeProposal { + #[codec(index = 1)] + V2(HandshakeProposalV2), +} + +/// Host-papp v2 handshake proposal sent by the host. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HandshakeProposalV2 { + pub device: HandshakeDevice, + pub metadata: Vec, +} + +/// Device keys advertised in the v2 handshake proposal. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HandshakeDevice { + pub statement_account_id: [u8; 32], + pub encryption_public_key: [u8; 65], +} + +/// Metadata key/value entry attached to a v2 handshake proposal. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HandshakeMetadataEntry(pub HandshakeMetadataKey, pub String); + +/// Metadata keys understood by the mobile SSO pairing flow. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HandshakeMetadataKey { + Custom(String), + HostName, + HostVersion, + HostIcon, + PlatformType, + PlatformVersion, +} + +/// Versioned encrypted response posted by the wallet to the pairing topic. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum VersionedHandshakeResponse { + #[codec(index = 1)] + V2 { + encrypted_message: Vec, + public_key: [u8; 65], + }, +} + +/// Plaintext v2 wallet response after decrypting the pairing statement. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum EncryptedHandshakeResponseV2 { + Pending(HandshakeStatusV2), + Success(Box), + Failed(String), +} + +/// Intermediate v2 handshake status emitted before success/failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HandshakeStatusV2 { + AllowanceAllocation, +} + +/// Successful v2 handshake payload used to establish the SSO session. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HandshakeSuccessV2 { + pub identity_account_id: [u8; 32], + pub root_account_id: [u8; 32], + pub identity_chat_private_key: [u8; 32], + pub sso_enc_pub_key: [u8; 65], + pub device_enc_pub_key: [u8; 65], + pub root_entropy_source: [u8; 32], +} + +/// Encrypted statement-channel envelope shared with the wallet. +/// +/// Mirrors `@novasamatech/statement-store` session statement data: +/// +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SsoStatementData { + Request { + request_id: String, + data: Vec>, + }, + Response { + request_id: String, + response_code: u8, + }, +} + +/// Decode wallet-posted pairing handshake data from SCALE bytes. +pub fn decode_app_handshake_data(blob: &[u8]) -> Result { + let mut input = blob; + let value: VersionedHandshakeResponse = + Decode::decode(&mut input).map_err(|err| format!("invalid app handshake data: {err}"))?; + if !input.is_empty() { + return Err("invalid app handshake data: trailing bytes".to_string()); + } + Ok(value) +} + +/// Decrypt a v2 handshake response. +pub fn decrypt_v2_handshake_response( + core_encryption_secret_key: [u8; 32], + wallet_ephemeral_public_key: [u8; 65], + encrypted_message: &[u8], +) -> Result { + let plaintext = decrypt_p256_hkdf_aes_gcm( + core_encryption_secret_key, + wallet_ephemeral_public_key, + encrypted_message, + )?; + let mut input = plaintext.as_slice(); + let value = EncryptedHandshakeResponseV2::decode(&mut input) + .map_err(|err| format!("invalid SSO V2 handshake response: {err}"))?; + if !input.is_empty() { + return Err("invalid SSO V2 handshake response: trailing bytes".to_string()); + } + Ok(value) +} + +/// Derive the persistent SSO session channels from a successful handshake. +pub fn establish_sso_session_info( + bootstrap: &PairingBootstrap, + peer_statement_account_id: [u8; 32], + peer_sso_enc_pub_key: [u8; 65], +) -> Result { + let shared_secret = shared_secret(bootstrap.encryption_secret_key, peer_sso_enc_pub_key)?; + let shared_secret_bytes: [u8; 32] = (*shared_secret.raw_secret_bytes()).into(); + let session_id_own = create_session_id( + shared_secret_bytes, + bootstrap.statement_store_public_key, + peer_statement_account_id, + ); + let session_id_peer = create_session_id( + shared_secret_bytes, + peer_statement_account_id, + bootstrap.statement_store_public_key, + ); + + Ok(SsoSessionInfo { + ss_secret: bootstrap.statement_store_secret, + ss_public_key: bootstrap.statement_store_public_key, + enc_secret: bootstrap.encryption_secret_key, + peer_enc_pubkey: peer_sso_enc_pub_key, + identity_account_id: peer_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), + }) +} + +/// Encrypt session-channel statement data with a random nonce. +pub fn encrypt_session_statement_data( + session: &SsoSessionInfo, + data: &SsoStatementData, +) -> Result, String> { + let mut nonce = [0u8; AES_GCM_NONCE_LEN]; + getrandom::getrandom(&mut nonce) + .map_err(|err| format!("failed to generate AES-GCM nonce: {err}"))?; + encrypt_session_statement_data_with_nonce(session, data, nonce) +} + +/// Encrypt session-channel statement data with a caller-supplied nonce. +pub fn encrypt_session_statement_data_with_nonce( + session: &SsoSessionInfo, + data: &SsoStatementData, + nonce: [u8; AES_GCM_NONCE_LEN], +) -> Result, String> { + let aes_key = session_aes_key(session)?; + let cipher = Aes256Gcm::new_from_slice(&aes_key) + .map_err(|err| format!("failed to initialize AES-GCM: {err}"))?; + let mut encrypted = nonce.to_vec(); + encrypted.extend( + cipher + .encrypt(Nonce::from_slice(&nonce), data.encode().as_slice()) + .map_err(|err| format!("failed to encrypt SSO statement data: {err}"))?, + ); + Ok(encrypted) +} + +/// Decrypt session-channel statement data. +pub fn decrypt_session_statement_data( + session: &SsoSessionInfo, + encrypted_message: &[u8], +) -> Result { + let plaintext = decrypt_session_message(session, encrypted_message)?; + let mut input = plaintext.as_slice(); + let data = SsoStatementData::decode(&mut input) + .map_err(|err| format!("invalid SSO statement data: {err}"))?; + if !input.is_empty() { + return Err("invalid SSO statement data: trailing bytes".to_string()); + } + Ok(data) +} + +fn decrypt_p256_hkdf_aes_gcm( + own_secret_key: [u8; 32], + peer_public_key: [u8; 65], + encrypted_message: &[u8], +) -> Result, String> { + if encrypted_message.len() < AES_GCM_NONCE_LEN { + return Err("encrypted SSO handshake answer is too short".to_string()); + } + let shared_secret = shared_secret(own_secret_key, peer_public_key)?; + let aes_key = aes_key_from_shared_secret(&shared_secret)?; + + decrypt_aes_gcm_with_key(aes_key, encrypted_message, "handshake answer") +} + +fn decrypt_session_message( + session: &SsoSessionInfo, + encrypted_message: &[u8], +) -> Result, String> { + decrypt_aes_gcm_with_key( + session_aes_key(session)?, + encrypted_message, + "statement data", + ) +} + +fn decrypt_aes_gcm_with_key( + aes_key: [u8; 32], + encrypted_message: &[u8], + label: &str, +) -> Result, String> { + if encrypted_message.len() < AES_GCM_NONCE_LEN { + return Err(format!("encrypted SSO {label} is too short")); + } + let (nonce, ciphertext) = encrypted_message.split_at(AES_GCM_NONCE_LEN); + let cipher = Aes256Gcm::new_from_slice(&aes_key) + .map_err(|err| format!("failed to initialize AES-GCM: {err}"))?; + cipher + .decrypt(Nonce::from_slice(nonce), ciphertext) + .map_err(|err| format!("failed to decrypt SSO {label}: {err}")) +} + +fn session_aes_key(session: &SsoSessionInfo) -> Result<[u8; 32], String> { + let shared_secret = shared_secret(session.enc_secret, session.peer_enc_pubkey)?; + aes_key_from_shared_secret(&shared_secret) +} + +fn aes_key_from_shared_secret( + shared_secret: &p256::ecdh::SharedSecret, +) -> Result<[u8; 32], String> { + let hkdf = Hkdf::::new(None, shared_secret.raw_secret_bytes()); + let mut aes_key = [0u8; 32]; + hkdf.expand(&[], &mut aes_key) + .map_err(|err| format!("failed to derive AES key: {err}"))?; + Ok(aes_key) +} + +fn shared_secret( + own_secret_key: [u8; 32], + peer_public_key: [u8; 65], +) -> Result { + let secret = SecretKey::from_slice(&own_secret_key) + .map_err(|err| format!("invalid P-256 secret key: {err}"))?; + let peer_public = PublicKey::from_sec1_bytes(&peer_public_key) + .map_err(|err| format!("invalid P-256 public key: {err}"))?; + Ok(diffie_hellman( + secret.to_nonzero_scalar(), + peer_public.as_affine(), + )) +} + +fn create_session_id( + shared_secret: [u8; 32], + account_a: [u8; 32], + account_b: [u8; 32], +) -> [u8; 32] { + let mut message = Vec::with_capacity(SESSION_PREFIX.len() + 32 + 32 + 2); + message.extend_from_slice(SESSION_PREFIX); + message.extend_from_slice(&account_a); + message.extend_from_slice(&account_b); + message.extend_from_slice(PIN_SEPARATOR); + message.extend_from_slice(PIN_SEPARATOR); + keyed_hash(shared_secret, &message) +} + +fn keyed_hash(key: [u8; 32], message: &[u8]) -> [u8; 32] { + let digest = blake2b(32, &key, message); + let mut output = [0u8; 32]; + output.copy_from_slice(digest.as_bytes()); + output +} + +/// Create one-shot pairing bootstrap material from runtime config. +pub fn create_pairing_bootstrap( + config: &RuntimeConfig, +) -> Result { + create_pairing_bootstrap_from_identity(config, generate_pairing_device_identity()?) +} + +/// Generate a fresh persistable pairing device identity. +pub fn generate_pairing_device_identity() -> Result { + let (statement_store_secret, statement_store_public_key) = generate_statement_store_keypair()?; + let (encryption_secret_key, encryption_public_key) = generate_p256_keypair()?; + + Ok(PairingDeviceIdentity { + statement_store_secret, + statement_store_public_key, + encryption_secret_key, + encryption_public_key, + }) +} + +/// Create pairing bootstrap material from an existing device identity. +pub fn create_pairing_bootstrap_from_identity( + config: &RuntimeConfig, + identity: PairingDeviceIdentity, +) -> Result { + let deeplink = build_pairing_deeplink( + &config.pairing_deeplink_scheme, + identity.statement_store_public_key, + identity.encryption_public_key, + config, + ); + let topic = bootstrap_topic( + identity.statement_store_public_key, + identity.encryption_public_key, + ); + + Ok(PairingBootstrap { + deeplink, + topic, + statement_store_public_key: identity.statement_store_public_key, + statement_store_secret: identity.statement_store_secret, + encryption_public_key: identity.encryption_public_key, + encryption_secret_key: identity.encryption_secret_key, + }) +} + +/// Build the wallet deeplink that carries the v2 handshake proposal. +pub fn build_pairing_deeplink( + scheme: &str, + statement_store_public_key: [u8; 32], + encryption_public_key: [u8; 65], + config: &RuntimeConfig, +) -> String { + let handshake = VersionedHandshakeProposal::V2(HandshakeProposalV2 { + device: HandshakeDevice { + statement_account_id: statement_store_public_key, + encryption_public_key, + }, + metadata: handshake_metadata(config), + }); + format!( + "{scheme}://pair?handshake={}", + hex::encode(handshake.encode()) + ) +} + +fn handshake_metadata(config: &RuntimeConfig) -> Vec { + let mut entries = vec![HandshakeMetadataEntry( + HandshakeMetadataKey::HostName, + config.host_info.name.clone(), + )]; + if let Some(value) = &config.host_info.version { + entries.push(HandshakeMetadataEntry( + HandshakeMetadataKey::HostVersion, + value.clone(), + )); + } + if let Some(value) = &config.host_info.icon { + entries.push(HandshakeMetadataEntry( + HandshakeMetadataKey::HostIcon, + value.clone(), + )); + } + if let Some(value) = &config.platform_info.kind { + entries.push(HandshakeMetadataEntry( + HandshakeMetadataKey::PlatformType, + value.clone(), + )); + } + if let Some(value) = &config.platform_info.version { + entries.push(HandshakeMetadataEntry( + HandshakeMetadataKey::PlatformVersion, + value.clone(), + )); + } + entries +} + +/// Derive the statement-store pairing topic from advertised host keys. +pub fn bootstrap_topic( + statement_store_public_key: [u8; 32], + encryption_public_key: [u8; 65], +) -> [u8; 32] { + let mut message = + Vec::with_capacity(encryption_public_key.len() + HANDSHAKE_TOPIC_SUFFIX.len()); + message.extend_from_slice(&encryption_public_key); + message.extend_from_slice(HANDSHAKE_TOPIC_SUFFIX); + + keyed_hash(statement_store_public_key, &message) +} + +fn generate_statement_store_keypair() -> Result<([u8; 64], [u8; 32]), PairingBootstrapError> { + let mut seed = [0u8; 32]; + getrandom::getrandom(&mut seed) + .map_err(|err| PairingBootstrapError::Random(err.to_string()))?; + let mini_secret = MiniSecretKey::from_bytes(&seed) + .map_err(|err| PairingBootstrapError::Random(err.to_string()))?; + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + Ok((keypair.secret.to_bytes(), keypair.public.to_bytes())) +} + +fn generate_p256_keypair() -> Result<([u8; 32], [u8; 65]), PairingBootstrapError> { + for _ in 0..MAX_P256_SECRET_ATTEMPTS { + let mut candidate = [0u8; 32]; + getrandom::getrandom(&mut candidate) + .map_err(|err| PairingBootstrapError::Random(err.to_string()))?; + 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); + let mut encryption_secret_key = [0u8; 32]; + encryption_secret_key.copy_from_slice(secret.to_bytes().as_slice()); + return Ok((encryption_secret_key, encryption_public_key)); + } + + Err(PairingBootstrapError::InvalidP256Secret) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SS_PUBLIC: [u8; 32] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, + 0x1e, 0x1f, + ]; + const ENC_PUBLIC: [u8; 65] = [ + 0x04, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, + 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, + 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, + 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, + ]; + + fn runtime_config() -> RuntimeConfig { + RuntimeConfig { + product_id: "myapp.dot".to_string(), + host_info: HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://example.invalid/dotli.png".to_string()), + version: Some("1.2.3".to_string()), + }, + platform_info: PlatformInfo { + kind: Some("Firefox".to_string()), + version: Some("192.32".to_string()), + }, + people_chain_genesis_hash: [0; 32], + pairing_deeplink_scheme: "polkadotapp".to_string(), + } + } + + #[test] + fn builds_v2_pairing_deeplink() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + + assert!(deeplink.starts_with("polkadotapp://pair?handshake=01")); + let encoded = hex::decode(deeplink.split("handshake=").nth(1).unwrap()).unwrap(); + let decoded = ::decode(&mut &encoded[..]).unwrap(); + let VersionedHandshakeProposal::V2(proposal) = decoded; + assert_eq!(proposal.device.statement_account_id, SS_PUBLIC); + assert_eq!(proposal.device.encryption_public_key, ENC_PUBLIC); + assert!(proposal.metadata.contains(&HandshakeMetadataEntry( + HandshakeMetadataKey::HostName, + "Polkadot Web".to_string() + ))); + } + + #[test] + fn builds_dev_pairing_deeplink() { + let deeplink = + build_pairing_deeplink("polkadotappdev", SS_PUBLIC, ENC_PUBLIC, &runtime_config()); + + assert!(deeplink.starts_with("polkadotappdev://pair?handshake=")); + } + + #[test] + fn derives_bootstrap_topic_vector() { + assert_eq!( + hex::encode(bootstrap_topic(SS_PUBLIC, ENC_PUBLIC)), + "031c589833c39b1dfbe3c1304ced75fa7b0d841035db008e5b407bfadd2779a4" + ); + } + + #[test] + fn generated_bootstrap_uses_real_key_shapes() { + let config = runtime_config(); + + let bootstrap = create_pairing_bootstrap(&config).unwrap(); + + assert!( + bootstrap + .deeplink + .starts_with("polkadotapp://pair?handshake=") + ); + assert_eq!(bootstrap.encryption_public_key[0], 0x04); + assert_eq!( + bootstrap.topic, + bootstrap_topic( + bootstrap.statement_store_public_key, + bootstrap.encryption_public_key + ) + ); + } + + #[test] + fn decodes_app_handshake_response() { + let answer = VersionedHandshakeResponse::V2 { + encrypted_message: vec![0xde, 0xad], + public_key: ENC_PUBLIC, + }; + + assert_eq!(decode_app_handshake_data(&answer.encode()).unwrap(), answer); + } + + #[test] + fn rejects_app_handshake_trailing_bytes() { + let mut encoded = VersionedHandshakeResponse::V2 { + encrypted_message: vec![0xde, 0xad], + public_key: ENC_PUBLIC, + } + .encode(); + encoded.push(0); + + assert_eq!( + decode_app_handshake_data(&encoded).unwrap_err(), + "invalid app handshake data: trailing bytes" + ); + } + + #[test] + fn decrypts_v2_handshake_response() { + let core_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let wallet_ephemeral_secret = SecretKey::from_slice(&[2; 32]).unwrap(); + let wallet_ephemeral_public = wallet_ephemeral_secret.public_key().to_encoded_point(false); + let mut wallet_ephemeral_public_bytes = [0u8; 65]; + wallet_ephemeral_public_bytes.copy_from_slice(wallet_ephemeral_public.as_bytes()); + + let shared_secret = diffie_hellman( + wallet_ephemeral_secret.to_nonzero_scalar(), + core_secret.public_key().as_affine(), + ); + let hkdf = Hkdf::::new(None, shared_secret.raw_secret_bytes()); + let mut aes_key = [0u8; 32]; + hkdf.expand(&[], &mut aes_key).unwrap(); + + let sensitive = EncryptedHandshakeResponseV2::Success(Box::new(HandshakeSuccessV2 { + 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 nonce = [9u8; AES_GCM_NONCE_LEN]; + let cipher = Aes256Gcm::new_from_slice(&aes_key).unwrap(); + let mut encrypted = nonce.to_vec(); + encrypted.extend( + cipher + .encrypt(Nonce::from_slice(&nonce), sensitive.encode().as_slice()) + .unwrap(), + ); + + assert_eq!( + decrypt_v2_handshake_response( + core_secret.to_bytes().into(), + wallet_ephemeral_public_bytes, + &encrypted + ) + .unwrap(), + sensitive + ); + } + + #[test] + fn rejects_short_handshake_ciphertext() { + assert_eq!( + decrypt_v2_handshake_response([1; 32], ENC_PUBLIC, &[0; AES_GCM_NONCE_LEN - 1]) + .unwrap_err(), + "encrypted SSO handshake answer is too short" + ); + } + + #[test] + fn establishes_session_ids_and_channels() { + let core_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let core_public = core_secret.public_key().to_encoded_point(false); + let mut core_public_bytes = [0u8; 65]; + core_public_bytes.copy_from_slice(core_public.as_bytes()); + let bootstrap = PairingBootstrap { + deeplink: "polkadotapp://pair?handshake=00".to_string(), + topic: [0x11; 32], + statement_store_public_key: [0x22; 32], + statement_store_secret: [0x33; 64], + encryption_public_key: core_public_bytes, + encryption_secret_key: [1; 32], + }; + let peer_secret = SecretKey::from_slice(&[2; 32]).unwrap(); + let peer_public = peer_secret.public_key().to_encoded_point(false); + let peer_public: [u8; 65] = peer_public.as_bytes().try_into().unwrap(); + + let info = establish_sso_session_info(&bootstrap, [0x55; 32], peer_public).unwrap(); + + assert_eq!(info.ss_secret, [0x33; 64]); + assert_eq!(info.ss_public_key, [0x22; 32]); + assert_eq!(info.enc_secret, [1; 32]); + assert_eq!(info.peer_enc_pubkey, peer_public); + assert_eq!(info.identity_account_id, [0x55; 32]); + assert_ne!(info.session_id_own, info.session_id_peer); + assert_eq!( + info.request_channel, + keyed_hash(info.session_id_own, b"request") + ); + assert_eq!( + info.response_channel, + keyed_hash(info.session_id_own, b"response") + ); + assert_eq!( + info.peer_request_channel, + keyed_hash(info.session_id_peer, b"request") + ); + } + + #[test] + fn statement_data_codec_round_trips_request_and_response() { + let request = SsoStatementData::Request { + request_id: "req-1".to_string(), + data: vec![vec![0xde, 0xad], vec![0xbe, 0xef]], + }; + let response = SsoStatementData::Response { + request_id: "req-1".to_string(), + response_code: 0, + }; + + assert_eq!( + SsoStatementData::decode(&mut &request.encode()[..]).unwrap(), + request + ); + assert_eq!( + SsoStatementData::decode(&mut &response.encode()[..]).unwrap(), + response + ); + assert_eq!(request.encode()[0], 0); + assert_eq!(response.encode()[0], 1); + } + + #[test] + fn encrypts_and_decrypts_session_statement_data() { + let core_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let core_public = core_secret.public_key().to_encoded_point(false); + let mut core_public_bytes = [0u8; 65]; + core_public_bytes.copy_from_slice(core_public.as_bytes()); + let bootstrap = PairingBootstrap { + deeplink: "polkadotapp://pair?handshake=00".to_string(), + topic: [0x11; 32], + statement_store_public_key: [0x22; 32], + statement_store_secret: [0x33; 64], + encryption_public_key: core_public_bytes, + encryption_secret_key: [1; 32], + }; + let peer_secret = SecretKey::from_slice(&[2; 32]).unwrap(); + let peer_public = peer_secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(); + let session = establish_sso_session_info(&bootstrap, [0x55; 32], peer_public).unwrap(); + let data = SsoStatementData::Request { + request_id: "req-1".to_string(), + data: vec![vec![0xde, 0xad]], + }; + let nonce = [9u8; AES_GCM_NONCE_LEN]; + + let encrypted = encrypt_session_statement_data_with_nonce(&session, &data, nonce).unwrap(); + + assert_eq!(&encrypted[..AES_GCM_NONCE_LEN], nonce); + assert_eq!( + decrypt_session_statement_data(&session, &encrypted).unwrap(), + data + ); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/statement_store.rs b/rust/crates/truapi-server/src/host_logic/statement_store.rs new file mode 100644 index 00000000..06980545 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/statement_store.rs @@ -0,0 +1,38 @@ +//! People-chain statement-store helpers. +//! +//! The core talks to the statement-store pallet through the host-provided +//! `ChainProvider` JSON-RPC connection. Transport mechanics live in +//! `HostRpcClient`; this module owns statement-store payload encoding, +//! proof verification, and subscription-result parsing. + +use thiserror::Error; + +mod rpc; +mod statement; + +pub use rpc::{ + MAX_MATCH_ALL_TOPICS, MAX_MATCH_ANY_TOPICS, NewStatements, SUBMIT_STATEMENT_METHOD, + SUBSCRIBE_STATEMENT_METHOD, TopicFilterKind, UNSUBSCRIBE_STATEMENT_METHOD, + parse_new_statements_result, +}; +pub(crate) use statement::current_unix_secs; +pub use statement::{ + StatementField, StatementProof, VerifiedStatementData, build_signed_session_request_statement, + build_signed_statement, decode_signed_statement, decode_statement_data, + decode_verified_statement_data, hex_topic, sign_statement_fields, signed_statement_to_scale, + statement_expiry_elapsed, statement_fields_from_v01, statement_proof_to_v01, + statement_signing_payload, +}; + +/// Error while parsing statement-store JSON-RPC or SCALE statement payloads. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum StatementStoreParseError { + #[error("invalid statement hex: {0}")] + InvalidStatementHex(String), + #[error("invalid statement scale: {0}")] + InvalidStatementScale(String), + #[error("malformed statement-store frame: {0}")] + Malformed(String), + #[error("invalid statement proof: {0}")] + InvalidStatementProof(String), +} diff --git a/rust/crates/truapi-server/src/host_logic/statement_store/rpc.rs b/rust/crates/truapi-server/src/host_logic/statement_store/rpc.rs new file mode 100644 index 00000000..bedbce5d --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/statement_store/rpc.rs @@ -0,0 +1,115 @@ +//! Statement-store JSON-RPC shapes mirrored from `sp_statement_store`. +//! +//! See the upstream RPC methods plus `TopicFilter` / `StatementEvent` types: +//! +//! +//! + +use serde_json::Value; + +use super::StatementStoreParseError; + +/// Statement-store RPC method used to open a topic subscription. +pub const SUBSCRIBE_STATEMENT_METHOD: &str = "statement_subscribeStatement"; +/// Statement-store RPC method used to close a topic subscription. +pub const UNSUBSCRIBE_STATEMENT_METHOD: &str = "statement_unsubscribeStatement"; +/// Statement-store RPC method used to submit a signed statement. +pub const SUBMIT_STATEMENT_METHOD: &str = "statement_submit"; +/// Maximum `matchAll` topic count accepted by the statement-store RPC. +pub const MAX_MATCH_ALL_TOPICS: usize = 4; +/// Maximum `matchAny` topic count accepted by the statement-store RPC. +pub const MAX_MATCH_ANY_TOPICS: usize = 128; + +/// Decoded `newStatements` subscription notification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewStatements { + /// Remote subscription id included in the notification. + pub remote_subscription_id: String, + /// SCALE-encoded signed statements carried by the notification. + pub statements: Vec>, + /// Optional server-side backlog count. + pub remaining: Option, +} + +/// Topic filter flavor used by statement-store subscribe requests. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TopicFilterKind { + /// Require every listed topic to match. + MatchAll, + /// Accept any listed topic match. + MatchAny, +} + +/// Parse a statement-store subscription result value. +pub fn parse_new_statements_result( + remote_subscription_id: String, + result: &Value, +) -> Result { + if result.get("event").and_then(Value::as_str) != Some("newStatements") { + return Err(StatementStoreParseError::Malformed( + "result is not a newStatements event".to_string(), + )); + } + let data = result + .get("data") + .ok_or_else(|| StatementStoreParseError::Malformed("missing data".to_string()))?; + let statement_values = data + .get("statements") + .and_then(Value::as_array) + .ok_or_else(|| StatementStoreParseError::Malformed("missing statements".to_string()))?; + let statements = statement_values + .iter() + .map(|value| { + let Some(hex) = value.as_str() else { + return Err(StatementStoreParseError::Malformed( + "statement is not a hex string".to_string(), + )); + }; + decode_hex(hex) + }) + .collect::, _>>()?; + let remaining = data + .get("remaining") + .map(|value| { + value.as_u64().ok_or_else(|| { + StatementStoreParseError::Malformed("remaining is not an integer".to_string()) + }) + }) + .transpose()?; + + Ok(NewStatements { + remote_subscription_id, + statements, + remaining, + }) +} + +fn decode_hex(value: &str) -> Result, StatementStoreParseError> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)) + .map_err(|error| StatementStoreParseError::InvalidStatementHex(error.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_dotli_sdk_new_statements_result() { + let result = serde_json::json!({ + "event": "newStatements", + "data": { + "statements": ["0xdeadbeef", "0xcafe"], + "remaining": 0, + }, + }); + + assert_eq!( + parse_new_statements_result("remote-sub".to_string(), &result).unwrap(), + NewStatements { + remote_subscription_id: "remote-sub".to_string(), + statements: vec![vec![0xde, 0xad, 0xbe, 0xef], vec![0xca, 0xfe]], + remaining: Some(0), + } + ); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs new file mode 100644 index 00000000..b4f29302 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs @@ -0,0 +1,675 @@ +use parity_scale_codec::{Compact, Decode, Encode}; +use schnorrkel::{PublicKey, SecretKey, Signature}; +use truapi::v01; + +use super::StatementStoreParseError; +use crate::host_logic::session::SsoSessionInfo; + +const SR25519_SIGNING_CONTEXT: &[u8] = b"substrate"; + +/// Verified statement payload plus the sr25519 signer recovered from proof. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedStatementData { + /// Raw statement data field. + pub data: Vec, + /// Sr25519 signer recovered from the proof. + pub signer: [u8; 32], + /// Raw `Expiry` field, if present: unix seconds in the upper 32 bits. + pub expiry: Option, +} + +/// SCALE statement proof variants mirrored from `sp_statement_store::Proof`. +/// +/// See the current upstream `Proof` codec: +/// +/// +/// `OnChain` is retained for v01 wire compatibility with older +/// statement-store bytes: +/// +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum StatementProof { + Sr25519 { + signature: [u8; 64], + signer: [u8; 32], + }, + Ed25519 { + signature: [u8; 64], + signer: [u8; 32], + }, + Ecdsa { + signature: [u8; 65], + signer: [u8; 33], + }, + OnChain { + who: [u8; 32], + block_hash: [u8; 32], + event: u64, + }, +} + +/// SCALE statement field variants mirrored from `sp_statement_store::Field`. +/// +/// See the upstream statement field vector codec: +/// +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum StatementField { + Proof(StatementProof), + DecryptionKey([u8; 32]), + Expiry(u64), + Channel([u8; 32]), + Topic1([u8; 32]), + Topic2([u8; 32]), + Topic3([u8; 32]), + Topic4([u8; 32]), + Data(Vec), +} + +/// Extract the raw `Data` field from a SCALE-encoded statement. +pub fn decode_statement_data(statement: &[u8]) -> Result, StatementStoreParseError> { + statement_data_from_fields(decode_statement_fields(statement)?) +} + +/// Verify statement proof and extract signer, expiry, and raw `Data` field. +pub fn decode_verified_statement_data( + statement: &[u8], + expected_signer: Option<[u8; 32]>, +) -> Result { + let fields = decode_statement_fields(statement)?; + let signer = verify_statement_proof(&fields, expected_signer)?; + let expiry = fields.iter().find_map(|field| match field { + StatementField::Expiry(value) => Some(*value), + _ => None, + }); + let data = statement_data_from_fields(fields)?; + Ok(VerifiedStatementData { + data, + signer, + expiry, + }) +} + +/// Whether a statement `Expiry` field (unix seconds in the upper 32 bits) is +/// in the past relative to `now_unix_secs`. +pub fn statement_expiry_elapsed(expiry: u64, now_unix_secs: u64) -> bool { + (expiry >> 32) < now_unix_secs +} + +/// Current unix time in seconds, used to stamp outgoing statement expiries +/// and to gate inbound statement freshness. Trusts the local clock on both +/// native and wasm targets. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn current_unix_secs() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Current unix time in seconds on wasm32, sourced from the JS clock. +#[cfg(target_arch = "wasm32")] +pub(crate) fn current_unix_secs() -> u64 { + (js_sys::Date::now() / 1000.0) as u64 +} + +/// Decode a SCALE signed statement into the public v01 statement shape. +pub fn decode_signed_statement( + statement: &[u8], +) -> Result { + signed_statement_from_fields(decode_statement_fields(statement)?) +} + +/// Build a signed statement on the active SSO request channel. +pub fn build_signed_session_request_statement( + session: &SsoSessionInfo, + encrypted_data: Vec, + expiry: u64, +) -> Result, String> { + build_signed_statement( + session, + session.request_channel, + session.session_id_own, + encrypted_data, + expiry, + ) +} + +/// Build a signed statement for an arbitrary channel/topic pair. +pub fn build_signed_statement( + session: &SsoSessionInfo, + channel: [u8; 32], + topic1: [u8; 32], + data: Vec, + expiry: u64, +) -> Result, String> { + let fields = vec![ + StatementField::Expiry(expiry), + StatementField::Channel(channel), + StatementField::Topic1(topic1), + StatementField::Data(data), + ]; + sign_statement_fields(session.ss_secret, session.ss_public_key, fields) + .map(|fields| fields.encode()) +} + +/// Sort fields, insert an sr25519 proof, and return signed fields. +pub fn sign_statement_fields( + ss_secret: [u8; 64], + expected_public_key: [u8; 32], + mut fields: Vec, +) -> Result, String> { + if fields + .iter() + .any(|field| matches!(field, StatementField::Proof(_))) + { + return Err("statement is already signed".to_string()); + } + fields.sort_by_key(statement_field_sort_index); + + let secret = + SecretKey::from_bytes(&ss_secret).map_err(|err| format!("invalid ss_secret: {err}"))?; + let public = secret.to_public(); + if public.to_bytes() != expected_public_key { + return Err("ss_secret does not match session statement public key".to_string()); + } + + let signing_payload = statement_signing_payload(&fields)?; + let signature = secret + .sign_simple(SR25519_SIGNING_CONTEXT, &signing_payload, &public) + .to_bytes(); + + let mut signed = Vec::with_capacity(fields.len() + 1); + signed.push(StatementField::Proof(StatementProof::Sr25519 { + signature, + signer: expected_public_key, + })); + signed.extend(fields); + Ok(signed) +} + +/// Build the statement signing payload from sorted fields. +pub fn statement_signing_payload(fields: &[StatementField]) -> Result, String> { + let encoded = fields.to_vec().encode(); + let mut input = encoded.as_slice(); + let _: Compact = + Decode::decode(&mut input).map_err(|err| format!("invalid statement vector: {err}"))?; + let compact_len = encoded.len() - input.len(); + Ok(encoded[compact_len..].to_vec()) +} + +fn decode_statement_fields( + statement: &[u8], +) -> Result, StatementStoreParseError> { + let mut input = statement; + let fields: Vec = Decode::decode(&mut input) + .map_err(|err| StatementStoreParseError::InvalidStatementScale(err.to_string()))?; + if !input.is_empty() { + return Err(StatementStoreParseError::Malformed( + "statement has trailing bytes".to_string(), + )); + } + Ok(fields) +} + +fn statement_data_from_fields( + fields: Vec, +) -> Result, StatementStoreParseError> { + fields + .into_iter() + .find_map(|field| match field { + StatementField::Data(value) => Some(value), + _ => None, + }) + .ok_or_else(|| StatementStoreParseError::Malformed("statement has no data".to_string())) +} + +fn verify_statement_proof( + fields: &[StatementField], + expected_signer: Option<[u8; 32]>, +) -> Result<[u8; 32], StatementStoreParseError> { + let mut proof = None; + let mut unsigned_fields = Vec::with_capacity(fields.len().saturating_sub(1)); + for field in fields { + match field { + StatementField::Proof(StatementProof::Sr25519 { signature, signer }) => { + if proof.replace((*signature, *signer)).is_some() { + return Err(StatementStoreParseError::InvalidStatementProof( + "statement has duplicate proof".to_string(), + )); + } + } + StatementField::Proof(_) => { + return Err(StatementStoreParseError::InvalidStatementProof( + "statement proof is not sr25519".to_string(), + )); + } + field => unsigned_fields.push(field.clone()), + } + } + let (signature, signer) = proof.ok_or_else(|| { + StatementStoreParseError::InvalidStatementProof("statement has no proof".to_string()) + })?; + if let Some(expected) = expected_signer + && signer != expected + { + return Err(StatementStoreParseError::InvalidStatementProof( + "statement proof signer does not match expected peer".to_string(), + )); + } + + unsigned_fields.sort_by_key(statement_field_sort_index); + let payload = + statement_signing_payload(&unsigned_fields).map_err(StatementStoreParseError::Malformed)?; + let public = PublicKey::from_bytes(&signer).map_err(|err| { + StatementStoreParseError::InvalidStatementProof(format!("invalid sr25519 signer: {err}")) + })?; + let signature = Signature::from_bytes(&signature).map_err(|err| { + StatementStoreParseError::InvalidStatementProof(format!("invalid sr25519 signature: {err}")) + })?; + public + .verify_simple(SR25519_SIGNING_CONTEXT, &payload, &signature) + .map_err(|err| { + StatementStoreParseError::InvalidStatementProof(format!( + "sr25519 signature verification failed: {err}" + )) + })?; + Ok(signer) +} + +/// Convert a public v01 statement into SCALE statement fields. +pub fn statement_fields_from_v01(statement: v01::Statement) -> Result, String> { + let mut fields = Vec::new(); + if let Some(proof) = statement.proof { + fields.push(StatementField::Proof(statement_proof_from_v01(proof))); + } + if let Some(decryption_key) = statement.decryption_key { + fields.push(StatementField::DecryptionKey(decryption_key)); + } + if let Some(expiry) = statement.expiry { + fields.push(StatementField::Expiry(expiry)); + } + if let Some(channel) = statement.channel { + fields.push(StatementField::Channel(channel)); + } + push_statement_topics(&mut fields, statement.topics)?; + if let Some(data) = statement.data { + fields.push(StatementField::Data(data)); + } + Ok(fields) +} + +/// Convert a public v01 signed statement into SCALE bytes. +pub fn signed_statement_to_scale(statement: v01::SignedStatement) -> Result, String> { + Ok(signed_statement_fields(statement)?.encode()) +} + +fn signed_statement_fields(statement: v01::SignedStatement) -> Result, String> { + let mut fields = vec![StatementField::Proof(statement_proof_from_v01( + statement.proof, + ))]; + if let Some(decryption_key) = statement.decryption_key { + fields.push(StatementField::DecryptionKey(decryption_key)); + } + if let Some(expiry) = statement.expiry { + fields.push(StatementField::Expiry(expiry)); + } + if let Some(channel) = statement.channel { + fields.push(StatementField::Channel(channel)); + } + push_statement_topics(&mut fields, statement.topics)?; + if let Some(data) = statement.data { + fields.push(StatementField::Data(data)); + } + fields.sort_by_key(statement_field_sort_index); + Ok(fields) +} + +fn signed_statement_from_fields( + fields: Vec, +) -> Result { + let mut proof = None; + let mut decryption_key = None; + let mut expiry = None; + let mut channel = None; + let mut topics = Vec::new(); + let mut data = None; + + for field in fields { + match field { + StatementField::Proof(value) => { + if proof.replace(statement_proof_to_v01(value)).is_some() { + return Err(StatementStoreParseError::Malformed( + "statement has duplicate proof".to_string(), + )); + } + } + StatementField::DecryptionKey(value) => { + if decryption_key.replace(value).is_some() { + return Err(StatementStoreParseError::Malformed( + "statement has duplicate decryption key".to_string(), + )); + } + } + StatementField::Expiry(value) => { + if expiry.replace(value).is_some() { + return Err(StatementStoreParseError::Malformed( + "statement has duplicate expiry".to_string(), + )); + } + } + StatementField::Channel(value) => { + if channel.replace(value).is_some() { + return Err(StatementStoreParseError::Malformed( + "statement has duplicate channel".to_string(), + )); + } + } + StatementField::Topic1(value) + | StatementField::Topic2(value) + | StatementField::Topic3(value) + | StatementField::Topic4(value) => topics.push(value), + StatementField::Data(value) => { + if data.replace(value).is_some() { + return Err(StatementStoreParseError::Malformed( + "statement has duplicate data".to_string(), + )); + } + } + } + } + + let proof = proof + .ok_or_else(|| StatementStoreParseError::Malformed("statement has no proof".to_string()))?; + Ok(v01::SignedStatement { + proof, + decryption_key, + expiry, + channel, + topics, + data, + }) +} + +/// Convert an internal proof into the public v01 proof shape. +pub fn statement_proof_to_v01(proof: StatementProof) -> v01::StatementProof { + match proof { + StatementProof::Sr25519 { signature, signer } => { + v01::StatementProof::Sr25519 { signature, signer } + } + StatementProof::Ed25519 { signature, signer } => { + v01::StatementProof::Ed25519 { signature, signer } + } + StatementProof::Ecdsa { signature, signer } => { + v01::StatementProof::Ecdsa { signature, signer } + } + StatementProof::OnChain { + who, + block_hash, + event, + } => v01::StatementProof::OnChain { + who, + block_hash, + event, + }, + } +} + +fn statement_proof_from_v01(proof: v01::StatementProof) -> StatementProof { + match proof { + v01::StatementProof::Sr25519 { signature, signer } => { + StatementProof::Sr25519 { signature, signer } + } + v01::StatementProof::Ed25519 { signature, signer } => { + StatementProof::Ed25519 { signature, signer } + } + v01::StatementProof::Ecdsa { signature, signer } => { + StatementProof::Ecdsa { signature, signer } + } + v01::StatementProof::OnChain { + who, + block_hash, + event, + } => StatementProof::OnChain { + who, + block_hash, + event, + }, + } +} + +fn push_statement_topics( + fields: &mut Vec, + topics: Vec<[u8; 32]>, +) -> Result<(), String> { + if topics.len() > 4 { + return Err(format!( + "statement has {} topics, maximum is 4", + topics.len() + )); + } + for (index, topic) in topics.into_iter().enumerate() { + fields.push(match index { + 0 => StatementField::Topic1(topic), + 1 => StatementField::Topic2(topic), + 2 => StatementField::Topic3(topic), + 3 => StatementField::Topic4(topic), + _ => unreachable!("topic count checked above"), + }); + } + Ok(()) +} + +fn statement_field_sort_index(field: &StatementField) -> u8 { + // Keep in sync with upstream `sp_statement_store::Field` discriminants: + // https://github.com/paritytech/polkadot-sdk/blob/f2f3aa6a8fda8ea52282da9711b3c5da4ba82529/substrate/primitives/statement-store/src/lib.rs#L314-L337 + match field { + StatementField::Proof(_) => 0, + StatementField::DecryptionKey(_) => 1, + StatementField::Expiry(_) => 2, + StatementField::Channel(_) => 3, + StatementField::Topic1(_) => 4, + StatementField::Topic2(_) => 5, + StatementField::Topic3(_) => 6, + StatementField::Topic4(_) => 7, + StatementField::Data(_) => 8, + } +} + +/// Format a 32-byte statement-store topic as `0x`-prefixed hex. +pub fn hex_topic(topic: &[u8; 32]) -> String { + format!("0x{}", hex::encode(topic)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_logic::session::SsoSessionInfo; + use schnorrkel::{ExpansionMode, MiniSecretKey, PublicKey, Signature}; + + fn test_session() -> SsoSessionInfo { + let mini_secret = MiniSecretKey::from_bytes(&[7; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + SsoSessionInfo { + ss_secret: keypair.secret.to_bytes(), + ss_public_key: keypair.public.to_bytes(), + enc_secret: [1; 32], + peer_enc_pubkey: [2; 65], + identity_account_id: [3; 32], + session_id_own: [4; 32], + session_id_peer: [5; 32], + request_channel: [6; 32], + response_channel: [7; 32], + peer_request_channel: [8; 32], + } + } + + #[test] + fn decodes_statement_data_field() { + let statement = vec![ + StatementField::Proof(StatementProof::Sr25519 { + signature: [1; 64], + signer: [2; 32], + }), + StatementField::Expiry(42), + StatementField::Channel([3; 32]), + StatementField::Topic1([4; 32]), + StatementField::Data(vec![0xde, 0xad, 0xbe, 0xef]), + ] + .encode(); + + assert_eq!( + decode_statement_data(&statement).unwrap(), + vec![0xde, 0xad, 0xbe, 0xef] + ); + } + + #[test] + fn signed_statement_scale_round_trips_public_shape() { + let signed = v01::SignedStatement { + proof: v01::StatementProof::Sr25519 { + signature: [9; 64], + signer: [8; 32], + }, + decryption_key: Some([7; 32]), + expiry: Some(99), + channel: Some([6; 32]), + topics: vec![[1; 32], [2; 32]], + data: Some(vec![3, 4, 5]), + }; + + let encoded = signed_statement_to_scale(signed.clone()).unwrap(); + + assert_eq!(decode_signed_statement(&encoded).unwrap(), signed); + } + + #[test] + fn signing_payload_strips_scale_vec_compact_len() { + let fields = vec![ + StatementField::Expiry(42), + StatementField::Channel([3; 32]), + StatementField::Topic1([4; 32]), + StatementField::Data(vec![0xde, 0xad, 0xbe, 0xef]), + ]; + let encoded = fields.encode(); + + assert_eq!(encoded[0], 16); + assert_eq!(statement_signing_payload(&fields).unwrap(), encoded[1..]); + } + + #[test] + fn builds_signed_session_request_statement() { + let session = test_session(); + + let statement = + build_signed_session_request_statement(&session, vec![0xde, 0xad], 42).unwrap(); + let mut input = statement.as_slice(); + let fields = Vec::::decode(&mut input).unwrap(); + + assert!(input.is_empty()); + assert_eq!(fields.len(), 5); + let StatementField::Proof(StatementProof::Sr25519 { signature, signer }) = fields[0] else { + panic!("expected sr25519 proof"); + }; + assert_eq!(signer, session.ss_public_key); + assert_eq!(fields[1], StatementField::Expiry(42)); + assert_eq!(fields[2], StatementField::Channel(session.request_channel)); + assert_eq!(fields[3], StatementField::Topic1(session.session_id_own)); + assert_eq!(fields[4], StatementField::Data(vec![0xde, 0xad])); + + let payload = statement_signing_payload(&fields[1..]).unwrap(); + let public = PublicKey::from_bytes(&signer).unwrap(); + let signature = Signature::from_bytes(&signature).unwrap(); + public + .verify_simple(SR25519_SIGNING_CONTEXT, &payload, &signature) + .unwrap(); + } + + #[test] + fn verified_statement_data_accepts_valid_sr25519_proof() { + let session = test_session(); + let statement = + build_signed_session_request_statement(&session, vec![0xde, 0xad], 42).unwrap(); + + let verified = + decode_verified_statement_data(&statement, Some(session.ss_public_key)).unwrap(); + + assert_eq!( + verified, + VerifiedStatementData { + data: vec![0xde, 0xad], + signer: session.ss_public_key, + expiry: Some(42), + } + ); + } + + #[test] + fn verified_statement_data_rejects_tampered_signature() { + let session = test_session(); + let statement = + build_signed_session_request_statement(&session, vec![0xde, 0xad], 42).unwrap(); + let mut fields = Vec::::decode(&mut statement.as_slice()).unwrap(); + let StatementField::Proof(StatementProof::Sr25519 { signature, .. }) = &mut fields[0] + else { + panic!("expected sr25519 proof"); + }; + signature[0] ^= 0xff; + + let err = decode_verified_statement_data(&fields.encode(), Some(session.ss_public_key)) + .unwrap_err(); + + assert!( + matches!(err, StatementStoreParseError::InvalidStatementProof(reason) if reason.contains("signature verification failed")) + ); + } + + #[test] + fn verified_statement_data_rejects_wrong_expected_signer() { + let session = test_session(); + let statement = + build_signed_session_request_statement(&session, vec![0xde, 0xad], 42).unwrap(); + + assert_eq!( + decode_verified_statement_data(&statement, Some([0xaa; 32])).unwrap_err(), + StatementStoreParseError::InvalidStatementProof( + "statement proof signer does not match expected peer".to_string() + ) + ); + } + + #[test] + fn signing_rejects_mismatched_session_key_material() { + let mut session = test_session(); + session.ss_public_key = [0xff; 32]; + + assert_eq!( + build_signed_session_request_statement(&session, vec![0xde], 42).unwrap_err(), + "ss_secret does not match session statement public key" + ); + } + + #[test] + fn signing_rejects_already_signed_statements() { + let session = test_session(); + let fields = vec![StatementField::Proof(StatementProof::Sr25519 { + signature: [1; 64], + signer: session.ss_public_key, + })]; + + assert_eq!( + sign_statement_fields(session.ss_secret, session.ss_public_key, fields).unwrap_err(), + "statement is already signed" + ); + } + + #[test] + fn rejects_statement_without_data_field() { + let statement = vec![StatementField::Expiry(42)].encode(); + + assert_eq!( + decode_statement_data(&statement).unwrap_err(), + StatementStoreParseError::Malformed("statement has no data".to_string()) + ); + } +} diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs new file mode 100644 index 00000000..8b56ec56 --- /dev/null +++ b/rust/crates/truapi-server/src/lib.rs @@ -0,0 +1,9 @@ +//! TrUAPI server runtime support. +//! +//! This layer contains host-agnostic logic shared by the runtime and target +//! adapters. Wire dispatch and platform runtime wiring are added by later stack +//! layers. + +#![forbid(unsafe_code)] + +pub mod host_logic; From c8c4595541ce27ab5596d0803e961b7faaa0cee7 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 1 Jul 2026 22:36:10 +0200 Subject: [PATCH 15/56] fixup! feat(truapi-server): add host logic primitives --- .../src/host_logic/sso/messages.rs | 365 +++++++++++++++--- .../src/host_logic/sso/pairing.rs | 7 +- 2 files changed, 315 insertions(+), 57 deletions(-) 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 31b69045..6d782242 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -1,17 +1,22 @@ -//! SCALE codecs for host-papp 0.7.9 SSO session-channel messages. +//! SCALE codecs for host-papp SSO session-channel messages. //! //! These are the encrypted payloads carried inside statement-store //! `SsoStatementData::Request` / `Response` frames. -//! The remote-message and signing codecs mirror host-papp: -//! -//! +//! The runtime builds them when forwarding TrUAPI account, signing, resource +//! allocation, and transaction requests to the paired wallet, then decodes the +//! wallet's responses while waiting on the SSO statement-store channels. +//! Field order and enum variant order are kept wire-compatible with host-papp: +//! +//! +//! +//! use parity_scale_codec::{Decode, Encode, OptionBool}; use truapi::latest::{ - AllocatableResource, HostAccountGetAliasResponse, ProductAccountId, ProductAccountTxPayload, + AccountId, AllocatableResource, HostAccountGetAliasResponse, HostSignPayloadRequest, + HostSignRawRequest, LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, RawPayload, }; -use truapi::v01::{HostSignPayloadRequest, HostSignRawRequest}; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::pairing::{ @@ -40,7 +45,10 @@ pub enum RemoteMessageData { V1(RemoteMessageV1), } -/// Host-papp v1 remote message variants. +/// v1 messages exchanged with the paired wallet over the encrypted SSO channel. +/// +/// The variant order is part of the SCALE wire protocol used inside +/// statement-store session statements. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum RemoteMessageV1 { Disconnected, @@ -52,6 +60,9 @@ pub enum RemoteMessageV1 { ResourceAllocationResponse(ResourceAllocationResponse), CreateTransactionRequest(CreateTransactionRequest), CreateTransactionResponse(CreateTransactionResponse), + CreateTransactionLegacyRequest(CreateTransactionLegacyRequest), + SignRawLegacyRequest(SignRawLegacyRequest), + SignRawLegacyResponse(SignRawLegacyResponse), } /// Signing request flavor sent to the wallet. @@ -61,7 +72,12 @@ pub enum SigningRequest { Raw(SigningRawRequest), } -/// Product-account payload signing request mirrored from host-papp. +/// Request sent when a product asks the paired wallet to sign a Substrate +/// payload with a product-derived account. +/// +/// Built from [`HostSignPayloadRequest`] and wrapped in +/// [`RemoteMessageV1::SignRequest`] before being encrypted into an SSO session +/// statement. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SigningPayloadRequest { pub product_account_id: ProductAccountId, @@ -82,47 +98,63 @@ pub struct SigningPayloadRequest { pub with_signed_transaction: OptionBool, } -impl From for SigningPayloadRequest { - fn from(value: HostSignPayloadRequest) -> Self { - let payload = value.payload; - Self { - product_account_id: value.account, - block_hash: payload.block_hash, - block_number: payload.block_number, - era: payload.era, - genesis_hash: payload.genesis_hash, - method: payload.method, - nonce: payload.nonce, - spec_version: payload.spec_version, - tip: payload.tip, - transaction_version: payload.transaction_version, - signed_extensions: payload.signed_extensions, - version: payload.version, - asset_id: payload.asset_id, - metadata_hash: payload.metadata_hash, - mode: payload.mode, - with_signed_transaction: OptionBool(payload.with_signed_transaction), - } +fn signing_payload_request_from(value: HostSignPayloadRequest) -> SigningPayloadRequest { + let payload = value.payload; + SigningPayloadRequest { + product_account_id: value.account, + block_hash: payload.block_hash, + block_number: payload.block_number, + era: payload.era, + genesis_hash: payload.genesis_hash, + method: payload.method, + nonce: payload.nonce, + spec_version: payload.spec_version, + tip: payload.tip, + transaction_version: payload.transaction_version, + signed_extensions: payload.signed_extensions, + version: payload.version, + asset_id: payload.asset_id, + metadata_hash: payload.metadata_hash, + mode: payload.mode, + with_signed_transaction: OptionBool(payload.with_signed_transaction), } } -/// Raw signing request mirrored from host-papp. +/// Request sent when a product asks the paired wallet to sign raw bytes or a +/// string message with a product-derived account. +/// +/// Built from [`HostSignRawRequest`] and wrapped in +/// [`RemoteMessageV1::SignRequest`] before being encrypted into an SSO session +/// statement. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SigningRawRequest { pub product_account_id: ProductAccountId, pub data: SigningRawPayload, } -impl From for SigningRawRequest { - fn from(value: HostSignRawRequest) -> Self { - Self { - product_account_id: value.account, - data: value.payload.into(), - } +fn signing_raw_request_from(value: HostSignRawRequest) -> SigningRawRequest { + SigningRawRequest { + product_account_id: value.account, + data: value.payload.into(), } } -/// Raw signing payload shape mirrored from host-papp. +/// Request sent when a product asks the paired wallet to sign raw data with a +/// user-imported legacy account. +/// +/// Unlike product-account signing, the signer is the raw account id selected +/// from the user's legacy accounts. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SignRawLegacyRequest { + pub account: AccountId, + pub data: SigningRawPayload, +} + +/// Raw data accepted by SSO signing requests. +/// +/// Used by both product-account raw signing and legacy-account raw signing to +/// distinguish binary payloads from string messages on the session-channel +/// wire. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SigningRawPayload { Bytes(Vec), @@ -138,35 +170,56 @@ impl From for SigningRawPayload { } } -/// Wallet response to a signing request. +/// Response returned by the wallet for a product-account signing request. +/// +/// Decoded from [`RemoteMessageV1::SignResponse`] while the runtime is waiting +/// for a matching SSO remote message id. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SigningResponse { pub responding_to: String, pub payload: Result, } -/// Successful signing response payload. +/// Successful product-account signing result returned by the wallet. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SigningPayloadResponseData { pub signature: Vec, pub signed_transaction: Option>, } -/// Wallet alias request for a product account. +/// Response returned by the wallet for a legacy-account raw signing request. +/// +/// Decoded from [`RemoteMessageV1::SignRawLegacyResponse`] and mapped back to +/// the public raw-signing response shape. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SignRawLegacyResponse { + pub responding_to: String, + pub signature: Result, String>, +} + +/// Request sent when a product asks the wallet for a ring-VRF alias. +/// +/// Used by `Account::get_account_alias`; the product account identifies the +/// alias target, while `product_id` identifies the caller that the wallet is +/// authorizing over the SSO channel. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RingVrfAliasRequest { pub product_account_id: ProductAccountId, pub product_id: String, } -/// Wallet alias response. +/// Response returned by the wallet for a ring-VRF alias request. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RingVrfAliasResponse { pub responding_to: String, pub payload: Result, } -/// Wallet resource-allocation request. +/// Request sent when a product asks the wallet to allocate SSO-backed +/// resources. +/// +/// Used by `ResourceAllocation::request` for capabilities such as statement +/// store allowance and auto-signing material. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct ResourceAllocationRequest { pub calling_product_id: String, @@ -203,7 +256,7 @@ pub enum OnExistingAllowancePolicy { Increase, } -/// Wallet resource-allocation response. +/// Response returned by the wallet for a resource-allocation request. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct ResourceAllocationResponse { pub responding_to: String, @@ -234,7 +287,8 @@ pub enum SsoAllocatedResource { }, } -/// Wallet transaction-creation request. +/// Request sent when a product asks the wallet to create a signed transaction +/// for a product-derived account. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct CreateTransactionRequest { pub payload: CreateTransactionPayload, @@ -246,7 +300,21 @@ pub enum CreateTransactionPayload { V1(ProductAccountTxPayload), } -/// Wallet transaction-creation response. +/// Request sent when a product asks the wallet to create a signed transaction +/// for a user-imported legacy account. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct CreateTransactionLegacyRequest { + pub payload: CreateTransactionLegacyPayload, +} + +/// Versioned legacy transaction-creation payload. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum CreateTransactionLegacyPayload { + V1(LegacyAccountTxPayload), +} + +/// Response returned by the wallet for either product-account or legacy-account +/// transaction creation. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct CreateTransactionResponse { pub responding_to: String, @@ -265,6 +333,7 @@ pub enum SsoSessionStatement { #[derive(Debug, Clone, PartialEq, Eq)] pub enum SsoRemoteResponse { Sign(SigningResponse), + SignRawLegacy(SignRawLegacyResponse), RingVrfAlias(RingVrfAliasResponse), ResourceAllocation(ResourceAllocationResponse), CreateTransaction(CreateTransactionResponse), @@ -362,6 +431,11 @@ fn remote_response_for_message( { Some(SsoRemoteResponse::RingVrfAlias(response)) } + RemoteMessageV1::SignRawLegacyResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::SignRawLegacy(response)) + } RemoteMessageV1::ResourceAllocationResponse(response) if response.responding_to == expected_remote_message_id => { @@ -378,10 +452,9 @@ fn remote_response_for_message( fn sso_response_code_name(code: u8) -> &'static str { match code { - 1 => "decodingFailed", - 2 => "decryptionFailed", - 3 => "unknown", - _ => "unrecognized response code", + 1 => "decryptionFailed", + 2 => "decodingFailed", + _ => "unknown", } } @@ -390,7 +463,7 @@ pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) RemoteMessage { message_id, data: RemoteMessageData::V1(RemoteMessageV1::SignRequest(Box::new( - SigningRequest::Payload(Box::new(request.into())), + SigningRequest::Payload(Box::new(signing_payload_request_from(request))), ))), } } @@ -400,11 +473,28 @@ pub fn sign_raw_message(message_id: String, request: HostSignRawRequest) -> Remo RemoteMessage { message_id, data: RemoteMessageData::V1(RemoteMessageV1::SignRequest(Box::new(SigningRequest::Raw( - request.into(), + signing_raw_request_from(request), )))), } } +/// Build a wallet legacy raw-signing request message. +pub fn sign_raw_legacy_message( + message_id: String, + account: AccountId, + payload: RawPayload, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(RemoteMessageV1::SignRawLegacyRequest( + SignRawLegacyRequest { + account, + data: payload.into(), + }, + )), + } +} + /// Build a wallet account-alias request message. pub fn alias_request_message( message_id: String, @@ -454,6 +544,21 @@ pub fn create_transaction_message( } } +/// Build a wallet legacy-account transaction-creation request message. +pub fn create_transaction_legacy_message( + message_id: String, + payload: LegacyAccountTxPayload, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(RemoteMessageV1::CreateTransactionLegacyRequest( + CreateTransactionLegacyRequest { + payload: CreateTransactionLegacyPayload::V1(payload), + }, + )), + } +} + /// Build a signed outbound SSO request statement with a random nonce. pub fn build_outgoing_request_statement( session: &SsoSessionInfo, @@ -526,6 +631,7 @@ mod tests { use p256::elliptic_curve::sec1::ToEncodedPoint; use schnorrkel::{ExpansionMode, MiniSecretKey}; use truapi::latest::HostSignPayloadData; + use truapi::v01; fn account() -> ProductAccountId { ProductAccountId { @@ -595,6 +701,157 @@ mod tests { assert_eq!(encoded[5], 1); } + #[test] + fn late_remote_message_variants_match_host_papp_order() { + let legacy_tx = create_transaction_legacy_message( + String::new(), + v01::LegacyAccountTxPayload { + signer: [1; 32], + genesis_hash: [2; 32], + call_data: Vec::new(), + extensions: Vec::new(), + tx_ext_version: 0, + }, + ) + .encode(); + let legacy_raw = + sign_raw_legacy_message(String::new(), [1; 32], RawPayload::Bytes { bytes: vec![] }) + .encode(); + + assert_eq!(legacy_tx[..3], [0, 0, 9]); + assert_eq!(legacy_raw[..3], [0, 0, 10]); + } + + fn sequential_bytes(start: u8) -> [u8; N] { + std::array::from_fn(|index| start.wrapping_add(index as u8)) + } + + fn assert_host_papp_0_8_8_fixture(message: RemoteMessage, expected: &str) { + assert_eq!( + hex::encode(message.encode()), + expected.trim_start_matches("0x") + ); + } + + #[test] + fn resource_allocation_message_matches_host_papp_0_8_8_fixture() { + let message = resource_allocation_message( + "m-resource".to_string(), + "truapi-playground.dot".to_string(), + vec![ + AllocatableResource::StatementStoreAllowance, + AllocatableResource::BulletinAllowance, + AllocatableResource::SmartContractAllowance(9), + AllocatableResource::AutoSigning, + ], + OnExistingAllowancePolicy::Increase, + ); + + assert_host_papp_0_8_8_fixture( + message, + "0x286d2d7265736f757263650005547472756170692d706c617967726f756e642e646f7410000102090000000301", + ); + } + + #[test] + fn create_transaction_message_matches_host_papp_0_8_8_fixture() { + let message = create_transaction_message( + "m-product-tx".to_string(), + v01::ProductAccountTxPayload { + signer: v01::ProductAccountId { + dot_ns_identifier: "truapi-playground.dot".to_string(), + derivation_index: 0, + }, + genesis_hash: sequential_bytes(32), + call_data: vec![0, 0], + extensions: vec![v01::TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![1], + additional_signed: vec![2, 3], + }], + tx_ext_version: 0, + }, + ); + + assert_host_papp_0_8_8_fixture( + message, + "0x306d2d70726f647563742d7478000700547472756170692d706c617967726f756e642e646f7400000000202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f0800000428436865636b4e6f6e6365040108020300", + ); + } + + #[test] + fn playground_create_transaction_message_matches_host_papp_0_8_8_fixture() { + let message = create_transaction_message( + "create-transaction-1".to_string(), + v01::ProductAccountTxPayload { + signer: v01::ProductAccountId { + dot_ns_identifier: "truapi-playground.dot".to_string(), + derivation_index: 0, + }, + genesis_hash: [ + 0xbf, 0x04, 0x88, 0xdb, 0xe9, 0xda, 0xa1, 0xde, 0x1c, 0x08, 0xc5, 0xf7, 0x43, + 0xe2, 0x6f, 0xdc, 0x2a, 0x4e, 0xcd, 0x74, 0xcf, 0x87, 0xdd, 0x1b, 0x4b, 0x1e, + 0xeb, 0x99, 0xae, 0x4e, 0xf1, 0x9f, + ], + call_data: vec![0, 0], + extensions: vec![], + tx_ext_version: 0, + }, + ); + + assert_host_papp_0_8_8_fixture( + message, + "0x506372656174652d7472616e73616374696f6e2d31000700547472756170692d706c617967726f756e642e646f7400000000bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f0800000000", + ); + } + + #[test] + fn create_transaction_legacy_message_matches_host_papp_0_8_8_fixture() { + let message = create_transaction_legacy_message( + "m-legacy-tx".to_string(), + v01::LegacyAccountTxPayload { + signer: sequential_bytes(0), + genesis_hash: sequential_bytes(32), + call_data: vec![0, 0], + extensions: vec![v01::TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![1], + additional_signed: vec![2, 3], + }], + tx_ext_version: 0, + }, + ); + + assert_host_papp_0_8_8_fixture( + message, + "0x2c6d2d6c65676163792d7478000900000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f0800000428436865636b4e6f6e6365040108020300", + ); + } + + #[test] + fn sign_raw_legacy_messages_match_host_papp_0_8_8_fixtures() { + assert_host_papp_0_8_8_fixture( + sign_raw_legacy_message( + "m-legacy-raw".to_string(), + sequential_bytes(0), + RawPayload::Bytes { + bytes: b"Hi".to_vec(), + }, + ), + "0x306d2d6c65676163792d726177000a000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f00084869", + ); + assert_host_papp_0_8_8_fixture( + sign_raw_legacy_message( + "m-legacy-raw-payload".to_string(), + sequential_bytes(0), + RawPayload::Payload { + payload: "Hi".to_string(), + }, + ), + "0x506d2d6c65676163792d7261772d7061796c6f6164000a000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f01443c42797465733e48693c2f42797465733e", + ); + } + #[test] fn option_bool_matches_host_papp_option_bool_encoding() { let mut request = HostSignPayloadRequest { @@ -617,11 +874,11 @@ mod tests { with_signed_transaction: Some(true), }, }; - let true_encoded = SigningPayloadRequest::from(request.clone()).encode(); + let true_encoded = signing_payload_request_from(request.clone()).encode(); request.payload.with_signed_transaction = Some(false); - let false_encoded = SigningPayloadRequest::from(request.clone()).encode(); + let false_encoded = signing_payload_request_from(request.clone()).encode(); request.payload.with_signed_transaction = None; - let none_encoded = SigningPayloadRequest::from(request).encode(); + let none_encoded = signing_payload_request_from(request).encode(); assert_eq!(true_encoded.last(), Some(&1)); assert_eq!(false_encoded.last(), Some(&2)); 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 3efecba5..f6e16065 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -2,8 +2,9 @@ //! //! This module owns the byte shape of the QR/deeplink payload described in //! `docs/design/host-contract-and-core-impl/H - sso-pairing-protocol.md`. -//! The SCALE handshake codecs mirror host-papp's v2 handshake codec: -//! +//! The SCALE handshake codecs are kept wire-compatible with host-papp's v2 +//! handshake codec: +//! use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Nonce}; @@ -134,7 +135,7 @@ pub struct HandshakeSuccessV2 { /// Encrypted statement-channel envelope shared with the wallet. /// /// Mirrors `@novasamatech/statement-store` session statement data: -/// +/// #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SsoStatementData { Request { From 88cf1a903960718c9795473b18363d384cf6c2e6 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 2 Jul 2026 10:53:15 +0200 Subject: [PATCH 16/56] fixup! feat(truapi-server): add host logic primitives --- .../truapi-server/src/host_logic/sso/pairing.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 f6e16065..73e7d402 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -509,20 +509,21 @@ mod tests { ]; fn runtime_config() -> RuntimeConfig { - RuntimeConfig { - product_id: "myapp.dot".to_string(), - host_info: HostInfo { + RuntimeConfig::new( + "myapp.dot".to_string(), + HostInfo { name: "Polkadot Web".to_string(), icon: Some("https://example.invalid/dotli.png".to_string()), version: Some("1.2.3".to_string()), }, - platform_info: PlatformInfo { + PlatformInfo { kind: Some("Firefox".to_string()), version: Some("192.32".to_string()), }, - people_chain_genesis_hash: [0; 32], - pairing_deeplink_scheme: "polkadotapp".to_string(), - } + [0; 32], + "polkadotapp".to_string(), + ) + .expect("test runtime config is valid") } #[test] From 2d88bdf5edd29a3240a33fe4841c1f713855213d Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 17:13:20 +0200 Subject: [PATCH 17/56] fixup! feat(truapi-server): add host logic primitives --- Cargo.lock | 14 ++ rust/crates/truapi-server/Cargo.toml | 1 + .../truapi-server/src/host_logic/entropy.rs | 38 +++- .../src/host_logic/product_account.rs | 116 ++++++++--- .../truapi-server/src/host_logic/session.rs | 72 ++++--- .../src/host_logic/sso/messages.rs | 182 ++++++++++-------- .../src/host_logic/sso/pairing.rs | 122 ++++-------- .../src/host_logic/sso/pairing/v2.rs | 57 ++++++ .../host_logic/statement_store/statement.rs | 100 +++++----- 9 files changed, 423 insertions(+), 279 deletions(-) create mode 100644 rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs diff --git a/Cargo.lock b/Cargo.lock index 23c9c80a..4b514a60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2901,6 +2901,19 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "substrate-bip39" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93affb0135879b1b67cbcf6370a256e1772f9eaaece3899ec20966d67ad0492" +dependencies = [ + "hmac 0.12.1", + "pbkdf2", + "schnorrkel", + "sha2 0.10.9", + "zeroize", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3280,6 +3293,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sp-crypto-hashing", + "substrate-bip39", "subxt-rpcs", "thiserror 1.0.69", "tracing", diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 6cecb5f6..a4a86a0c 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -30,6 +30,7 @@ blake2-rfc = { version = "0.2", default-features = false } sp-crypto-hashing = { version = "0.1", default-features = false } bs58 = { version = "0.5", default-features = false, features = ["alloc"] } schnorrkel = { version = "0.11.5", default-features = false, features = ["alloc", "getrandom"] } +substrate-bip39 = { version = "0.6", default-features = false } getrandom = { version = "0.2", features = ["js"] } p256 = { version = "0.13", default-features = false, features = ["ecdh"] } hkdf = "0.12" diff --git a/rust/crates/truapi-server/src/host_logic/entropy.rs b/rust/crates/truapi-server/src/host_logic/entropy.rs index 68e9e1fb..1e9b4193 100644 --- a/rust/crates/truapi-server/src/host_logic/entropy.rs +++ b/rust/crates/truapi-server/src/host_logic/entropy.rs @@ -26,7 +26,8 @@ pub fn derive_product_entropy( derive_product_entropy_from_source(&root_entropy_source, product_id, key) } -/// Derive product-scoped entropy from an already normalized root entropy source. +/// Derive product-scoped entropy when the session already stores the +/// pre-hashed root entropy source. pub fn derive_product_entropy_from_source( root_entropy_source: &[u8; 32], product_id: &str, @@ -36,7 +37,7 @@ pub fn derive_product_entropy_from_source( return Err(ProductEntropyError::InvalidKeyLength(key.len())); } - let product_id_hash = blake2b256(product_id.as_bytes()); + let product_id_hash = blake2b256_keyed(product_id.as_bytes(), &[]); let per_product_entropy = blake2b256_keyed(root_entropy_source, &product_id_hash); Ok(blake2b256_keyed(&per_product_entropy, key)) } @@ -48,10 +49,6 @@ fn blake2b256_keyed(message: &[u8], key: &[u8]) -> [u8; 32] { .expect("BLAKE2b-256 returns 32 bytes") } -fn blake2b256(message: &[u8]) -> [u8; 32] { - blake2b256_keyed(message, &[]) -} - #[cfg(test)] mod tests { use super::*; @@ -99,6 +96,35 @@ mod tests { assert_eq!(hex::encode(entropy), case.expected_hex, "{}", case.name); } + // Byte-for-byte vectors from polkadot-app-ios-v2 + // (ProductRootEntropyDeriverTests): raw BIP-39 entropy of 16 * 0xAB. + let ios_entropy = [0xABu8; 16]; + let ios_cases = [ + ( + "test.product.dot", + b"my-key".as_slice(), + "479d5b9ecce19615397c9f160ee95e2f00c579837a5afb111132dd0da5fd472a", + ), + ( + "test.product.dot", + b"other-key".as_slice(), + "0d576d5d77cb179bf94b85cb1d644b7879315e74d9e69791fb9cbe94df3c7c39", + ), + ( + "other.product.dot", + b"my-key".as_slice(), + "e2f25271c106593c2977d5965f52fa1d2227da0fc110d682c8cb8f30b2ba21c8", + ), + ]; + for (product_id, key, expected_hex) in ios_cases { + let entropy = derive_product_entropy(&ios_entropy, product_id, key).unwrap(); + assert_eq!( + hex::encode(entropy), + expected_hex, + "ios vector {product_id}" + ); + } + let error_cases = vec![ (Vec::new(), ProductEntropyError::InvalidKeyLength(0)), (vec![0u8; 33], ProductEntropyError::InvalidKeyLength(33)), 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 c4b16ec0..8298907c 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -5,35 +5,59 @@ use blake2_rfc::blake2b::blake2b; use parity_scale_codec::Encode; -use schnorrkel::PublicKey; use schnorrkel::derive::{ChainCode, Derivation}; +use schnorrkel::{ExpansionMode, Keypair, PublicKey}; use thiserror::Error; -use unicode_normalization::UnicodeNormalization; const JUNCTION_ID_LEN: usize = 32; const PRODUCT_JUNCTION: &str = "product"; const SS58_PREFIX: &[u8] = b"SS58PRE"; const SUBSTRATE_GENERIC_SS58_PREFIX: u8 = 42; +/// Substrate sr25519 signing-context string, shared by every sr25519 signature +/// the core produces (statement store, product raw signing). +pub(crate) const SR25519_SIGNING_CONTEXT: &[u8] = b"substrate"; + #[derive(Debug, Error, PartialEq, Eq)] pub enum ProductAccountError { #[error("invalid sr25519 root public key")] InvalidRootPublicKey, #[error("numeric derivation junction is outside u64 range")] NumericJunctionOutOfRange, + #[error("invalid BIP-39 entropy: {0}")] + InvalidEntropy(String), } -/// Whether `identifier` is a product scope the core is allowed to derive for. -pub fn is_product_identifier(identifier: &str) -> bool { - let normalized = normalize_product_identifier(identifier); - normalized.ends_with(".dot") - || normalized == "localhost" - || normalized.starts_with("localhost:") +/// Derive the root sr25519 keypair from raw BIP-39 entropy. +/// +/// Matches the Substrate mini-secret scheme (`sp_core::sr25519::Pair::from_entropy`) +/// used by polkadot-app-ios-v2: PBKDF2 over the entropy to a 32-byte mini +/// secret, then Ed25519-mode expansion. The public key of this keypair is the +/// `rootAccountId` shared with paired hosts. +pub fn derive_root_keypair_from_entropy(entropy: &[u8]) -> Result { + let mini_secret = substrate_bip39::mini_secret_from_entropy(entropy, "") + .map_err(|err| ProductAccountError::InvalidEntropy(format!("{err:?}")))?; + Ok(mini_secret.expand_to_keypair(ExpansionMode::Ed25519)) } -/// Normalize product identifiers before derivation and policy checks. -pub fn normalize_product_identifier(identifier: &str) -> String { - identifier.nfc().collect::().to_lowercase() +/// Derive a product-account keypair from the root keypair. +/// +/// Applies the same soft HDKD junctions `["product", product_id, +/// derivation_index]` as [`derive_product_public_key`] on the secret side, so +/// the resulting public key equals the seedless public derivation by +/// construction. +pub fn derive_product_keypair( + root: &Keypair, + product_id: &str, + derivation_index: u32, +) -> Result { + let mut keypair = root.clone(); + let derivation_index = derivation_index.to_string(); + for junction in [PRODUCT_JUNCTION, product_id, derivation_index.as_str()] { + let chain_code = ChainCode(create_chain_code(junction)?); + keypair = keypair.derived_key_simple(chain_code, []).0; + } + Ok(keypair) } /// Derive a product account public key from the paired root public key. @@ -45,12 +69,9 @@ pub fn derive_product_public_key( let mut public_key = PublicKey::from_bytes(&root_public_key) .map_err(|_| ProductAccountError::InvalidRootPublicKey)?; - for junction in [ - PRODUCT_JUNCTION.to_string(), - product_id.to_string(), - derivation_index.to_string(), - ] { - let chain_code = ChainCode(create_chain_code(&junction)?); + let derivation_index = derivation_index.to_string(); + for junction in [PRODUCT_JUNCTION, product_id, derivation_index.as_str()] { + let chain_code = ChainCode(create_chain_code(junction)?); let (derived, _) = public_key.derived_key_simple(chain_code, []); public_key = derived; } @@ -73,8 +94,9 @@ pub fn product_public_key_to_address(public_key: [u8; 32]) -> String { bs58::encode(payload).into_string() } +/// Create a Substrate soft-derivation chain code for one junction. fn create_chain_code(code: &str) -> Result<[u8; 32], ProductAccountError> { - let encoded = if is_numeric_junction(code) { + let encoded = if !code.is_empty() && code.bytes().all(|byte| byte.is_ascii_digit()) { code.parse::() .map_err(|_| ProductAccountError::NumericJunctionOutOfRange)? .encode() @@ -92,10 +114,6 @@ fn create_chain_code(code: &str) -> Result<[u8; 32], ProductAccountError> { Ok(chain_code) } -fn is_numeric_junction(code: &str) -> bool { - !code.is_empty() && code.bytes().all(|byte| byte.is_ascii_digit()) -} - #[cfg(test)] mod tests { use super::*; @@ -148,11 +166,55 @@ mod tests { } #[test] - fn accepts_dot_and_localhost_product_identifiers() { - assert!(is_product_identifier("Example.DOT")); - assert!(is_product_identifier("localhost")); - assert!(is_product_identifier("localhost:3000")); - assert!(!is_product_identifier("example.com")); + fn product_secret_derivation_matches_public_derivation() { + // The signing-host secret path and the seedless public path must agree + // on the product public key for any root, index, and product id. + let entropy = [0xABu8; 16]; + let root = derive_root_keypair_from_entropy(&entropy).unwrap(); + let root_public = root.public.to_bytes(); + for (product_id, index) in [("myapp.dot", 0u32), ("myapp.dot", 1), ("localhost:3000", 7)] { + let keypair = derive_product_keypair(&root, product_id, index).unwrap(); + let public = derive_product_public_key(root_public, product_id, index).unwrap(); + assert_eq!( + keypair.public.to_bytes(), + public, + "{product_id}#{index} secret vs public derivation", + ); + } + } + + #[test] + fn root_keypair_from_entropy_regression_pin() { + // Regression pin for the entropy -> mini-secret -> sr25519 root path + // (substrate-bip39 + schnorrkel Ed25519 expansion). This guards + // against an accidental change to that path (dep bump, expansion mode) + // that the pub-vs-secret self-consistency test cannot catch, since it + // derives both sides from the same root. + // + // NOTE: this is a self-computed regression value, NOT yet cross-checked + // against a polkadot-app-ios-v2 `deriveAccount` vector. Replace with an + // iOS-sourced value once available to make it a true interop anchor. + let root = derive_root_keypair_from_entropy(&[0xAB; 16]).unwrap(); + assert_eq!( + hex::encode(root.public.to_bytes()), + "0062ba8ae929ea64bc2ad6f21359e96a29e236a41d376d1c5ba76491da94fc72", + ); + } + + #[test] + fn product_secret_signs_verifiably() { + let root = derive_root_keypair_from_entropy(&[0xABu8; 16]).unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let message = b"hello"; + let signature = keypair + .secret + .sign_simple(b"substrate", message, &keypair.public); + assert!( + keypair + .public + .verify_simple(b"substrate", message, &signature) + .is_ok() + ); } #[test] diff --git a/rust/crates/truapi-server/src/host_logic/session.rs b/rust/crates/truapi-server/src/host_logic/session.rs index e2b1eeb3..261c110a 100644 --- a/rust/crates/truapi-server/src/host_logic/session.rs +++ b/rust/crates/truapi-server/src/host_logic/session.rs @@ -1,7 +1,7 @@ -//! Core-owned active-session state. Platform entrypoints notify the core when -//! pairing or unpairing changes the session, and account-management methods -//! read this state instead of round-tripping a host callback on every product -//! call. +//! Pairing-host active-session state. The runtime updates this when pairing or +//! unpairing with a signing host changes the inter-host session, and +//! account-management methods read it instead of round-tripping host callbacks +//! on every product call. use futures::channel::mpsc; use futures::stream::{self, BoxStream, StreamExt}; @@ -11,14 +11,15 @@ use std::sync::{Arc, Mutex}; use truapi::v01::HostAccountConnectionStatusSubscribeItem; use truapi::versioned::account::HostAccountConnectionStatusSubscribeItem as VersionedItem; -/// Session info pushed by the host. The 32-byte sr25519 public key plus -/// optional usernames sourced from the People-Chain identity record. +/// Session info for a pairing host's active signing-host session. The 32-byte +/// sr25519 public key plus optional usernames are sourced from the signing host +/// and People-chain identity record. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SessionInfo { - /// 32-byte sr25519 root public key of the paired session. + /// 32-byte sr25519 root public key owned by the signing host. pub public_key: [u8; 32], - /// Core-owned SSO channel state. Core-run pairing fills this; unavailable - /// sessions restored from older test fixtures may leave it empty. + /// SSO channel state negotiated by this pairing host with the signing host. + /// Sessions restored from older test fixtures may leave it empty. pub sso: Option, /// Wallet-provided source for deterministic product entropy. pub root_entropy_source: Option<[u8; 32]>, @@ -30,54 +31,62 @@ pub struct SessionInfo { pub full_username: Option, } -/// Core-owned SSO session material negotiated with the wallet during pairing. +/// SSO session material negotiated by the pairing host with the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SsoSessionInfo { - /// Core's own 64-byte expanded sr25519 statement-store secret. + /// Pairing host's own 64-byte expanded sr25519 statement-store secret. pub ss_secret: [u8; 64], - /// Core's own session sr25519 statement-store public key. + /// Pairing host's own session sr25519 statement-store public key. pub ss_public_key: [u8; 32], - /// Core's P-256 ECDH private key. + /// Pairing host's P-256 ECDH private key. pub enc_secret: [u8; 32], - /// Wallet persistent P-256 public key. + /// Signing host's persistent P-256 public key. pub peer_enc_pubkey: [u8; 65], - /// Wallet identity sr25519 account id. + /// Signing host's identity sr25519 account id. pub identity_account_id: [u8; 32], - /// Core -> wallet topic id. + /// Pairing host -> signing host topic id. pub session_id_own: [u8; 32], - /// Wallet -> core topic id. + /// Signing host -> pairing host topic id. pub session_id_peer: [u8; 32], - /// Statement channel for core requests. + /// Statement channel for pairing-host requests. pub request_channel: [u8; 32], - /// Statement channel for wallet responses to core requests. + /// Statement channel for signing-host responses to pairing-host requests. pub response_channel: [u8; 32], - /// Statement channel for wallet-initiated requests. + /// Statement channel for signing-host initiated requests. pub peer_request_channel: [u8; 32], } -const PERSISTED_SESSION_VERSION: u8 = 3; +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +enum PersistedSessionBlob { + #[codec(index = 3)] + V3(SessionInfo), +} /// Encode the active-session fields the core currently understands into an /// opaque host-global session blob. Later SSO channel state should bump -/// `PERSISTED_SESSION_VERSION` instead of extending this layout silently. +/// the enum variant instead of extending this layout silently. pub fn encode_persisted_session(info: &SessionInfo) -> Vec { - (PERSISTED_SESSION_VERSION, info).encode() + PersistedSessionBlob::V3(info.clone()).encode() } /// Decode a core-owned persisted session blob. pub fn decode_persisted_session(blob: &[u8]) -> Result { - let mut input = blob; - let version = u8::decode(&mut input).map_err(|err| format!("invalid session blob: {err}"))?; - let info = match version { - PERSISTED_SESSION_VERSION => { - SessionInfo::decode(&mut input).map_err(|err| format!("invalid session blob: {err}"))? - } - _ => return Err(format!("unsupported session blob version {version}")), + let Some(version) = blob.first() else { + return Err("invalid session blob: missing version".to_string()); }; + if *version != 3 { + return Err(format!("unsupported session blob version {version}")); + } + + let mut input = blob; + let decoded = PersistedSessionBlob::decode(&mut input) + .map_err(|err| format!("invalid session blob: {err}"))?; if !input.is_empty() { return Err("invalid session blob: trailing bytes".to_string()); } - Ok(info) + match decoded { + PersistedSessionBlob::V3(info) => Ok(info), + } } /// Holds the currently-active session and broadcasts connection-status @@ -152,6 +161,7 @@ impl SessionState { } } +/// Broadcast one connection-status transition and prune dropped subscribers. fn broadcast( subscribers: &mut Vec>, status: HostAccountConnectionStatusSubscribeItem, 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 6d782242..abbd7e5d 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -3,8 +3,13 @@ //! These are the encrypted payloads carried inside statement-store //! `SsoStatementData::Request` / `Response` frames. //! The runtime builds them when forwarding TrUAPI account, signing, resource -//! allocation, and transaction requests to the paired wallet, then decodes the -//! wallet's responses while waiting on the SSO statement-store channels. +//! allocation, and transaction requests to the paired signing host, then +//! decodes the signing host's responses while waiting on the SSO +//! statement-store channels. +//! The envelope and baseline message catalog are specified in host-spec: +//! +//! Deployed extension variants are tracked as a host-spec divergence: +//! //! Field order and enum variant order are kept wire-compatible with host-papp: //! //! @@ -28,12 +33,36 @@ use crate::host_logic::statement_store::{ statement_expiry_elapsed, }; -const SSO_RESPONSE_CODE_SUCCESS: u8 = 0; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, derive_more::Display)] +enum SsoResponseCode { + #[codec(index = 0)] + #[display("success")] + Success, + #[codec(index = 1)] + #[display("decryptionFailed")] + DecryptionFailed, + #[codec(index = 2)] + #[display("decodingFailed")] + DecodingFailed, +} + +impl TryFrom for SsoResponseCode { + type Error = (); + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::Success), + 1 => Ok(Self::DecryptionFailed), + 2 => Ok(Self::DecodingFailed), + _ => Err(()), + } + } +} -/// Top-level wallet remote message sent over the encrypted SSO channel. +/// Top-level remote message sent over the encrypted SSO channel. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RemoteMessage { - /// Correlation id used to match wallet responses to host requests. + /// Correlation id used to match signing-host responses to pairing-host requests. pub message_id: String, /// Versioned remote message body. pub data: RemoteMessageData, @@ -45,7 +74,7 @@ pub enum RemoteMessageData { V1(RemoteMessageV1), } -/// v1 messages exchanged with the paired wallet over the encrypted SSO channel. +/// v1 messages exchanged with the paired signing host over the encrypted SSO channel. /// /// The variant order is part of the SCALE wire protocol used inside /// statement-store session statements. @@ -65,19 +94,19 @@ pub enum RemoteMessageV1 { SignRawLegacyResponse(SignRawLegacyResponse), } -/// Signing request flavor sent to the wallet. +/// Signing request flavor sent to the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SigningRequest { Payload(Box), Raw(SigningRawRequest), } -/// Request sent when a product asks the paired wallet to sign a Substrate +/// Request sent when a product asks the paired signing host to sign a Substrate /// payload with a product-derived account. /// -/// Built from [`HostSignPayloadRequest`] and wrapped in -/// [`RemoteMessageV1::SignRequest`] before being encrypted into an SSO session -/// statement. +/// Built from [`HostSignPayloadRequest`] but kept as a dedicated wire type +/// because the host-papp SSO dialect flattens the public request payload and +/// encodes `with_signed_transaction` as `OptionBool`. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SigningPayloadRequest { pub product_account_id: ProductAccountId, @@ -98,29 +127,31 @@ pub struct SigningPayloadRequest { pub with_signed_transaction: OptionBool, } -fn signing_payload_request_from(value: HostSignPayloadRequest) -> SigningPayloadRequest { - let payload = value.payload; - SigningPayloadRequest { - product_account_id: value.account, - block_hash: payload.block_hash, - block_number: payload.block_number, - era: payload.era, - genesis_hash: payload.genesis_hash, - method: payload.method, - nonce: payload.nonce, - spec_version: payload.spec_version, - tip: payload.tip, - transaction_version: payload.transaction_version, - signed_extensions: payload.signed_extensions, - version: payload.version, - asset_id: payload.asset_id, - metadata_hash: payload.metadata_hash, - mode: payload.mode, - with_signed_transaction: OptionBool(payload.with_signed_transaction), +impl From for SigningPayloadRequest { + fn from(value: truapi::v01::HostSignPayloadRequest) -> Self { + let payload = value.payload; + Self { + product_account_id: value.account, + block_hash: payload.block_hash, + block_number: payload.block_number, + era: payload.era, + genesis_hash: payload.genesis_hash, + method: payload.method, + nonce: payload.nonce, + spec_version: payload.spec_version, + tip: payload.tip, + transaction_version: payload.transaction_version, + signed_extensions: payload.signed_extensions, + version: payload.version, + asset_id: payload.asset_id, + metadata_hash: payload.metadata_hash, + mode: payload.mode, + with_signed_transaction: OptionBool(payload.with_signed_transaction), + } } } -/// Request sent when a product asks the paired wallet to sign raw bytes or a +/// Request sent when a product asks the paired signing host to sign raw bytes or a /// string message with a product-derived account. /// /// Built from [`HostSignRawRequest`] and wrapped in @@ -132,14 +163,16 @@ pub struct SigningRawRequest { pub data: SigningRawPayload, } -fn signing_raw_request_from(value: HostSignRawRequest) -> SigningRawRequest { - SigningRawRequest { - product_account_id: value.account, - data: value.payload.into(), +impl From for SigningRawRequest { + fn from(value: truapi::v01::HostSignRawRequest) -> Self { + Self { + product_account_id: value.account, + data: value.payload.into(), + } } } -/// Request sent when a product asks the paired wallet to sign raw data with a +/// Request sent when a product asks the paired signing host to sign raw data with a /// user-imported legacy account. /// /// Unlike product-account signing, the signer is the raw account id selected @@ -170,7 +203,7 @@ impl From for SigningRawPayload { } } -/// Response returned by the wallet for a product-account signing request. +/// Response returned by the signing host for a product-account signing request. /// /// Decoded from [`RemoteMessageV1::SignResponse`] while the runtime is waiting /// for a matching SSO remote message id. @@ -180,14 +213,14 @@ pub struct SigningResponse { pub payload: Result, } -/// Successful product-account signing result returned by the wallet. +/// Successful product-account signing result returned by the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SigningPayloadResponseData { pub signature: Vec, pub signed_transaction: Option>, } -/// Response returned by the wallet for a legacy-account raw signing request. +/// Response returned by the signing host for a legacy-account raw signing request. /// /// Decoded from [`RemoteMessageV1::SignRawLegacyResponse`] and mapped back to /// the public raw-signing response shape. @@ -197,10 +230,10 @@ pub struct SignRawLegacyResponse { pub signature: Result, String>, } -/// Request sent when a product asks the wallet for a ring-VRF alias. +/// Request sent when a product asks the signing host for a ring-VRF alias. /// /// Used by `Account::get_account_alias`; the product account identifies the -/// alias target, while `product_id` identifies the caller that the wallet is +/// alias target, while `product_id` identifies the caller that the signing host is /// authorizing over the SSO channel. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RingVrfAliasRequest { @@ -208,14 +241,14 @@ pub struct RingVrfAliasRequest { pub product_id: String, } -/// Response returned by the wallet for a ring-VRF alias request. +/// Response returned by the signing host for a ring-VRF alias request. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RingVrfAliasResponse { pub responding_to: String, pub payload: Result, } -/// Request sent when a product asks the wallet to allocate SSO-backed +/// Request sent when a product asks the signing host to allocate SSO-backed /// resources. /// /// Used by `ResourceAllocation::request` for capabilities such as statement @@ -227,7 +260,7 @@ pub struct ResourceAllocationRequest { pub on_existing: OnExistingAllowancePolicy, } -/// Resources the wallet may allocate for the calling product. +/// Resources the signing host may allocate for the calling product. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SsoAllocatableResource { StatementStoreAllowance, @@ -249,21 +282,21 @@ impl From for SsoAllocatableResource { } } -/// Wallet policy for already-existing resource allowance. +/// Signing-host policy for already-existing resource allowance. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] pub enum OnExistingAllowancePolicy { Ignore, Increase, } -/// Response returned by the wallet for a resource-allocation request. +/// Response returned by the signing host for a resource-allocation request. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct ResourceAllocationResponse { pub responding_to: String, pub payload: Result, String>, } -/// Per-resource allocation result from the wallet. +/// Per-resource allocation result from the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SsoAllocationOutcome { Allocated(SsoAllocatedResource), @@ -271,7 +304,7 @@ pub enum SsoAllocationOutcome { NotAvailable, } -/// Resource material allocated by the wallet. +/// Resource material allocated by the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SsoAllocatedResource { StatementStoreAllowance { @@ -287,7 +320,7 @@ pub enum SsoAllocatedResource { }, } -/// Request sent when a product asks the wallet to create a signed transaction +/// Request sent when a product asks the signing host to create a signed transaction /// for a product-derived account. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct CreateTransactionRequest { @@ -300,7 +333,7 @@ pub enum CreateTransactionPayload { V1(ProductAccountTxPayload), } -/// Request sent when a product asks the wallet to create a signed transaction +/// Request sent when a product asks the signing host to create a signed transaction /// for a user-imported legacy account. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct CreateTransactionLegacyRequest { @@ -313,7 +346,7 @@ pub enum CreateTransactionLegacyPayload { V1(LegacyAccountTxPayload), } -/// Response returned by the wallet for either product-account or legacy-account +/// Response returned by the signing host for either product-account or legacy-account /// transaction creation. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct CreateTransactionResponse { @@ -329,7 +362,7 @@ pub enum SsoSessionStatement { Disconnected, } -/// Wallet response variants that can satisfy a pending remote request. +/// Signing-host response variants that can satisfy a pending remote request. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SsoRemoteResponse { Sign(SigningResponse), @@ -405,13 +438,10 @@ fn classify_response_ack( request_id: String, response_code: u8, ) -> Result { - if response_code == SSO_RESPONSE_CODE_SUCCESS { - Ok(SsoSessionStatement::RequestAccepted) - } else { - Err(format!( - "SSO request {request_id} was rejected: {}", - sso_response_code_name(response_code) - )) + match SsoResponseCode::try_from(response_code) { + Ok(SsoResponseCode::Success) => Ok(SsoSessionStatement::RequestAccepted), + Ok(code) => Err(format!("SSO request {request_id} was rejected: {code}")), + Err(()) => Err(format!("SSO request {request_id} was rejected: unknown")), } } @@ -450,35 +480,27 @@ fn remote_response_for_message( } } -fn sso_response_code_name(code: u8) -> &'static str { - match code { - 1 => "decryptionFailed", - 2 => "decodingFailed", - _ => "unknown", - } -} - -/// Build a wallet payload-signing request message. +/// Build a signing-host payload-signing request message. pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) -> RemoteMessage { RemoteMessage { message_id, data: RemoteMessageData::V1(RemoteMessageV1::SignRequest(Box::new( - SigningRequest::Payload(Box::new(signing_payload_request_from(request))), + SigningRequest::Payload(Box::new(request.into())), ))), } } -/// Build a wallet raw-signing request message. +/// Build a signing-host raw-signing request message. pub fn sign_raw_message(message_id: String, request: HostSignRawRequest) -> RemoteMessage { RemoteMessage { message_id, data: RemoteMessageData::V1(RemoteMessageV1::SignRequest(Box::new(SigningRequest::Raw( - signing_raw_request_from(request), + request.into(), )))), } } -/// Build a wallet legacy raw-signing request message. +/// Build a signing-host legacy raw-signing request message. pub fn sign_raw_legacy_message( message_id: String, account: AccountId, @@ -495,7 +517,7 @@ pub fn sign_raw_legacy_message( } } -/// Build a wallet account-alias request message. +/// Build a signing-host account-alias request message. pub fn alias_request_message( message_id: String, product_account_id: ProductAccountId, @@ -510,7 +532,7 @@ pub fn alias_request_message( } } -/// Build a wallet resource-allocation request message. +/// Build a signing-host resource-allocation request message. pub fn resource_allocation_message( message_id: String, calling_product_id: String, @@ -529,7 +551,7 @@ pub fn resource_allocation_message( } } -/// Build a wallet transaction-creation request message. +/// Build a signing-host transaction-creation request message. pub fn create_transaction_message( message_id: String, payload: ProductAccountTxPayload, @@ -544,7 +566,7 @@ pub fn create_transaction_message( } } -/// Build a wallet legacy-account transaction-creation request message. +/// Build a signing-host legacy-account transaction-creation request message. pub fn create_transaction_legacy_message( message_id: String, payload: LegacyAccountTxPayload, @@ -874,11 +896,11 @@ mod tests { with_signed_transaction: Some(true), }, }; - let true_encoded = signing_payload_request_from(request.clone()).encode(); + let true_encoded = SigningPayloadRequest::from(request.clone()).encode(); request.payload.with_signed_transaction = Some(false); - let false_encoded = signing_payload_request_from(request.clone()).encode(); + let false_encoded = SigningPayloadRequest::from(request.clone()).encode(); request.payload.with_signed_transaction = None; - let none_encoded = signing_payload_request_from(request).encode(); + let none_encoded = SigningPayloadRequest::from(request).encode(); assert_eq!(true_encoded.last(), Some(&1)); assert_eq!(false_encoded.last(), Some(&2)); @@ -988,7 +1010,7 @@ mod tests { session, &SsoStatementData::Response { request_id: "statement-1".to_string(), - response_code: SSO_RESPONSE_CODE_SUCCESS, + response_code: 0, }, [9; AES_GCM_NONCE_LEN], ) 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 73e7d402..93231638 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -17,7 +17,7 @@ use parity_scale_codec::{Decode, Encode}; use schnorrkel::{ExpansionMode, MiniSecretKey}; use sha2::Sha256; use thiserror::Error; -use truapi_platform::RuntimeConfig; +use truapi_platform::PairingHostConfig; #[cfg(test)] use truapi_platform::{HostInfo, PlatformInfo}; @@ -65,36 +65,7 @@ pub enum PairingBootstrapError { #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum VersionedHandshakeProposal { #[codec(index = 1)] - V2(HandshakeProposalV2), -} - -/// Host-papp v2 handshake proposal sent by the host. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HandshakeProposalV2 { - pub device: HandshakeDevice, - pub metadata: Vec, -} - -/// Device keys advertised in the v2 handshake proposal. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HandshakeDevice { - pub statement_account_id: [u8; 32], - pub encryption_public_key: [u8; 65], -} - -/// Metadata key/value entry attached to a v2 handshake proposal. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HandshakeMetadataEntry(pub HandshakeMetadataKey, pub String); - -/// Metadata keys understood by the mobile SSO pairing flow. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HandshakeMetadataKey { - Custom(String), - HostName, - HostVersion, - HostIcon, - PlatformType, - PlatformVersion, + V2(v2::Proposal), } /// Versioned encrypted response posted by the wallet to the pairing topic. @@ -107,30 +78,8 @@ pub enum VersionedHandshakeResponse { }, } -/// Plaintext v2 wallet response after decrypting the pairing statement. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum EncryptedHandshakeResponseV2 { - Pending(HandshakeStatusV2), - Success(Box), - Failed(String), -} - -/// Intermediate v2 handshake status emitted before success/failure. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HandshakeStatusV2 { - AllowanceAllocation, -} - -/// Successful v2 handshake payload used to establish the SSO session. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HandshakeSuccessV2 { - pub identity_account_id: [u8; 32], - pub root_account_id: [u8; 32], - pub identity_chat_private_key: [u8; 32], - pub sso_enc_pub_key: [u8; 65], - pub device_enc_pub_key: [u8; 65], - pub root_entropy_source: [u8; 32], -} +/// Host-papp v2 handshake wire types. +pub mod v2; /// Encrypted statement-channel envelope shared with the wallet. /// @@ -164,14 +113,14 @@ pub fn decrypt_v2_handshake_response( core_encryption_secret_key: [u8; 32], wallet_ephemeral_public_key: [u8; 65], encrypted_message: &[u8], -) -> Result { +) -> Result { let plaintext = decrypt_p256_hkdf_aes_gcm( core_encryption_secret_key, wallet_ephemeral_public_key, encrypted_message, )?; let mut input = plaintext.as_slice(); - let value = EncryptedHandshakeResponseV2::decode(&mut input) + let value = v2::EncryptedResponse::decode(&mut input) .map_err(|err| format!("invalid SSO V2 handshake response: {err}"))?; if !input.is_empty() { return Err("invalid SSO V2 handshake response: trailing bytes".to_string()); @@ -256,6 +205,7 @@ pub fn decrypt_session_statement_data( Ok(data) } +/// Decrypt an SSO handshake answer using P-256 ECDH, HKDF-SHA256, and AES-GCM. fn decrypt_p256_hkdf_aes_gcm( own_secret_key: [u8; 32], peer_public_key: [u8; 65], @@ -281,6 +231,7 @@ fn decrypt_session_message( ) } +/// Decrypt a nonce-prefixed AES-GCM payload with the already-derived channel key. fn decrypt_aes_gcm_with_key( aes_key: [u8; 32], encrypted_message: &[u8], @@ -297,11 +248,13 @@ fn decrypt_aes_gcm_with_key( .map_err(|err| format!("failed to decrypt SSO {label}: {err}")) } +/// Derive the AES-GCM session key for encrypted statement-channel messages. fn session_aes_key(session: &SsoSessionInfo) -> Result<[u8; 32], String> { let shared_secret = shared_secret(session.enc_secret, session.peer_enc_pubkey)?; aes_key_from_shared_secret(&shared_secret) } +/// Expand a P-256 ECDH shared secret into a 32-byte AES-GCM key. fn aes_key_from_shared_secret( shared_secret: &p256::ecdh::SharedSecret, ) -> Result<[u8; 32], String> { @@ -312,6 +265,7 @@ fn aes_key_from_shared_secret( Ok(aes_key) } +/// Compute the P-256 ECDH shared secret from our private key and the peer public key. fn shared_secret( own_secret_key: [u8; 32], peer_public_key: [u8; 65], @@ -349,7 +303,7 @@ fn keyed_hash(key: [u8; 32], message: &[u8]) -> [u8; 32] { /// Create one-shot pairing bootstrap material from runtime config. pub fn create_pairing_bootstrap( - config: &RuntimeConfig, + config: &PairingHostConfig, ) -> Result { create_pairing_bootstrap_from_identity(config, generate_pairing_device_identity()?) } @@ -369,7 +323,7 @@ pub fn generate_pairing_device_identity() -> Result Result { let deeplink = build_pairing_deeplink( @@ -398,10 +352,10 @@ pub fn build_pairing_deeplink( scheme: &str, statement_store_public_key: [u8; 32], encryption_public_key: [u8; 65], - config: &RuntimeConfig, + config: &PairingHostConfig, ) -> String { - let handshake = VersionedHandshakeProposal::V2(HandshakeProposalV2 { - device: HandshakeDevice { + let handshake = VersionedHandshakeProposal::V2(v2::Proposal { + device: v2::Device { statement_account_id: statement_store_public_key, encryption_public_key, }, @@ -413,32 +367,29 @@ pub fn build_pairing_deeplink( ) } -fn handshake_metadata(config: &RuntimeConfig) -> Vec { - let mut entries = vec![HandshakeMetadataEntry( - HandshakeMetadataKey::HostName, - config.host_info.name.clone(), +fn handshake_metadata(config: &PairingHostConfig) -> Vec { + let mut entries = vec![v2::MetadataEntry( + v2::MetadataKey::HostName, + config.host.host_info.name.clone(), )]; - if let Some(value) = &config.host_info.version { - entries.push(HandshakeMetadataEntry( - HandshakeMetadataKey::HostVersion, + if let Some(value) = &config.host.host_info.version { + entries.push(v2::MetadataEntry( + v2::MetadataKey::HostVersion, value.clone(), )); } - if let Some(value) = &config.host_info.icon { - entries.push(HandshakeMetadataEntry( - HandshakeMetadataKey::HostIcon, - value.clone(), - )); + if let Some(value) = &config.host.host_info.icon { + entries.push(v2::MetadataEntry(v2::MetadataKey::HostIcon, value.clone())); } - if let Some(value) = &config.platform_info.kind { - entries.push(HandshakeMetadataEntry( - HandshakeMetadataKey::PlatformType, + if let Some(value) = &config.host.platform_info.kind { + entries.push(v2::MetadataEntry( + v2::MetadataKey::PlatformType, value.clone(), )); } - if let Some(value) = &config.platform_info.version { - entries.push(HandshakeMetadataEntry( - HandshakeMetadataKey::PlatformVersion, + if let Some(value) = &config.host.platform_info.version { + entries.push(v2::MetadataEntry( + v2::MetadataKey::PlatformVersion, value.clone(), )); } @@ -508,9 +459,8 @@ mod tests { 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, ]; - fn runtime_config() -> RuntimeConfig { - RuntimeConfig::new( - "myapp.dot".to_string(), + fn runtime_config() -> PairingHostConfig { + PairingHostConfig::new( HostInfo { name: "Polkadot Web".to_string(), icon: Some("https://example.invalid/dotli.png".to_string()), @@ -537,8 +487,8 @@ mod tests { let VersionedHandshakeProposal::V2(proposal) = decoded; assert_eq!(proposal.device.statement_account_id, SS_PUBLIC); assert_eq!(proposal.device.encryption_public_key, ENC_PUBLIC); - assert!(proposal.metadata.contains(&HandshakeMetadataEntry( - HandshakeMetadataKey::HostName, + assert!(proposal.metadata.contains(&v2::MetadataEntry( + v2::MetadataKey::HostName, "Polkadot Web".to_string() ))); } @@ -621,7 +571,7 @@ mod tests { let mut aes_key = [0u8; 32]; hkdf.expand(&[], &mut aes_key).unwrap(); - let sensitive = EncryptedHandshakeResponseV2::Success(Box::new(HandshakeSuccessV2 { + let sensitive = v2::EncryptedResponse::Success(Box::new(v2::Success { identity_account_id: [8; 32], root_account_id: [7; 32], identity_chat_private_key: [6; 32], diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs new file mode 100644 index 00000000..43f3f277 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs @@ -0,0 +1,57 @@ +//! Host-papp v2 handshake wire types. + +use parity_scale_codec::{Decode, Encode}; + +/// Handshake proposal sent by the host. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct Proposal { + pub device: Device, + pub metadata: Vec, +} + +/// Device keys advertised in the handshake proposal. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct Device { + pub statement_account_id: [u8; 32], + pub encryption_public_key: [u8; 65], +} + +/// Metadata key/value entry attached to a handshake proposal. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct MetadataEntry(pub MetadataKey, pub String); + +/// Metadata keys understood by the mobile SSO pairing flow. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum MetadataKey { + Custom(String), + HostName, + HostVersion, + HostIcon, + PlatformType, + PlatformVersion, +} + +/// Plaintext wallet response after decrypting the pairing statement. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum EncryptedResponse { + Pending(Status), + Success(Box), + Failed(String), +} + +/// Intermediate handshake status emitted before success/failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum Status { + AllowanceAllocation, +} + +/// Successful handshake payload used to establish the SSO session. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct Success { + pub identity_account_id: [u8; 32], + pub root_account_id: [u8; 32], + pub identity_chat_private_key: [u8; 32], + pub sso_enc_pub_key: [u8; 65], + pub device_enc_pub_key: [u8; 65], + pub root_entropy_source: [u8; 32], +} diff --git a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs index b4f29302..faef35c1 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs @@ -3,10 +3,9 @@ use schnorrkel::{PublicKey, SecretKey, Signature}; use truapi::v01; use super::StatementStoreParseError; +use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; use crate::host_logic::session::SsoSessionInfo; -const SR25519_SIGNING_CONTEXT: &[u8] = b"substrate"; - /// Verified statement payload plus the sr25519 signer recovered from proof. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VerifiedStatementData { @@ -281,7 +280,7 @@ fn verify_statement_proof( pub fn statement_fields_from_v01(statement: v01::Statement) -> Result, String> { let mut fields = Vec::new(); if let Some(proof) = statement.proof { - fields.push(StatementField::Proof(statement_proof_from_v01(proof))); + fields.push(StatementField::Proof(proof.into())); } if let Some(decryption_key) = statement.decryption_key { fields.push(StatementField::DecryptionKey(decryption_key)); @@ -301,13 +300,7 @@ pub fn statement_fields_from_v01(statement: v01::Statement) -> Result Result, String> { - Ok(signed_statement_fields(statement)?.encode()) -} - -fn signed_statement_fields(statement: v01::SignedStatement) -> Result, String> { - let mut fields = vec![StatementField::Proof(statement_proof_from_v01( - statement.proof, - ))]; + let mut fields = vec![StatementField::Proof(statement.proof.into())]; if let Some(decryption_key) = statement.decryption_key { fields.push(StatementField::DecryptionKey(decryption_key)); } @@ -322,9 +315,10 @@ fn signed_statement_fields(statement: v01::SignedStatement) -> Result, ) -> Result { @@ -338,7 +332,7 @@ fn signed_statement_from_fields( for field in fields { match field { StatementField::Proof(value) => { - if proof.replace(statement_proof_to_v01(value)).is_some() { + if proof.replace(value.into()).is_some() { return Err(StatementStoreParseError::Malformed( "statement has duplicate proof".to_string(), )); @@ -393,48 +387,56 @@ fn signed_statement_from_fields( /// Convert an internal proof into the public v01 proof shape. pub fn statement_proof_to_v01(proof: StatementProof) -> v01::StatementProof { - match proof { - StatementProof::Sr25519 { signature, signer } => { - v01::StatementProof::Sr25519 { signature, signer } - } - StatementProof::Ed25519 { signature, signer } => { - v01::StatementProof::Ed25519 { signature, signer } - } - StatementProof::Ecdsa { signature, signer } => { - v01::StatementProof::Ecdsa { signature, signer } + proof.into() +} + +impl From for v01::StatementProof { + fn from(proof: StatementProof) -> Self { + match proof { + StatementProof::Sr25519 { signature, signer } => { + v01::StatementProof::Sr25519 { signature, signer } + } + StatementProof::Ed25519 { signature, signer } => { + v01::StatementProof::Ed25519 { signature, signer } + } + StatementProof::Ecdsa { signature, signer } => { + v01::StatementProof::Ecdsa { signature, signer } + } + StatementProof::OnChain { + who, + block_hash, + event, + } => v01::StatementProof::OnChain { + who, + block_hash, + event, + }, } - StatementProof::OnChain { - who, - block_hash, - event, - } => v01::StatementProof::OnChain { - who, - block_hash, - event, - }, } } -fn statement_proof_from_v01(proof: v01::StatementProof) -> StatementProof { - match proof { - v01::StatementProof::Sr25519 { signature, signer } => { - StatementProof::Sr25519 { signature, signer } - } - v01::StatementProof::Ed25519 { signature, signer } => { - StatementProof::Ed25519 { signature, signer } - } - v01::StatementProof::Ecdsa { signature, signer } => { - StatementProof::Ecdsa { signature, signer } +impl From for StatementProof { + fn from(proof: v01::StatementProof) -> Self { + match proof { + v01::StatementProof::Sr25519 { signature, signer } => { + StatementProof::Sr25519 { signature, signer } + } + v01::StatementProof::Ed25519 { signature, signer } => { + StatementProof::Ed25519 { signature, signer } + } + v01::StatementProof::Ecdsa { signature, signer } => { + StatementProof::Ecdsa { signature, signer } + } + v01::StatementProof::OnChain { + who, + block_hash, + event, + } => StatementProof::OnChain { + who, + block_hash, + event, + }, } - v01::StatementProof::OnChain { - who, - block_hash, - event, - } => StatementProof::OnChain { - who, - block_hash, - event, - }, } } From e1f6790efedbf9ce9f5a4b65e7d198799b9c68f4 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 1 Jul 2026 11:18:51 +0200 Subject: [PATCH 18/56] feat(truapi-server): add wire and chain infrastructure --- .../crates/truapi-server/src/chain_runtime.rs | 1592 +++++++++++++++++ rust/crates/truapi-server/src/dispatcher.rs | 272 +++ rust/crates/truapi-server/src/frame.rs | 433 +++++ .../truapi-server/src/host_rpc_client.rs | 587 ++++++ rust/crates/truapi-server/src/lib.rs | 14 +- rust/crates/truapi-server/src/subscription.rs | 495 +++++ rust/crates/truapi-server/src/transport.rs | 12 + .../truapi-server/tests/golden_frame.rs | 53 + .../tests/snapshots/golden-account-get.bin | Bin 0 -> 14 bytes .../tests/wire_table_ts_parity.rs | 228 +++ 10 files changed, 3683 insertions(+), 3 deletions(-) create mode 100644 rust/crates/truapi-server/src/chain_runtime.rs create mode 100644 rust/crates/truapi-server/src/dispatcher.rs create mode 100644 rust/crates/truapi-server/src/frame.rs create mode 100644 rust/crates/truapi-server/src/host_rpc_client.rs create mode 100644 rust/crates/truapi-server/src/subscription.rs create mode 100644 rust/crates/truapi-server/src/transport.rs create mode 100644 rust/crates/truapi-server/tests/golden_frame.rs create mode 100644 rust/crates/truapi-server/tests/snapshots/golden-account-get.bin create mode 100644 rust/crates/truapi-server/tests/wire_table_ts_parity.rs diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs new file mode 100644 index 00000000..b8fcacca --- /dev/null +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -0,0 +1,1592 @@ +//! ChainHead v1 state machine used by `PlatformRuntimeHost`. +//! +//! [`ChainRuntime`] keeps one [`ChainConnection`] per chain (keyed by genesis +//! hash) on top of the platform-provided [`JsonRpcConnection`]. The generic +//! JSON-RPC mechanics are delegated to [`crate::host_rpc_client`], while +//! `subxt-rpcs` owns the raw `chainHead_v1` method shapes and event parsing. +//! This module keeps the TrUAPI-facing local follow ids and maps subxt DTOs to +//! public v01 [`RemoteChainHeadFollowItem`] values. +//! +//! The chain-side traits return [`RuntimeFailure`], a local classification +//! that the [`crate::runtime`] layer maps to [`truapi::CallError`] variants +//! (`Unsupported`, `HostFailure`, ...). This avoids leaking json-rpc plumbing +//! into the public API. + +#![allow(dead_code)] + +use core::pin::Pin; +use core::task::{Context, Poll}; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex; + +use futures::FutureExt; +use futures::channel::mpsc; +use futures::future::{AbortHandle, Abortable}; +use futures::future::{BoxFuture, Shared}; +use futures::stream::BoxStream; +use futures::{Stream, StreamExt}; +use parity_scale_codec::{Decode, Error as ScaleError, Input}; +use primitive_types::H256; +use serde::de::{Deserializer, Error as DeError}; +use serde_json::Value; +use subxt_rpcs::client::RpcClient; +use subxt_rpcs::methods::chain_head as subxt_chain; +use subxt_rpcs::{ChainHeadRpcMethods, Error as SubxtRpcError, RpcConfig}; +use tracing::instrument; +use truapi::v01::{ + OperationStartedResult, RemoteChainHeadBodyRequest, RemoteChainHeadBodyResponse, + RemoteChainHeadCallRequest, RemoteChainHeadCallResponse, RemoteChainHeadContinueRequest, + RemoteChainHeadFollowItem, RemoteChainHeadFollowRequest, RemoteChainHeadHeaderRequest, + RemoteChainHeadHeaderResponse, RemoteChainHeadStopOperationRequest, + RemoteChainHeadStorageRequest, RemoteChainHeadStorageResponse, RemoteChainHeadUnpinRequest, + RemoteChainSpecChainNameResponse, RemoteChainSpecGenesisHashResponse, + RemoteChainSpecPropertiesResponse, RemoteChainTransactionBroadcastRequest, + RemoteChainTransactionBroadcastResponse, RemoteChainTransactionStopRequest, RuntimeApi, + RuntimeSpec, RuntimeType, StorageQueryItem, StorageQueryType, StorageResultItem, +}; +use truapi_platform::JsonRpcConnection; + +use crate::host_rpc_client::HostRpcClient; +use crate::subscription::Spawner; + +const FOLLOW_METHOD: &str = "remote_chain_head_follow"; + +struct TruapiRpcConfig; + +impl RpcConfig for TruapiRpcConfig { + type Header = RawHeader; + type Hash = H256; + type AccountId = (); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RawHeader(Vec); + +impl Decode for RawHeader { + fn decode(input: &mut I) -> Result { + let Some(len) = input.remaining_len()? else { + return Err("raw header input length is unknown".into()); + }; + let mut bytes = vec![0u8; len]; + input.read(&mut bytes)?; + Ok(Self(bytes)) + } +} + +impl<'de> serde::Deserialize<'de> for RawHeader { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let bytes = subxt_chain::Bytes::deserialize(deserializer).map_err(D::Error::custom)?; + Ok(Self(bytes.0)) + } +} + +/// Shared, single-flight `chainHead_v1_follow` setup keyed by local follow id. +/// Concurrent callers for the same id await one in-flight request rather than +/// each opening (and leaking) a separate remote subscription. +type FollowSetup = Shared>>; + +/// Shared, single-flight provider connect keyed by genesis hash. Concurrent +/// first connections for the same chain await one in-flight `connect` rather +/// than each opening a connection and orphaning all but the last insert. +type ConnectionSetup = Shared, RuntimeFailure>>>; + +/// Classification of framework-level chain failures separate from JSON-RPC +/// domain errors. Maps cleanly to [`truapi::CallError`] variants at the +/// `PlatformRuntimeHost` boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeFailureKind { + /// Backend is not wired or refused the request for plumbing reasons. + Unavailable, + /// Backend responded but the payload was malformed or the call failed. + HostFailure, +} + +/// Framework-level chain failure with a diagnostic reason. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeFailure { + kind: RuntimeFailureKind, + method: &'static str, + reason: Option, +} + +impl RuntimeFailure { + /// Backend refused the call for unavailability reasons (no provider, the + /// connection died, etc.). + pub fn unavailable(method: &'static str) -> Self { + Self { + kind: RuntimeFailureKind::Unavailable, + method, + reason: None, + } + } + + /// Backend produced a structural error (malformed json-rpc, unexpected + /// shape, ...). + pub fn host_failure(method: &'static str, reason: impl Into) -> Self { + Self { + kind: RuntimeFailureKind::HostFailure, + method, + reason: Some(reason.into()), + } + } + + /// Failure classification. + pub fn kind(&self) -> RuntimeFailureKind { + self.kind + } + + /// Method tag the failure originated from. + #[allow(dead_code)] + pub fn method(&self) -> &'static str { + self.method + } + + /// Diagnostic reason. Always non-empty for `HostFailure`. + pub fn reason(&self) -> String { + match &self.reason { + Some(reason) => format!("{}: {}", self.method, reason), + None => self.method.to_string(), + } + } + + /// Re-tag this failure under `method`, preserving its kind and reason. + fn reclassify(&self, method: &'static str) -> RuntimeFailure { + match self.kind() { + RuntimeFailureKind::Unavailable => RuntimeFailure::unavailable(method), + RuntimeFailureKind::HostFailure => RuntimeFailure::host_failure(method, self.reason()), + } + } +} + +/// Provider of `JsonRpcConnection` instances keyed by chain genesis hash. +/// The default [`UnavailableChainProvider`] makes every call fail; real +/// hosts plug in the platform-side `ChainProvider`. +#[async_trait::async_trait] +pub trait RuntimeChainProvider: Send + Sync { + /// Open or reuse a JSON-RPC connection for the chain identified by + /// `genesis_hash`. + async fn connect( + &self, + genesis_hash: Vec, + ) -> Result, RuntimeFailure>; +} + +/// Default provider: every `connect` call fails with `Unavailable`, so each +/// chain RPC surfaces a typed "unavailable" error to the product. +#[allow(dead_code)] +#[derive(Default)] +pub struct UnavailableChainProvider; + +#[async_trait::async_trait] +impl RuntimeChainProvider for UnavailableChainProvider { + async fn connect( + &self, + _genesis_hash: Vec, + ) -> Result, RuntimeFailure> { + Err(RuntimeFailure::unavailable("remote_chain_connect")) + } +} + +/// chainHead-v1 state machine on top of a [`RuntimeChainProvider`]. +/// +/// Each method maps a typed v01 chain request to one or more json-rpc calls, +/// shares one `chainHead_v1_follow` subscription per (genesis_hash, local +/// follow id) pair, and parses follow events back into typed +/// [`RemoteChainHeadFollowItem`] values. +#[derive(Clone)] +pub struct ChainRuntime { + provider: Arc, + spawner: Spawner, + connections: Arc>>>, + connection_setups: Arc>>, +} + +impl ChainRuntime { + /// Build a `ChainRuntime` driven by `provider`. Background tasks (response + /// pumps, follow setup) are spawned on `spawner`. + pub fn new(provider: Arc, spawner: Spawner) -> Self { + Self { + provider, + spawner, + connections: Arc::new(Mutex::new(HashMap::new())), + connection_setups: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Start (or attach to an existing) `chainHead_v1_follow` subscription. + /// Returns a stream of typed follow items that closes when the remote + /// sends `stop` or the connection drops. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.follow"))] + pub fn remote_chain_head_follow( + &self, + follow_subscription_id: String, + request: RemoteChainHeadFollowRequest, + ) -> BoxStream<'static, RemoteChainHeadFollowItem> { + let (tx, rx) = mpsc::unbounded(); + let runtime = self.clone(); + let cleanup_runtime = self.clone(); + let cleanup_genesis_hash = request.genesis_hash.clone(); + let cleanup_follow_id = follow_subscription_id.clone(); + + let fut = async move { + if runtime + .start_follow(follow_subscription_id, request, Some(tx.clone())) + .await + .is_err() + { + let _ = tx.unbounded_send(FollowSignal::Interrupt); + } + }; + (self.spawner)(fut.boxed()); + + ManagedSubscription::new( + rx.boxed(), + Some(Box::new(move || { + cleanup_runtime.cleanup_follow(&cleanup_genesis_hash, &cleanup_follow_id); + })), + ) + .filter_map(|signal| async move { + match signal { + FollowSignal::Item(item) => Some(item), + FollowSignal::Interrupt => None, + } + }) + .boxed() + } + + /// Fetch a block header. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.header"))] + pub async fn remote_chain_head_header( + &self, + request: RemoteChainHeadHeaderRequest, + ) -> Result { + let method = "remote_chain_head_header"; + let connection = self.connection_for(method, &request.genesis_hash).await?; + let remote_follow_id = self + .ensure_follow_context(method, &connection, request.follow_subscription_id, false) + .await?; + + let hash = hash_from_bytes(method, &request.hash)?; + let header = connection + .methods + .chainhead_v1_header(&remote_follow_id, hash) + .await + .map_err(|err| rpc_failure(method, err))? + .map(|header| header.0); + Ok(RemoteChainHeadHeaderResponse { header }) + } + + /// Start a chainHead_v1_body operation. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.body"))] + pub async fn remote_chain_head_body( + &self, + request: RemoteChainHeadBodyRequest, + ) -> Result { + let method = "remote_chain_head_body"; + let connection = self.connection_for(method, &request.genesis_hash).await?; + let remote_follow_id = self + .ensure_follow_context(method, &connection, request.follow_subscription_id, false) + .await?; + + let operation = connection + .methods + .chainhead_v1_body(&remote_follow_id, hash_from_bytes(method, &request.hash)?) + .await + .map_err(|err| rpc_failure(method, err)) + .and_then(operation_started_result)?; + Ok(RemoteChainHeadBodyResponse { operation }) + } + + /// Start a chainHead_v1_storage operation. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.storage"))] + pub async fn remote_chain_head_storage( + &self, + request: RemoteChainHeadStorageRequest, + ) -> Result { + let method = "remote_chain_head_storage"; + let connection = self.connection_for(method, &request.genesis_hash).await?; + let remote_follow_id = self + .ensure_follow_context(method, &connection, request.follow_subscription_id, false) + .await?; + + let items = request + .items + .iter() + .map(map_storage_query_item) + .collect::>(); + + let operation = connection + .methods + .chainhead_v1_storage( + &remote_follow_id, + hash_from_bytes(method, &request.hash)?, + items, + request.child_trie.as_deref(), + ) + .await + .map_err(|err| rpc_failure(method, err)) + .and_then(operation_started_result)?; + Ok(RemoteChainHeadStorageResponse { operation }) + } + + /// Start a chainHead_v1_call operation. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.call"))] + pub async fn remote_chain_head_call( + &self, + request: RemoteChainHeadCallRequest, + ) -> Result { + let method = "remote_chain_head_call"; + let connection = self.connection_for(method, &request.genesis_hash).await?; + let remote_follow_id = self + .ensure_follow_context(method, &connection, request.follow_subscription_id, true) + .await?; + + let operation = connection + .methods + .chainhead_v1_call( + &remote_follow_id, + hash_from_bytes(method, &request.hash)?, + &request.function, + &request.call_parameters, + ) + .await + .map_err(|err| rpc_failure(method, err)) + .and_then(operation_started_result)?; + Ok(RemoteChainHeadCallResponse { operation }) + } + + /// Release pinned blocks. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.unpin"))] + pub async fn remote_chain_head_unpin( + &self, + request: RemoteChainHeadUnpinRequest, + ) -> Result<(), RuntimeFailure> { + let method = "remote_chain_head_unpin"; + let connection = self.connection_for(method, &request.genesis_hash).await?; + let remote_follow_id = self + .ensure_follow_context(method, &connection, request.follow_subscription_id, false) + .await?; + for hash in request.hashes { + connection + .methods + .chainhead_v1_unpin(&remote_follow_id, hash_from_bytes(method, &hash)?) + .await + .map_err(|err| rpc_failure(method, err))?; + } + Ok(()) + } + + /// Continue a paused operation. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.continue"))] + pub async fn remote_chain_head_continue( + &self, + request: RemoteChainHeadContinueRequest, + ) -> Result<(), RuntimeFailure> { + let method = "remote_chain_head_continue"; + let connection = self.connection_for(method, &request.genesis_hash).await?; + let remote_follow_id = self + .ensure_follow_context(method, &connection, request.follow_subscription_id, false) + .await?; + connection + .methods + .chainhead_v1_continue(&remote_follow_id, &request.operation_id) + .await + .map_err(|err| rpc_failure(method, err)) + } + + /// Stop a chain-head operation. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.stop_operation"))] + pub async fn remote_chain_head_stop_operation( + &self, + request: RemoteChainHeadStopOperationRequest, + ) -> Result<(), RuntimeFailure> { + let method = "remote_chain_head_stop_operation"; + let connection = self.connection_for(method, &request.genesis_hash).await?; + let remote_follow_id = self + .ensure_follow_context(method, &connection, request.follow_subscription_id, false) + .await?; + connection + .methods + .chainhead_v1_stop_operation(&remote_follow_id, &request.operation_id) + .await + .map_err(|err| rpc_failure(method, err)) + } + + /// Echo back the chain genesis hash via chainSpec_v1_genesisHash. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.spec_genesis_hash"))] + pub async fn remote_chain_spec_genesis_hash( + &self, + genesis_hash: Vec, + ) -> Result { + let method = "remote_chain_spec_genesis_hash"; + let connection = self.connection_for(method, &genesis_hash).await?; + let genesis_hash = connection + .methods + .chainspec_v1_genesis_hash() + .await + .map_err(|err| rpc_failure(method, err)) + .map(hash_to_bytes)?; + Ok(RemoteChainSpecGenesisHashResponse { genesis_hash }) + } + + /// Fetch the chain display name via chainSpec_v1_chainName. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.spec_chain_name"))] + pub async fn remote_chain_spec_chain_name( + &self, + genesis_hash: Vec, + ) -> Result { + let method = "remote_chain_spec_chain_name"; + let connection = self.connection_for(method, &genesis_hash).await?; + let chain_name = connection + .methods + .chainspec_v1_chain_name() + .await + .map_err(|err| rpc_failure(method, err))?; + Ok(RemoteChainSpecChainNameResponse { chain_name }) + } + + /// Fetch the chain JSON properties via chainSpec_v1_properties. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.spec_properties"))] + pub async fn remote_chain_spec_properties( + &self, + genesis_hash: Vec, + ) -> Result { + let method = "remote_chain_spec_properties"; + let connection = self.connection_for(method, &genesis_hash).await?; + let value = connection + .methods + .chainspec_v1_properties::() + .await + .map_err(|err| rpc_failure(method, err))?; + let properties = serde_json::to_string(&value) + .map_err(|err| RuntimeFailure::host_failure(method, err.to_string()))?; + Ok(RemoteChainSpecPropertiesResponse { properties }) + } + + /// Broadcast a signed transaction via transaction_v1_broadcast. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.transaction_broadcast"))] + pub async fn remote_chain_transaction_broadcast( + &self, + request: RemoteChainTransactionBroadcastRequest, + ) -> Result { + let method = "remote_chain_transaction_broadcast"; + let connection = self.connection_for(method, &request.genesis_hash).await?; + let operation_id = connection + .methods + .transaction_v1_broadcast(&request.transaction) + .await + .map_err(|err| rpc_failure(method, err))?; + Ok(RemoteChainTransactionBroadcastResponse { operation_id }) + } + + /// Stop a transaction broadcast via transaction_v1_stop. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.transaction_stop"))] + pub async fn remote_chain_transaction_stop( + &self, + request: RemoteChainTransactionStopRequest, + ) -> Result<(), RuntimeFailure> { + let method = "remote_chain_transaction_stop"; + let connection = self.connection_for(method, &request.genesis_hash).await?; + connection + .methods + .transaction_v1_stop(&request.operation_id) + .await + .map_err(|err| rpc_failure(method, err)) + } + + #[instrument(skip_all, fields(runtime.method = "chain_runtime.connection_for", method = method))] + async fn connection_for( + &self, + method: &'static str, + genesis_hash: &[u8], + ) -> Result, RuntimeFailure> { + let key = encode_hex(genesis_hash); + let setup = { + let mut connections = self.connections.lock().unwrap(); + match connections.get(&key) { + Some(connection) if !connection.is_closed() => return Ok(connection.clone()), + Some(_) => { + connections.remove(&key); + } + None => {} + } + // Single-flight the provider connect (same shape as + // `follow_setups`): concurrent first connections for the same + // chain share one in-flight `connect` instead of racing the + // insert and orphaning the loser's connection. + let mut setups = self.connection_setups.lock().unwrap(); + if let Some(existing) = setups.get(&key) { + existing.clone() + } else { + let provider = self.provider.clone(); + let spawner = self.spawner.clone(); + let connections = self.connections.clone(); + let setups_map = self.connection_setups.clone(); + let setup_key = key.clone(); + let genesis_hash = genesis_hash.to_owned(); + let setup: ConnectionSetup = async move { + let result = provider.connect(genesis_hash).await.map(|rpc| { + let connection = ChainConnection::new(rpc, spawner); + connections + .lock() + .unwrap() + .insert(setup_key.clone(), connection.clone()); + connection + }); + setups_map.lock().unwrap().remove(&setup_key); + result + } + .boxed() + .shared(); + setups.insert(key, setup.clone()); + setup + } + }; + + setup.await.map_err(|failure| failure.reclassify(method)) + } + + #[instrument(skip_all, fields(runtime.method = "chain_runtime.start_follow"))] + async fn start_follow( + &self, + local_follow_id: String, + request: RemoteChainHeadFollowRequest, + sender: Option>, + ) -> Result<(), RuntimeFailure> { + let connection = self + .connection_for(FOLLOW_METHOD, &request.genesis_hash) + .await?; + // Record this subscriber's sender before kicking off (or joining) the + // single-flight setup so events route to it regardless of which caller + // wins the setup. + connection.register_follow_intent(&local_follow_id, request.with_runtime, sender); + connection + .ensure_remote_follow(local_follow_id, request.with_runtime) + .await?; + Ok(()) + } + + #[instrument(skip_all, fields(runtime.method = "chain_runtime.ensure_follow_context", method = method))] + async fn ensure_follow_context( + &self, + method: &'static str, + connection: &Arc, + local_follow_id: String, + with_runtime: bool, + ) -> Result { + let remote_follow_id = connection + .require_remote_follow(method, local_follow_id.clone()) + .await?; + if with_runtime && !connection.follow_with_runtime(&local_follow_id) { + return Err(RuntimeFailure::host_failure( + method, + "follow subscription was created without runtime metadata", + )); + } + Ok(remote_follow_id) + } + + #[instrument(skip_all, fields(runtime.method = "chain_runtime.cleanup_follow"))] + fn cleanup_follow(&self, genesis_hash: &[u8], local_follow_id: &str) { + let key = encode_hex(genesis_hash); + let Some(connection) = self.connections.lock().unwrap().get(&key).cloned() else { + return; + }; + connection.unfollow(local_follow_id); + } +} + +/// One delivery on the local follow stream. `Interrupt` signals an +/// abnormal close (connection dropped, follow setup failed); it produces no +/// item but ends the stream. +enum FollowSignal { + Item(RemoteChainHeadFollowItem), + Interrupt, +} + +struct ChainConnection { + rpc_client: HostRpcClient, + methods: ChainHeadRpcMethods, + spawner: Spawner, + follows: Mutex>, + follow_setups: Mutex>, +} + +impl ChainConnection { + fn new(rpc: Arc, spawner: Spawner) -> Arc { + let rpc_client = HostRpcClient::new(rpc, spawner.clone()); + let methods = ChainHeadRpcMethods::new(RpcClient::new(rpc_client.clone())); + Arc::new(Self { + rpc_client, + methods, + spawner, + follows: Mutex::new(HashMap::new()), + follow_setups: Mutex::new(HashMap::new()), + }) + } + + fn is_closed(&self) -> bool { + self.rpc_client.is_closed() + } + + fn follow_with_runtime(&self, local_follow_id: &str) -> bool { + self.follows + .lock() + .unwrap() + .get(local_follow_id) + .is_some_and(|follow| follow.with_runtime) + } + + fn remote_follow_id(&self, local_follow_id: &str) -> Option { + self.follows + .lock() + .unwrap() + .get(local_follow_id) + .and_then(|follow| follow.remote_subscription_id.clone()) + } + + /// Record intent to follow `local_follow_id`, attaching `sender` for a + /// follow subscriber. Idempotent: an existing follow keeps its + /// `with_runtime` flag and remote id; only the sender is (re)attached. + fn register_follow_intent( + &self, + local_follow_id: &str, + with_runtime: bool, + sender: Option>, + ) { + let mut follows = self.follows.lock().unwrap(); + match follows.get_mut(local_follow_id) { + Some(follow) => { + if sender.is_some() { + follow.sender = sender; + } + } + None => { + follows.insert( + local_follow_id.to_string(), + FollowState { + with_runtime, + remote_subscription_id: None, + abort: None, + sender, + }, + ); + } + } + } + + /// Issue `chainHead_v1_follow` exactly once per local follow id and return + /// the remote subscription id. Concurrent callers for the same id share + /// one in-flight setup instead of each opening a duplicate remote + /// subscription that would then leak. + #[instrument(skip_all, fields(runtime.method = "chain_connection.ensure_remote_follow"))] + async fn ensure_remote_follow( + self: &Arc, + local_follow_id: String, + with_runtime: bool, + ) -> Result { + if let Some(remote_follow_id) = self.remote_follow_id(&local_follow_id) { + return Ok(remote_follow_id); + } + + let setup = { + let mut setups = self.follow_setups.lock().unwrap(); + if let Some(existing) = setups.get(&local_follow_id) { + existing.clone() + } else { + let connection = self.clone(); + let id = local_follow_id.clone(); + let setup: FollowSetup = + async move { connection.run_follow_setup(id, with_runtime).await } + .boxed() + .shared(); + setups.insert(local_follow_id.clone(), setup.clone()); + setup + } + }; + + let result = setup.await; + // On failure, drop the cached setup so a later re-subscribe can retry. + // On success the established follow short-circuits the fast path above, + // and `remove_follow` clears the entry at teardown. + if result.is_err() { + self.follow_setups.lock().unwrap().remove(&local_follow_id); + } + result + } + + /// Return the remote follow id for an already-created local follow. + /// + /// Follow-bound request methods must not create remote follows themselves: + /// the local follow stream owns cleanup, so only `follow_head_subscribe` + /// may establish the remote subscription. + #[instrument(skip_all, fields(runtime.method = "chain_connection.require_remote_follow"))] + async fn require_remote_follow( + self: &Arc, + method: &'static str, + local_follow_id: String, + ) -> Result { + if let Some(remote_follow_id) = self.remote_follow_id(&local_follow_id) { + return Ok(remote_follow_id); + } + + let setup = { + let follows = self.follows.lock().unwrap(); + if !follows.contains_key(&local_follow_id) { + return Err(RuntimeFailure::host_failure( + method, + format!("unknown follow subscription id {local_follow_id:?}"), + )); + } + self.follow_setups + .lock() + .unwrap() + .get(&local_follow_id) + .cloned() + }; + + match setup { + Some(setup) => setup.await.map_err(|failure| failure.reclassify(method)), + None => Err(RuntimeFailure::host_failure( + method, + format!("follow subscription {local_follow_id:?} is not established"), + )), + } + } + + /// Body of the single-flight follow setup: ensure the `FollowState` + /// exists, issue `chainHead_v1_follow`, and record the remote id. + #[instrument(skip_all, fields(runtime.method = "chain_connection.run_follow_setup"))] + async fn run_follow_setup( + self: Arc, + local_follow_id: String, + with_runtime: bool, + ) -> Result { + self.follows + .lock() + .unwrap() + .entry(local_follow_id.clone()) + .or_insert_with(|| FollowState { + with_runtime, + remote_subscription_id: None, + abort: None, + sender: None, + }); + + let mut follow = self + .methods + .chainhead_v1_follow(with_runtime) + .await + .map_err(|err| { + self.remove_follow(&local_follow_id); + rpc_failure(FOLLOW_METHOD, err) + })?; + let remote_follow_id = follow + .subscription_id() + .ok_or_else(|| { + RuntimeFailure::host_failure(FOLLOW_METHOD, "missing follow subscription id") + })? + .to_string(); + + let (abort, abort_registration) = AbortHandle::new_pair(); + let connection = self.clone(); + let pump_follow_id = local_follow_id.clone(); + let pump = async move { + while let Some(item) = follow.next().await { + match item { + Ok(event) => match map_follow_event(event) { + Ok(item) => { + let is_stop = matches!(item, RemoteChainHeadFollowItem::Stop); + connection.deliver_follow_event(&pump_follow_id, item, false); + if is_stop { + break; + } + } + Err(_) => { + connection.interrupt_follow(&pump_follow_id, false); + break; + } + }, + Err(_) => { + connection.interrupt_follow(&pump_follow_id, false); + break; + } + } + } + connection.remove_follow_without_abort(&pump_follow_id); + }; + + if !self.attach_remote_follow(&local_follow_id, remote_follow_id.clone(), abort) { + return Err(RuntimeFailure::unavailable(FOLLOW_METHOD)); + } + + (self.spawner)(Abortable::new(pump, abort_registration).map(|_| ()).boxed()); + Ok(remote_follow_id) + } + + fn attach_remote_follow( + &self, + local_follow_id: &str, + remote_follow_id: String, + abort: AbortHandle, + ) -> bool { + let mut follows = self.follows.lock().unwrap(); + let Some(follow) = follows.get_mut(local_follow_id) else { + return false; + }; + follow.remote_subscription_id = Some(remote_follow_id); + follow.abort = Some(abort); + true + } + + fn remove_follow(&self, local_follow_id: &str) { + self.follow_setups.lock().unwrap().remove(local_follow_id); + if let Some(mut follow) = self.follows.lock().unwrap().remove(local_follow_id) + && let Some(abort) = follow.abort.take() + { + abort.abort(); + } + } + + fn remove_follow_without_abort(&self, local_follow_id: &str) { + self.follow_setups.lock().unwrap().remove(local_follow_id); + self.follows.lock().unwrap().remove(local_follow_id); + } + + fn unfollow(&self, local_follow_id: &str) { + self.remove_follow(local_follow_id); + } + + fn deliver_follow_event( + &self, + local_follow_id: &str, + event: RemoteChainHeadFollowItem, + abort_on_stop: bool, + ) { + let sender = self + .follows + .lock() + .unwrap() + .get(local_follow_id) + .and_then(|follow| follow.sender.clone()); + let is_stop = matches!(event, RemoteChainHeadFollowItem::Stop); + if let Some(sender) = sender { + let _ = sender.unbounded_send(FollowSignal::Item(event)); + } + if is_stop { + if abort_on_stop { + self.remove_follow(local_follow_id); + } else { + self.remove_follow_without_abort(local_follow_id); + } + } + } + + fn interrupt_follow(&self, local_follow_id: &str, abort: bool) { + let sender = self + .follows + .lock() + .unwrap() + .get(local_follow_id) + .and_then(|follow| follow.sender.clone()); + if let Some(sender) = sender { + let _ = sender.unbounded_send(FollowSignal::Interrupt); + } + if abort { + self.remove_follow(local_follow_id); + } else { + self.remove_follow_without_abort(local_follow_id); + } + } +} + +struct FollowState { + with_runtime: bool, + remote_subscription_id: Option, + abort: Option, + sender: Option>, +} + +/// Subscription wrapper that runs an `on_drop` cleanup when the stream is +/// dropped. Used by `remote_chain_head_follow` to send `chainHead_v1_unfollow` +/// when the local follow stream is dropped. +struct ManagedSubscription { + inner: BoxStream<'static, T>, + on_drop: Option>, +} + +impl ManagedSubscription { + fn new(inner: BoxStream<'static, T>, on_drop: Option>) -> Self { + Self { inner, on_drop } + } +} + +impl Drop for ManagedSubscription { + fn drop(&mut self) { + if let Some(on_drop) = self.on_drop.take() { + on_drop(); + } + } +} + +impl Stream for ManagedSubscription { + type Item = T; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + this.inner.as_mut().poll_next(cx) + } +} + +fn operation_started_result( + response: subxt_chain::MethodResponse, +) -> Result { + match response { + subxt_chain::MethodResponse::Started(started) => Ok(OperationStartedResult::Started { + operation_id: started.operation_id, + }), + subxt_chain::MethodResponse::LimitReached => Ok(OperationStartedResult::LimitReached), + } +} + +fn map_follow_event( + event: subxt_chain::FollowEvent, +) -> Result { + match event { + subxt_chain::FollowEvent::Initialized(event) => { + Ok(RemoteChainHeadFollowItem::Initialized { + finalized_block_hashes: event + .finalized_block_hashes + .into_iter() + .map(hash_to_bytes) + .collect(), + finalized_block_runtime: event + .finalized_block_runtime + .map(map_runtime_event) + .transpose()?, + }) + } + subxt_chain::FollowEvent::NewBlock(event) => Ok(RemoteChainHeadFollowItem::NewBlock { + block_hash: hash_to_bytes(event.block_hash), + parent_block_hash: hash_to_bytes(event.parent_block_hash), + new_runtime: event.new_runtime.map(map_runtime_event).transpose()?, + }), + subxt_chain::FollowEvent::BestBlockChanged(event) => { + Ok(RemoteChainHeadFollowItem::BestBlockChanged { + best_block_hash: hash_to_bytes(event.best_block_hash), + }) + } + subxt_chain::FollowEvent::Finalized(event) => Ok(RemoteChainHeadFollowItem::Finalized { + finalized_block_hashes: event + .finalized_block_hashes + .into_iter() + .map(hash_to_bytes) + .collect(), + pruned_block_hashes: event + .pruned_block_hashes + .into_iter() + .map(hash_to_bytes) + .collect(), + }), + subxt_chain::FollowEvent::OperationBodyDone(event) => { + Ok(RemoteChainHeadFollowItem::OperationBodyDone { + operation_id: event.operation_id, + value: event.value.into_iter().map(|bytes| bytes.0).collect(), + }) + } + subxt_chain::FollowEvent::OperationCallDone(event) => { + Ok(RemoteChainHeadFollowItem::OperationCallDone { + operation_id: event.operation_id, + output: event.output.0, + }) + } + subxt_chain::FollowEvent::OperationStorageItems(event) => { + Ok(RemoteChainHeadFollowItem::OperationStorageItems { + operation_id: event.operation_id, + items: event + .items + .into_iter() + .map(map_storage_result) + .collect::, _>>()?, + }) + } + subxt_chain::FollowEvent::OperationStorageDone(event) => { + Ok(RemoteChainHeadFollowItem::OperationStorageDone { + operation_id: event.operation_id, + }) + } + subxt_chain::FollowEvent::OperationWaitingForContinue(event) => { + Ok(RemoteChainHeadFollowItem::OperationWaitingForContinue { + operation_id: event.operation_id, + }) + } + subxt_chain::FollowEvent::OperationInaccessible(event) => { + Ok(RemoteChainHeadFollowItem::OperationInaccessible { + operation_id: event.operation_id, + }) + } + subxt_chain::FollowEvent::OperationError(event) => { + Ok(RemoteChainHeadFollowItem::OperationError { + operation_id: event.operation_id, + error: event.error, + }) + } + subxt_chain::FollowEvent::Stop => Ok(RemoteChainHeadFollowItem::Stop), + } +} + +fn map_runtime_event(event: subxt_chain::RuntimeEvent) -> Result { + match event { + subxt_chain::RuntimeEvent::Valid(event) => { + let mut apis = event + .spec + .apis + .into_iter() + .map(|(name, version)| RuntimeApi { name, version }) + .collect::>(); + apis.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(RuntimeType::Valid(RuntimeSpec { + spec_name: event.spec.spec_name, + impl_name: event.spec.impl_name, + spec_version: event.spec.spec_version, + impl_version: event.spec.impl_version, + transaction_version: Some(event.spec.transaction_version), + apis, + })) + } + subxt_chain::RuntimeEvent::Invalid(event) => { + Ok(RuntimeType::Invalid { error: event.error }) + } + } +} + +fn map_storage_query_item(item: &StorageQueryItem) -> subxt_chain::StorageQuery<&[u8]> { + subxt_chain::StorageQuery { + key: item.key.as_slice(), + query_type: match item.query_type { + StorageQueryType::Value => subxt_chain::StorageQueryType::Value, + StorageQueryType::Hash => subxt_chain::StorageQueryType::Hash, + StorageQueryType::ClosestDescendantMerkleValue => { + subxt_chain::StorageQueryType::ClosestDescendantMerkleValue + } + StorageQueryType::DescendantsValues => subxt_chain::StorageQueryType::DescendantsValues, + StorageQueryType::DescendantsHashes => subxt_chain::StorageQueryType::DescendantsHashes, + }, + } +} + +fn map_storage_result( + item: subxt_chain::StorageResult, +) -> Result { + let mut result = StorageResultItem { + key: item.key.0, + value: None, + hash: None, + closest_descendant_merkle_value: None, + }; + match item.result { + subxt_chain::StorageResultType::Value(value) => result.value = Some(value.0), + subxt_chain::StorageResultType::Hash(hash) => result.hash = Some(hash.0), + subxt_chain::StorageResultType::ClosestDescendantMerkleValue(value) => { + result.closest_descendant_merkle_value = Some(value.0); + } + } + Ok(result) +} + +fn hash_from_bytes(method: &'static str, bytes: &[u8]) -> Result { + if bytes.len() != 32 { + return Err(RuntimeFailure::host_failure( + method, + format!("expected 32-byte hash, got {}", bytes.len()), + )); + } + Ok(H256::from_slice(bytes)) +} + +fn hash_to_bytes(hash: H256) -> Vec { + hash.as_bytes().to_vec() +} + +fn rpc_failure(method: &'static str, error: SubxtRpcError) -> RuntimeFailure { + match error { + SubxtRpcError::Client(_) | SubxtRpcError::DisconnectedWillReconnect(_) => { + RuntimeFailure::unavailable(method) + } + error => RuntimeFailure::host_failure(method, error.to_string()), + } +} + +/// Encode a byte slice as a `0x`-prefixed lowercase hex string. +pub(crate) fn encode_hex(value: &[u8]) -> String { + format!("0x{}", hex::encode(value)) +} + +#[cfg(test)] +fn decode_hex(value: &str) -> Result, String> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)).map_err(|_| "invalid hex".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use futures::channel::mpsc as fut_mpsc; + use futures::stream::BoxStream; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn spawner_for_tests() -> Spawner { + #[cfg(not(target_arch = "wasm32"))] + { + crate::subscription::thread_per_subscription_spawner() + } + #[cfg(target_arch = "wasm32")] + { + Arc::new(futures::executor::block_on) + } + } + + /// Provider that echoes a canned response for every request it sees, + /// driven by a `respond` closure. The closure receives each json-rpc + /// request string and returns the response string the test wants the + /// server to deliver. Keeps the response loop synchronized with the + /// request stream so there is no race between `send` and the response + /// loop draining frames before pending requests have registered. + type Responder = Arc Option + Send + Sync>; + + struct ScriptedProvider { + respond: Responder, + sent: Arc>>, + sender: Arc>>>, + receiver: Arc>>>, + connect_calls: Arc, + } + + impl ScriptedProvider { + fn new(respond: F) -> Self + where + F: Fn(&str) -> Option + Send + Sync + 'static, + { + let (tx, rx) = fut_mpsc::unbounded(); + Self { + respond: Arc::new(respond), + sent: Arc::new(Mutex::new(Vec::new())), + sender: Arc::new(Mutex::new(Some(tx))), + receiver: Arc::new(Mutex::new(Some(rx))), + connect_calls: Arc::new(AtomicUsize::new(0)), + } + } + } + + struct ScriptedConnection { + respond: Responder, + sent: Arc>>, + sender: Arc>>>, + receiver: Mutex>>, + } + + impl JsonRpcConnection for ScriptedConnection { + fn send(&self, request: String) { + self.sent.lock().unwrap().push(request.clone()); + if let Some(response) = (self.respond)(&request) + && let Some(sender) = self.sender.lock().unwrap().as_ref() + { + let _ = sender.unbounded_send(response); + } + } + fn responses(&self) -> BoxStream<'static, String> { + let rx = self + .receiver + .lock() + .unwrap() + .take() + .expect("ScriptedConnection::responses called twice"); + rx.boxed() + } + + fn close(&self) { + self.sender.lock().unwrap().take(); + } + } + + #[async_trait] + impl RuntimeChainProvider for ScriptedProvider { + async fn connect( + &self, + _genesis_hash: Vec, + ) -> Result, RuntimeFailure> { + self.connect_calls.fetch_add(1, Ordering::SeqCst); + let receiver = self.receiver.lock().unwrap().take(); + Ok(Arc::new(ScriptedConnection { + respond: self.respond.clone(), + sent: self.sent.clone(), + sender: self.sender.clone(), + receiver: Mutex::new(receiver), + })) + } + } + + /// Clone of the scripted notification sender, used by tests to push + /// asynchronous frames (e.g. follow events) into the response stream. + fn notification_sender(provider: &ScriptedProvider) -> fut_mpsc::UnboundedSender { + provider + .sender + .lock() + .unwrap() + .as_ref() + .expect("notification sender available") + .clone() + } + + #[test] + fn unavailable_provider_surfaces_failure() { + let provider = Arc::new(UnavailableChainProvider); + let result = futures::executor::block_on(provider.connect(vec![0u8; 32])); + let err = match result { + Ok(_) => panic!("expected failure"), + Err(err) => err, + }; + assert_eq!(err.kind(), RuntimeFailureKind::Unavailable); + assert_eq!(err.method(), "remote_chain_connect"); + } + + /// Find the json-rpc request id of the just-sent frame so the scripted + /// responder can mirror it back to the dispatcher. + fn extract_id(request: &str) -> Option { + let value: Value = serde_json::from_str(request).ok()?; + value.get("id")?.as_str().map(ToString::to_string) + } + + fn wait_for_sent( + provider: &ScriptedProvider, + predicate: impl Fn(&[String]) -> bool, + ) -> Vec { + for _ in 0..500 { + let sent = provider.sent.lock().unwrap().clone(); + if predicate(&sent) { + return sent; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + provider.sent.lock().unwrap().clone() + } + + #[test] + fn header_request_reuses_existing_follow() { + let provider = Arc::new(ScriptedProvider::new(|request| { + let id = extract_id(request).unwrap(); + if request.contains("chainHead_v1_follow") { + Some(format!( + r#"{{"jsonrpc":"2.0","id":"{id}","result":"REMOTE-FOLLOW"}}"# + )) + } else if request.contains("chainHead_v1_header") { + Some(format!( + r#"{{"jsonrpc":"2.0","id":"{id}","result":"0xdeadbeef"}}"# + )) + } else { + None + } + })); + let runtime = ChainRuntime::new(provider.clone(), spawner_for_tests()); + let _follow_stream = runtime.remote_chain_head_follow( + "local-follow".to_string(), + RemoteChainHeadFollowRequest { + genesis_hash: vec![0u8; 32], + with_runtime: false, + }, + ); + let sent = wait_for_sent(&provider, |sent| { + sent.iter() + .any(|request| request.contains("chainHead_v1_follow")) + }); + assert!( + sent.iter() + .any(|request| request.contains("chainHead_v1_follow")), + "follow setup did not start; sent: {sent:?}", + ); + + let response = futures::executor::block_on(runtime.remote_chain_head_header( + RemoteChainHeadHeaderRequest { + genesis_hash: vec![0u8; 32], + follow_subscription_id: "local-follow".to_string(), + hash: vec![1u8; 32], + }, + )) + .expect("ok response"); + assert_eq!(response.header, Some(vec![0xde, 0xad, 0xbe, 0xef])); + assert_eq!(provider.connect_calls.load(Ordering::SeqCst), 1); + let sent = provider.sent.lock().unwrap().clone(); + assert_eq!(sent.len(), 2); + assert!(sent[0].contains("chainHead_v1_follow")); + assert!(sent[1].contains("chainHead_v1_header")); + } + + #[test] + fn header_request_rejects_unknown_follow_id_without_opening_follow() { + let provider = Arc::new(ScriptedProvider::new(|request| { + let id = extract_id(request).unwrap(); + if request.contains("chainHead_v1_follow") { + Some(format!( + r#"{{"jsonrpc":"2.0","id":"{id}","result":"REMOTE-FOLLOW"}}"# + )) + } else if request.contains("chainHead_v1_header") { + Some(format!( + r#"{{"jsonrpc":"2.0","id":"{id}","result":"0xdeadbeef"}}"# + )) + } else { + None + } + })); + let runtime = ChainRuntime::new(provider.clone(), spawner_for_tests()); + + let err = futures::executor::block_on(runtime.remote_chain_head_header( + RemoteChainHeadHeaderRequest { + genesis_hash: vec![0u8; 32], + follow_subscription_id: "missing-follow".to_string(), + hash: vec![1u8; 32], + }, + )) + .expect_err("unknown follow id should fail"); + + assert_eq!(err.kind(), RuntimeFailureKind::HostFailure); + assert!( + err.reason().contains("unknown follow subscription id"), + "unexpected error: {}", + err.reason(), + ); + assert!(provider.sent.lock().unwrap().is_empty()); + } + + /// Two concurrent calls for the same chain must share one provider + /// `connect` instead of racing the first connection and orphaning the + /// loser. + #[test] + fn concurrent_connection_for_shares_one_connect() { + struct SlowConnectProvider { + inner: ScriptedProvider, + } + + #[async_trait] + impl RuntimeChainProvider for SlowConnectProvider { + async fn connect( + &self, + genesis_hash: Vec, + ) -> Result, RuntimeFailure> { + futures_timer::Delay::new(std::time::Duration::from_millis(50)).await; + self.inner.connect(genesis_hash).await + } + } + + let provider = Arc::new(SlowConnectProvider { + inner: ScriptedProvider::new(|request| { + let id = extract_id(request).unwrap(); + if request.contains("chainSpec_v1_chainName") { + Some(format!( + r#"{{"jsonrpc":"2.0","id":"{id}","result":"Polkadot"}}"# + )) + } else { + None + } + }), + }); + let runtime = ChainRuntime::new(provider.clone(), spawner_for_tests()); + + let (first, second) = futures::executor::block_on(futures::future::join( + runtime.remote_chain_spec_chain_name(vec![0u8; 32]), + runtime.remote_chain_spec_chain_name(vec![0u8; 32]), + )); + + assert_eq!(first.unwrap().chain_name, "Polkadot"); + assert_eq!(second.unwrap().chain_name, "Polkadot"); + assert_eq!(provider.inner.connect_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn unknown_genesis_chain_spec_propagates_failure() { + let provider = Arc::new(UnavailableChainProvider); + let runtime = ChainRuntime::new(provider, spawner_for_tests()); + let err = match futures::executor::block_on( + runtime.remote_chain_spec_chain_name(vec![0u8; 32]), + ) { + Ok(_) => panic!("expected failure"), + Err(err) => err, + }; + assert_eq!(err.kind(), RuntimeFailureKind::Unavailable); + assert_eq!(err.method(), "remote_chain_spec_chain_name"); + } + + #[test] + fn json_rpc_error_becomes_host_failure() { + let provider = Arc::new(ScriptedProvider::new(|request| { + let id = extract_id(request).unwrap(); + Some(format!( + r#"{{"jsonrpc":"2.0","id":"{id}","error":{{"code":-32601,"message":"method not found"}}}}"# + )) + })); + let runtime = ChainRuntime::new(provider, spawner_for_tests()); + let err = match futures::executor::block_on( + runtime.remote_chain_spec_chain_name(vec![0u8; 32]), + ) { + Ok(_) => panic!("expected failure"), + Err(err) => err, + }; + assert_eq!(err.kind(), RuntimeFailureKind::HostFailure); + assert!( + err.reason().contains("method not found"), + "unexpected reason: {}", + err.reason() + ); + } + + #[test] + fn follow_event_initialized_translates_to_v01_item() { + // Answer `chainHead_v1_follow` through the synchronized responder so + // the ack cannot reach the response loop before the pending request + // is registered. + let provider = Arc::new(ScriptedProvider::new(|request| { + let id = extract_id(request).unwrap(); + if request.contains("chainHead_v1_follow") { + Some(format!( + r#"{{"jsonrpc":"2.0","id":"{id}","result":"REMOTE-FOLLOW"}}"# + )) + } else { + None + } + })); + let runtime = ChainRuntime::new(provider.clone(), spawner_for_tests()); + + let mut stream = runtime.remote_chain_head_follow( + "local-follow".to_string(), + RemoteChainHeadFollowRequest { + genesis_hash: vec![0u8; 32], + with_runtime: false, + }, + ); + + // Push follow events keyed by remote subscription id. Events that + // land before the follow ack are buffered by remote id and replayed + // once the follow is established. + let tx = notification_sender(&provider); + tx.unbounded_send( + r#"{"jsonrpc":"2.0","method":"chainHead_v1_followEvent","params":{"subscription":"REMOTE-FOLLOW","result":{"event":"initialized","finalizedBlockHashes":["0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}}}"# + .to_string(), + ).unwrap(); + tx.unbounded_send( + r#"{"jsonrpc":"2.0","method":"chainHead_v1_followEvent","params":{"subscription":"REMOTE-FOLLOW","result":{"event":"stop"}}}"# + .to_string(), + ).unwrap(); + + let items: Vec<_> = futures::executor::block_on(async { + let mut out = Vec::new(); + while let Some(item) = stream.next().await { + let is_stop = matches!(item, RemoteChainHeadFollowItem::Stop); + out.push(item); + if is_stop { + break; + } + } + out + }); + + match &items[0] { + RemoteChainHeadFollowItem::Initialized { + finalized_block_hashes, + finalized_block_runtime, + } => { + assert_eq!(finalized_block_hashes, &vec![vec![0xaa; 32]]); + assert!(finalized_block_runtime.is_none()); + } + other => panic!("expected Initialized, got {other:?}"), + } + assert!(matches!(items[1], RemoteChainHeadFollowItem::Stop)); + } + + #[cfg_attr(target_arch = "wasm32", ignore)] + #[test] + fn drop_follow_stream_sends_unfollow() { + let provider = Arc::new(ScriptedProvider::new(|request| { + let id = extract_id(request).unwrap(); + if request.contains("chainHead_v1_follow") { + Some(format!( + r#"{{"jsonrpc":"2.0","id":"{id}","result":"REMOTE-FOLLOW"}}"# + )) + } else { + None + } + })); + let runtime = ChainRuntime::new(provider.clone(), spawner_for_tests()); + let sent = provider.sent.clone(); + + let stream = runtime.remote_chain_head_follow( + "local-follow".to_string(), + RemoteChainHeadFollowRequest { + genesis_hash: vec![0u8; 32], + with_runtime: false, + }, + ); + + // Wait until the follow setup roundtrips and lands in `sent`. + // Generous timeout so the test stays robust under loaded CI runners + // where the spawner can be slow to schedule the request task. + for _ in 0..500 { + if !sent.lock().unwrap().is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + drop(stream); + + // Wait for the cleanup task to run and emit the unfollow request. + for _ in 0..500 { + if sent.lock().unwrap().len() >= 2 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + let messages = sent.lock().unwrap().clone(); + assert!( + messages.iter().any(|m| m.contains("chainHead_v1_unfollow")), + "unfollow not sent; messages: {messages:?}", + ); + } + + #[test] + fn encode_hex_round_trip() { + let bytes = vec![0x00u8, 0x12, 0xab, 0xff]; + let s = encode_hex(&bytes); + assert_eq!(s, "0x0012abff"); + assert_eq!(decode_hex(&s).unwrap(), bytes); + } + + #[test] + fn parse_runtime_type_valid_sorts_apis() { + let runtime_type = map_runtime_event(subxt_chain::RuntimeEvent::Valid( + subxt_chain::RuntimeVersionEvent { + spec: subxt_chain::RuntimeSpec { + spec_name: "polkadot".to_string(), + impl_name: "parity-polkadot".to_string(), + spec_version: 1000, + impl_version: 1, + transaction_version: 24, + apis: HashMap::from([("0xbeef".to_string(), 2), ("0xbabe".to_string(), 4)]), + }, + }, + )) + .unwrap(); + match runtime_type { + RuntimeType::Valid(spec) => { + assert_eq!(spec.apis.len(), 2); + assert_eq!(spec.apis[0].name, "0xbabe"); + assert_eq!(spec.apis[1].name, "0xbeef"); + assert_eq!(spec.transaction_version, Some(24)); + } + other => panic!("expected Valid, got {other:?}"), + } + } +} diff --git a/rust/crates/truapi-server/src/dispatcher.rs b/rust/crates/truapi-server/src/dispatcher.rs new file mode 100644 index 00000000..da27c8c0 --- /dev/null +++ b/rust/crates/truapi-server/src/dispatcher.rs @@ -0,0 +1,272 @@ +//! Request dispatcher. +//! +//! Routes incoming frames to the appropriate trait method based on the +//! numeric wire discriminant. The handler set is registered by the +//! auto-generated [`crate::generated::dispatcher::register`] function; this +//! module provides the framework that owns the registration tables and the +//! routing logic. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use futures::future::LocalBoxFuture; +use tracing::instrument; + +use crate::frame::{Payload, ProtocolMessage}; +use crate::generated::wire_table::{RequestFrameIds, SubscriptionFrameIds}; +use crate::subscription::{Spawner, SubscriptionManager, SubscriptionStream}; +use crate::transport::Transport; + +/// A handler for a request-response method. The returned future is not +/// required to be `Send` because the truapi trait uses `async fn`, whose +/// auto-Send-ness is not guaranteed. The `request_id` is the per-frame +/// identifier; handlers thread it into the `CallContext` so trait methods +/// can correlate logs/cancellation with the originating request. On the +/// error path handlers return the complete SCALE-encoded response payload. +pub type RequestHandler = + Arc) -> LocalBoxFuture<'static, Result, Vec>> + Send + Sync>; + +/// A handler for a subscription method. On the error path the handler returns +/// the complete SCALE-encoded `_interrupt` payload. +pub type SubscriptionHandler = Arc< + dyn Fn(String, Vec) -> LocalBoxFuture<'static, Result>> + + Send + + Sync, +>; + +/// A registered request handler plus the discriminants it replies on. +pub struct RequestEntry { + ids: RequestFrameIds, + handler: RequestHandler, +} + +/// A registered subscription handler plus the discriminants its frames carry. +pub struct SubscriptionEntry { + ids: SubscriptionFrameIds, + handler: SubscriptionHandler, +} + +/// Routes incoming protocol messages to registered handlers, keyed on the +/// numeric wire discriminant. +pub struct Dispatcher { + by_request: HashMap, + by_start: HashMap, + stop_ids: HashSet, + subscriptions: SubscriptionManager, +} + +impl Dispatcher { + /// Construct a dispatcher whose subscriptions are driven on `spawner`. + pub fn new(spawner: Spawner) -> Self { + Self { + by_request: HashMap::new(), + by_start: HashMap::new(), + stop_ids: HashSet::new(), + subscriptions: SubscriptionManager::new(spawner), + } + } + + /// Register a request-response handler, keyed on `ids.request_id`. Returns + /// the previously registered entry if any; callers (the generated + /// `dispatcher::register`) should treat `Some` as a programming error + /// since each request id must own exactly one handler. + pub fn on_request(&mut self, ids: RequestFrameIds, handler: F) -> Option + where + F: Fn(String, Vec) -> LocalBoxFuture<'static, Result, Vec>> + + Send + + Sync + + 'static, + { + self.by_request.insert( + ids.request_id, + RequestEntry { + ids, + handler: Arc::new(handler), + }, + ) + } + + /// Register a subscription handler, keyed on `ids.start_id`, and record + /// `ids.stop_id` so a matching `_stop` frame tears the subscription down. + /// Returns the previously registered entry if any. + pub fn on_subscription( + &mut self, + ids: SubscriptionFrameIds, + handler: F, + ) -> Option + where + F: Fn(String, Vec) -> LocalBoxFuture<'static, Result>> + + Send + + Sync + + 'static, + { + self.stop_ids.insert(ids.stop_id); + self.by_start.insert( + ids.start_id, + SubscriptionEntry { + ids, + handler: Arc::new(handler), + }, + ) + } + + /// Process an incoming protocol message, sending any responses or + /// subscription frames through `transport`. A discriminant with no + /// registered handler is dropped. + #[instrument(skip_all, fields(runtime.method = "dispatcher.dispatch"))] + pub async fn dispatch(&self, message: ProtocolMessage, transport: Arc) { + let id = message.payload.id; + + if let Some(entry) = self.by_request.get(&id) { + let request_id = message.request_id.clone(); + let value = (entry.handler)(request_id, message.payload.value) + .await + .unwrap_or_else(|value| value); + transport.send(ProtocolMessage { + request_id: message.request_id, + payload: Payload { + id: entry.ids.response_id, + value, + }, + }); + } else if let Some(entry) = self.by_start.get(&id) { + // Reserve the slot before awaiting the handler so a `_stop` + // arriving while the handler resolves cancels the pending + // subscription instead of racing the registration. + let token = self.subscriptions.reserve(message.request_id.clone()); + let request_id = message.request_id.clone(); + match (entry.handler)(request_id, message.payload.value).await { + Ok(stream) => { + self.subscriptions.activate( + token, + entry.ids.receive_id, + entry.ids.interrupt_id, + stream, + transport, + ); + } + Err(err_bytes) => { + self.subscriptions.cancel_reservation(token); + transport.send(ProtocolMessage { + request_id: message.request_id, + payload: Payload { + id: entry.ids.interrupt_id, + value: err_bytes, + }, + }); + } + } + } else if self.stop_ids.contains(&id) { + self.subscriptions.handle_stop(&message.request_id); + } + // Unknown discriminant: drop. Response / receive / interrupt frames are + // handled by the client side and never registered here. + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + fn test_spawner() -> Spawner { + #[cfg(not(target_arch = "wasm32"))] + { + crate::subscription::thread_per_subscription_spawner() + } + #[cfg(target_arch = "wasm32")] + { + Arc::new(futures::executor::block_on) + } + } + + #[derive(Default)] + struct RecordingTransport { + sent: Mutex>, + } + + impl RecordingTransport { + fn sent(&self) -> Vec { + self.sent.lock().unwrap().clone() + } + } + + impl Transport for RecordingTransport { + fn send(&self, message: ProtocolMessage) { + self.sent.lock().unwrap().push(message); + } + fn on_message( + &self, + _handler: Box, + ) -> Box { + Box::new(|| {}) + } + } + + fn make_frame(id: u8, value: Vec) -> ProtocolMessage { + ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { id, value }, + } + } + + /// A frame whose discriminant has no registered handler is dropped: no + /// response, no interrupt. (In production `register` registers every wire + /// method, so this only happens for malformed or client-bound ids.) + #[test] + fn dispatch_unregistered_id_sends_nothing() { + let dispatcher = Dispatcher::new(test_spawner()); + let transport = Arc::new(RecordingTransport::default()); + let transport_dyn: Arc = transport.clone(); + let frame = make_frame(250, Vec::new()); + futures::executor::block_on(dispatcher.dispatch(frame, transport_dyn)); + assert!( + transport.sent().is_empty(), + "an unregistered discriminant must produce no frame" + ); + } + + /// A handler error already owns the complete response payload. The + /// dispatcher only routes it to the registered response id. + #[test] + fn dispatch_request_handler_error_emits_response_payload() { + let mut dispatcher = Dispatcher::new(test_spawner()); + let ids = RequestFrameIds { + request_id: 200, + response_id: 201, + }; + dispatcher.on_request(ids, |_request_id, _bytes| { + Box::pin(async move { Err(vec![9, 8, 7]) }) + }); + let transport = Arc::new(RecordingTransport::default()); + let frame = make_frame(200, Vec::new()); + futures::executor::block_on(dispatcher.dispatch(frame, transport.clone())); + let sent = transport.sent(); + assert_eq!(sent.len(), 1, "exactly one response expected"); + assert_eq!(sent[0].payload.id, 201); + assert_eq!(sent[0].payload.value, vec![9, 8, 7]); + } + + /// Registering two handlers under the same key must not silently + /// overwrite. The contract chosen here is "loud": `on_request` + /// returns the previous handler, so callers can detect collisions. + #[test] + fn register_request_twice_returns_previous_handler() { + let mut dispatcher = Dispatcher::new(test_spawner()); + let ids = RequestFrameIds { + request_id: 200, + response_id: 201, + }; + let prev = dispatcher.on_request(ids, |_request_id, _bytes| { + Box::pin(async move { Ok(Vec::new()) }) + }); + assert!(prev.is_none(), "first registration has no predecessor"); + let prev = dispatcher.on_request(ids, |_request_id, _bytes| { + Box::pin(async move { Ok(Vec::new()) }) + }); + assert!( + prev.is_some(), + "second registration must return the previous handler" + ); + } +} diff --git a/rust/crates/truapi-server/src/frame.rs b/rust/crates/truapi-server/src/frame.rs new file mode 100644 index 00000000..c324200a --- /dev/null +++ b/rust/crates/truapi-server/src/frame.rs @@ -0,0 +1,433 @@ +//! Wire protocol frame types. +//! +//! Every message on the wire is a `ProtocolMessage` containing a `requestId` +//! and a `payload`. On the wire the envelope is: +//! +//! ```text +//! [requestId: SCALE str][discriminant: u8][payload bytes...] +//! ``` +//! +//! The discriminant maps to a method/kind slot via the auto-generated +//! [`crate::generated::wire_table::WIRE_TABLE`]. Method ordering is part of +//! the wire protocol; only ever append to the table. The payload bytes are +//! the SCALE-encoded inner value, inlined without a length prefix. +//! +//! In-memory we keep the numeric id directly so dispatch does not need to +//! reconstruct string action tags on every frame. + +use parity_scale_codec::{Decode, Encode, Error as CodecError, Input, Output}; + +use crate::generated::wire_table::{RequestFrameIds, SubscriptionFrameIds, WIRE_TABLE, WireKind}; + +/// Top-level wire message. Encoded as `[requestId][discriminant][bytes]`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProtocolMessage { + /// Per-message identifier carried by both halves of a request/response. + pub request_id: String, + /// Tagged payload describing the frame kind and SCALE bytes. + pub payload: Payload, +} + +/// Encode `Versioned>` from a versioned success wrapper. +/// +/// TODO(shared-core-wire): once all hosts use the shared Rust core/generated +/// client stack, remove this dispatcher compatibility rewrite and encode the +/// trait return shape directly: `Result>`. +pub fn encode_versioned_ok_payload(value: T) -> Vec { + encode_versioned_result_payload(value, 0) +} + +/// Encode `Versioned>` for methods whose success type is unit. +pub fn encode_versioned_unit_ok_payload(version: u8) -> Vec { + vec![version_index(version), 0] +} + +/// Encode `Versioned>` from an ordinary error value. +pub fn encode_versioned_err_payload(value: T, version: u8) -> Vec { + let encoded = value.encode(); + let mut out = Vec::with_capacity(encoded.len() + 2); + out.push(version_index(version)); + out.push(1); + out.extend_from_slice(&encoded); + out +} + +/// Encode `Result<(), _>` for unversioned methods whose success type is unit. +pub fn encode_raw_unit_ok_payload() -> Vec { + Ok::<(), ()>(()).encode() +} + +/// Encode `Result<(), Err>` for unversioned methods from an ordinary error value. +pub fn encode_raw_err_payload(value: T) -> Vec { + Err::<(), T>(value).encode() +} + +/// Encode a versioned subscription interrupt payload from an ordinary error. +pub fn encode_versioned_interrupt_payload(value: T, version: u8) -> Vec { + let encoded = value.encode(); + let mut out = Vec::with_capacity(encoded.len() + 1); + out.push(version_index(version)); + out.extend_from_slice(&encoded); + out +} + +impl Encode for ProtocolMessage { + fn encode_to(&self, dest: &mut T) { + self.request_id.encode_to(dest); + self.payload.id.encode_to(dest); + // Payload bytes are inlined; the receiver reads "until end of frame" + // because each transport frame is one ProtocolMessage. This matches + // the public versioned enum transport shape (variant payload encoded + // inline, no length prefix), and constrains us to slice-shaped + // `Input`s on the decode side. + dest.write(&self.payload.value); + } +} + +// Callers must hand `Decode` a slice-shaped `Input`; streaming inputs cannot +// decode this envelope because the payload has no length prefix. +impl Decode for ProtocolMessage { + fn decode(input: &mut I) -> Result { + let request_id = String::decode(input)?; + let id = u8::decode(input)?; + // Unknown ids are accepted here; routing is deferred to dispatch, + // which drops frames with no registered handler. + let remaining = input + .remaining_len()? + .ok_or_else(|| CodecError::from("frame input must report remaining length"))?; + let mut value = vec![0u8; remaining]; + input.read(&mut value)?; + Ok(ProtocolMessage { + request_id, + payload: Payload { id, value }, + }) + } +} + +/// Tagged payload. The `id` is the wire discriminant from +/// [`crate::generated::wire_table::WIRE_TABLE`], identifying the frame's method +/// and kind (request/response/start/stop/interrupt/receive). +/// +/// Note: `Payload` does not derive `Encode`/`Decode` directly; the wire +/// representation lives on [`ProtocolMessage`]. `Payload` is kept as a plain +/// data type for in-memory dispatch (key on `id`, value bytes already +/// SCALE-encoded by the call site). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Payload { + /// Wire discriminant identifying the frame's method and kind. + pub id: u8, + /// SCALE-encoded inner value bytes. + pub value: Vec, +} + +/// Request discriminants for a request method, by name. Walks the generated +/// [`WIRE_TABLE`]; intended for tests and embedders that route by method +/// string rather than holding the generated const. +pub fn request_ids(method: &str) -> Option { + WIRE_TABLE + .iter() + .find_map(|entry| match (&entry.kind, entry.method == method) { + (WireKind::Request(ids), true) => Some(*ids), + _ => None, + }) +} + +/// Subscription discriminants for a subscription method, by name. Walks the +/// generated [`WIRE_TABLE`]. +pub fn subscription_ids(method: &str) -> Option { + WIRE_TABLE + .iter() + .find_map(|entry| match (&entry.kind, entry.method == method) { + (WireKind::Subscription(ids), true) => Some(*ids), + _ => None, + }) +} + +/// Unique ID generator with a prefix. +pub struct IdFactory { + prefix: String, + counter: u64, +} + +impl IdFactory { + /// Build a factory that mints IDs of the form `{prefix}{counter}`. + pub fn new(prefix: impl Into) -> Self { + Self { + prefix: prefix.into(), + counter: 0, + } + } + + /// Return the next ID, monotonically increasing from 1. + pub fn next_id(&mut self) -> String { + self.counter += 1; + format!("{}{}", self.prefix, self.counter) + } +} + +fn encode_versioned_result_payload(value: T, result_index: u8) -> Vec { + let encoded = value.encode(); + let Some((&version_index, inner)) = encoded.split_first() else { + return vec![result_index]; + }; + let mut out = Vec::with_capacity(encoded.len() + 1); + out.push(version_index); + out.push(result_index); + out.extend_from_slice(inner); + out +} + +fn version_index(version: u8) -> u8 { + version.saturating_sub(1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Encode)] + enum TestVersioned { + V1(T), + } + + fn build(id: u8, value: Vec) -> ProtocolMessage { + ProtocolMessage { + request_id: "p:1".to_string(), + payload: Payload { id, value }, + } + } + + fn expected_wire(id: u8, value: &[u8]) -> Vec { + let mut out = Vec::new(); + "p:1".to_string().encode_to(&mut out); + out.push(id); + out.extend_from_slice(value); + out + } + + #[test] + fn handshake_request_encodes_with_discriminant_zero() { + // SCALE-encoded HostHandshakeRequest::V1(1u8) = [0u8 variant][1u8 codec_version] + let inner: Vec = vec![0x00, 0x01]; + let msg = build(0, inner.clone()); + assert_eq!(msg.encode(), expected_wire(0, &inner)); + } + + #[test] + fn get_account_request_encodes_with_discriminant_22() { + let mut inner = vec![0x00]; // V1 variant + "foo".to_string().encode_to(&mut inner); + 0u32.encode_to(&mut inner); + let msg = build(22, inner.clone()); + assert_eq!(msg.encode(), expected_wire(22, &inner)); + } + + #[test] + fn round_trip_preserves_id_and_value() { + let inner: Vec = vec![0x00, 0x42, 0xab, 0xcd]; + let msg = build(12, inner.clone()); + let decoded = ProtocolMessage::decode(&mut &msg.encode()[..]).expect("decode"); + assert_eq!(decoded, msg); + } + + /// An unknown discriminant is no longer rejected at decode; routing is + /// deferred to dispatch (which drops frames with no registered handler). + #[test] + fn unknown_discriminant_decodes_ok() { + let mut bytes = Vec::new(); + "p:1".to_string().encode_to(&mut bytes); + bytes.push(250); // far outside the populated range + bytes.extend_from_slice(&[0xaa, 0xbb]); + let decoded = ProtocolMessage::decode(&mut &bytes[..]).expect("unknown id must decode"); + assert_eq!(decoded.payload.id, 250); + assert_eq!(decoded.payload.value, vec![0xaa, 0xbb]); + } + + /// All four subscription phases round-trip through the codec. Catches a + /// regression where `Decode` mishandles a frame whose payload is empty for + /// `_stop` / `_interrupt` (no inner data) but non-empty for `_start` / + /// `_receive`. The ids are the `account_connection_status_subscribe` + /// quartet (18..=21). + #[test] + fn subscription_phases_round_trip_through_codec() { + let cases: &[(u8, Vec)] = &[ + (18, vec![0x00, 0xaa]), // start + (19, Vec::new()), // stop + (20, Vec::new()), // interrupt + (21, vec![0x01, 0x02, 0x03, 0x04]), // receive + ]; + for (id, value) in cases { + let msg = build(*id, value.clone()); + let bytes = msg.encode(); + assert_eq!( + bytes, + expected_wire(*id, value), + "encode mismatch for id {id}" + ); + let decoded = ProtocolMessage::decode(&mut &bytes[..]).expect("decode"); + assert_eq!(decoded, msg, "round-trip mismatch for id {id}"); + } + } + + /// `request_ids` / `subscription_ids` resolve a method name to its + /// generated discriminants without going through the codec. + #[test] + fn id_helpers_resolve_known_methods() { + let handshake = request_ids("system_handshake").expect("known request method"); + assert_eq!(handshake.request_id, 0); + assert_eq!(handshake.response_id, 1); + + let get_account = request_ids("account_get_account").expect("known request method"); + assert_eq!(get_account.request_id, 22); + + let sub = + subscription_ids("account_connection_status_subscribe").expect("known subscription"); + assert_eq!(sub.start_id, 18); + assert_eq!(sub.stop_id, 19); + assert_eq!(sub.interrupt_id, 20); + assert_eq!(sub.receive_id, 21); + + // A request method is not a subscription and vice versa. + assert!(subscription_ids("system_handshake").is_none()); + assert!(request_ids("account_connection_status_subscribe").is_none()); + assert!(request_ids("not_a_method").is_none()); + } + + /// Genuine zero-byte payload (e.g. unit-typed response). `Decode` must + /// handle `remaining_len == 0` without erroring or reading past EOF. + #[test] + fn empty_payload_round_trips() { + // local_storage_clear_response = 17. + let msg = build(17, Vec::new()); + let bytes = msg.encode(); + // [SCALE compact-len 0x0c][p][:][1][u8 17] = 4 + 1 = 5 bytes total + assert_eq!(bytes.len(), 5); + let decoded = ProtocolMessage::decode(&mut &bytes[..]).expect("decode"); + assert_eq!(decoded, msg); + } + + /// Compact-len mode 1 kicks in for strings with length 64..=16383. Make + /// sure the codec handles a long requestId without truncation. + #[test] + fn long_request_id_round_trips() { + let long_id: String = "x".repeat(200); + let msg = ProtocolMessage { + request_id: long_id, + payload: Payload { + id: 22, + value: vec![0x00, 0xab, 0xcd], + }, + }; + let decoded = ProtocolMessage::decode(&mut &msg.encode()[..]).expect("decode"); + assert_eq!(decoded, msg); + } + + /// Truncated frames must surface a `CodecError`, not panic. + #[test] + fn truncated_frames_error_cleanly() { + // Empty buffer. + assert!(ProtocolMessage::decode(&mut &[][..]).is_err()); + // Just the requestId, no discriminant byte. + let mut only_request_id = Vec::new(); + "p:1".to_string().encode_to(&mut only_request_id); + assert!(ProtocolMessage::decode(&mut &only_request_id[..]).is_err()); + // RequestId header claims length=200 but the buffer is far shorter. + let truncated_str_header = [200u8 << 2, 0x61, 0x62, 0x63]; + assert!(ProtocolMessage::decode(&mut &truncated_str_header[..]).is_err()); + } + + /// Empty requestId (zero-length string) is a valid SCALE-encoded `str` + /// (compact-len 0, no body). The codec must round-trip it without + /// confusing length-0 with EOF. + #[test] + fn empty_request_id_round_trips() { + let msg = ProtocolMessage { + request_id: String::new(), + payload: Payload { + id: 22, + value: vec![0x00, 0x01, 0x02], + }, + }; + let bytes = msg.encode(); + // [SCALE compact-len 0 = 0x00][discriminant][payload] + assert_eq!(bytes[0], 0x00); + let decoded = ProtocolMessage::decode(&mut &bytes[..]).expect("decode"); + assert_eq!(decoded, msg); + } + + /// Unicode characters round-trip through SCALE string encoding. + #[test] + fn unicode_request_id_round_trips() { + let msg = ProtocolMessage { + request_id: "héllo-世界-🦀".to_string(), + payload: Payload { + id: 22, + value: vec![0x00, 0x01], + }, + }; + let decoded = ProtocolMessage::decode(&mut &msg.encode()[..]).expect("decode"); + assert_eq!(decoded, msg); + } + + /// Large payload (>64KiB) round-trips. Catches buffer-size assumptions + /// in the inline-payload read path. + #[test] + fn large_payload_round_trips() { + let big = vec![0xa5u8; 100 * 1024]; + let msg = build(22, big); + let decoded = ProtocolMessage::decode(&mut &msg.encode()[..]).expect("decode"); + assert_eq!(decoded, msg); + } + + #[test] + fn encode_versioned_unit_ok_payload_wraps_unit_success() { + assert_eq!(encode_versioned_unit_ok_payload(1), vec![0u8, 0u8]); + assert_eq!(encode_versioned_unit_ok_payload(0), vec![0u8, 0u8]); + } + + #[test] + fn encode_versioned_ok_payload_wraps_success_values() { + let mut expected = vec![0u8, 0u8]; + 7u32.encode_to(&mut expected); + assert_eq!( + encode_versioned_ok_payload(TestVersioned::V1(7u32)), + expected + ); + } + + #[test] + fn encode_versioned_err_payload_wraps_error_values() { + let mut expected = vec![0u8, 1u8]; + 9u32.encode_to(&mut expected); + assert_eq!(encode_versioned_err_payload(9u32, 1), expected); + } + + #[test] + fn encode_versioned_interrupt_payload_wraps_error_values() { + let mut expected = vec![1u8]; + 9u32.encode_to(&mut expected); + assert_eq!(encode_versioned_interrupt_payload(9u32, 2), expected); + } + + /// IdFactory mints monotonically increasing ids prefixed with the + /// configured string. + #[test] + fn id_factory_minted_ids_are_unique_and_monotonic() { + let mut factory = IdFactory::new("p:"); + assert_eq!(factory.next_id(), "p:1"); + assert_eq!(factory.next_id(), "p:2"); + assert_eq!(factory.next_id(), "p:3"); + } + + /// Two distinct factories each maintain their own counter; minting from + /// one does not advance the other. + #[test] + fn two_factories_dont_share_state() { + let mut a = IdFactory::new("a:"); + let mut b = IdFactory::new("b:"); + assert_eq!(a.next_id(), "a:1"); + assert_eq!(b.next_id(), "b:1"); + assert_eq!(a.next_id(), "a:2"); + assert_eq!(b.next_id(), "b:2"); + } +} diff --git a/rust/crates/truapi-server/src/host_rpc_client.rs b/rust/crates/truapi-server/src/host_rpc_client.rs new file mode 100644 index 00000000..88229e03 --- /dev/null +++ b/rust/crates/truapi-server/src/host_rpc_client.rs @@ -0,0 +1,587 @@ +//! `subxt-rpcs` client adapter for host-provided JSON-RPC pipes. +//! +//! The platform owns the physical chain connection. This module owns only the +//! generic JSON-RPC mechanics needed to expose that pipe as a +//! [`subxt_rpcs::RpcClientT`]: request correlation, subscription routing, and +//! best-effort unsubscribe on subscription drop. + +#![allow(dead_code)] + +use core::fmt; +use core::mem; +use core::pin::Pin; +use core::task::{Context, Poll}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use futures::channel::{mpsc, oneshot}; +use futures::{FutureExt, pin_mut}; +use futures::{Stream, StreamExt}; +use serde::Serialize; +use serde_json::value::RawValue; +use subxt_rpcs::client::{RawRpcFuture, RawRpcSubscription, RpcClientT}; +use subxt_rpcs::{Error as RpcError, UserError}; +use tracing::instrument; +use truapi_platform::JsonRpcConnection; + +use crate::subscription::Spawner; + +const MAX_BUFFERED_SUBSCRIPTIONS: usize = 64; +const MAX_BUFFERED_ITEMS_PER_SUBSCRIPTION: usize = 256; + +/// JSON-RPC client backed by a host-owned [`JsonRpcConnection`]. +pub(crate) struct HostRpcClient { + inner: Arc, +} + +struct HostRpcClientInner { + connection: Arc, + request_ids: AtomicU64, + user_handles: AtomicUsize, + closed: AtomicBool, + stop_response_loop: Mutex>>, + pending: Mutex>, + subscriptions: Mutex>, + buffered_subscription_items: Mutex>>>, +} + +struct HostRpcClientLease { + inner: Arc, +} + +struct PendingRequest { + tx: oneshot::Sender, RpcError>>, +} + +#[derive(Clone)] +struct SubscriptionSink { + tx: mpsc::UnboundedSender, RpcError>>, +} + +#[derive(Debug)] +struct HostRpcClientError(String); + +impl fmt::Display for HostRpcClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for HostRpcClientError {} + +#[derive(Serialize)] +struct JsonRpcRequest<'a> { + jsonrpc: &'static str, + id: &'a str, + method: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + params: Option<&'a RawValue>, +} + +impl HostRpcClient { + /// Wrap `connection` and start the response pump on `spawner`. + pub(crate) fn new(connection: Arc, spawner: Spawner) -> Self { + let (stop_response_tx, stop_response_rx) = oneshot::channel(); + let client = Self { + inner: Arc::new(HostRpcClientInner { + connection, + request_ids: AtomicU64::new(1), + user_handles: AtomicUsize::new(1), + closed: AtomicBool::new(false), + stop_response_loop: Mutex::new(Some(stop_response_tx)), + pending: Mutex::new(HashMap::new()), + subscriptions: Mutex::new(HashMap::new()), + buffered_subscription_items: Mutex::new(HashMap::new()), + }), + }; + client.spawn_response_loop(spawner, stop_response_rx); + client + } + + /// Whether the underlying response stream has ended or failed. + pub(crate) fn is_closed(&self) -> bool { + self.inner.closed.load(Ordering::Relaxed) + } + + /// Send a JSON-RPC request without waiting for its response. + /// + /// Used by best-effort notifications where the caller must not block on + /// the remote endpoint acknowledging the request. + pub(crate) fn send_fire_and_forget( + &self, + method: &str, + params: Option>, + ) -> Result<(), RpcError> { + if self.inner.closed.load(Ordering::Relaxed) { + return Err(client_error("json-rpc connection is closed")); + } + let id = self.inner.next_request_id(); + self.inner.send_request(&id, method, params.as_deref()) + } + + fn spawn_response_loop(&self, spawner: Spawner, stop_rx: oneshot::Receiver<()>) { + let inner = self.inner.clone(); + let fut = async move { + let mut responses = inner.connection.responses(); + let stop = stop_rx.fuse(); + pin_mut!(stop); + loop { + futures::select! { + _ = stop => return, + frame = responses.next().fuse() => match frame { + Some(frame) => { + if let Err(error) = inner.handle_frame(&frame) { + inner.close_with_error(error); + return; + } + } + None => { + inner.close_with_error(client_error("json-rpc response stream ended")); + return; + } + } + } + } + }; + (spawner)(fut.boxed()); + } +} + +impl Clone for HostRpcClient { + fn clone(&self) -> Self { + self.inner.retain_user_handle(); + Self { + inner: self.inner.clone(), + } + } +} + +impl Drop for HostRpcClient { + fn drop(&mut self) { + self.inner.release_user_handle(); + } +} + +impl HostRpcClientInner { + fn retain_user_handle(&self) { + self.user_handles.fetch_add(1, Ordering::Relaxed); + } + + fn acquire_lease(self: &Arc) -> HostRpcClientLease { + self.retain_user_handle(); + HostRpcClientLease { + inner: self.clone(), + } + } + + fn release_user_handle(&self) { + let previous = self.user_handles.fetch_sub(1, Ordering::AcqRel); + debug_assert!(previous > 0, "host rpc client handle count underflow"); + if previous == 1 { + self.close_with_error(client_error("json-rpc client dropped")); + } + } + + fn next_request_id(&self) -> String { + format!( + "truapi:{}", + self.request_ids.fetch_add(1, Ordering::Relaxed) + ) + } + + fn send_request( + &self, + id: &str, + method: &str, + params: Option<&RawValue>, + ) -> Result<(), RpcError> { + let request = JsonRpcRequest { + jsonrpc: "2.0", + id, + method, + params, + }; + let encoded = serde_json::to_string(&request).map_err(RpcError::Serialization)?; + self.connection.send(encoded); + Ok(()) + } + + async fn request( + &self, + method: &str, + params: Option>, + ) -> Result, RpcError> { + let id = self.next_request_id(); + let (tx, rx) = oneshot::channel(); + { + let mut pending = self.pending.lock().unwrap(); + if self.closed.load(Ordering::Relaxed) { + return Err(client_error("json-rpc connection is closed")); + } + pending.insert(id.clone(), PendingRequest { tx }); + } + + if let Err(error) = self.send_request(&id, method, params.as_deref()) { + self.pending.lock().unwrap().remove(&id); + return Err(error); + } + + rx.await + .map_err(|_| client_error("json-rpc request was cancelled"))? + } + + async fn subscribe( + self: Arc, + method: &str, + params: Option>, + unsubscribe_method: &str, + lease: HostRpcClientLease, + ) -> Result { + let raw_id = self.request(method, params).await?; + let subscription_id = subscription_id_from_raw(raw_id.as_ref())?; + let (tx, rx) = mpsc::unbounded(); + { + let mut subscriptions = self.subscriptions.lock().unwrap(); + if self.closed.load(Ordering::Relaxed) { + return Err(client_error("json-rpc connection is closed")); + } + subscriptions.insert(subscription_id.clone(), SubscriptionSink { tx: tx.clone() }); + } + + let buffered = self + .buffered_subscription_items + .lock() + .unwrap() + .remove(&subscription_id) + .unwrap_or_default(); + for item in buffered { + let _ = tx.unbounded_send(Ok(item)); + } + + let stream = SubscriptionStream { + inner: rx, + client: self, + _lease: lease, + subscription_id: subscription_id.clone(), + unsubscribe_method: unsubscribe_method.to_string(), + closed: false, + }; + Ok(RawRpcSubscription { + stream: Box::pin(stream), + id: Some(subscription_id), + }) + } + + fn unsubscribe(&self, subscription_id: &str, unsubscribe_method: &str) { + self.subscriptions.lock().unwrap().remove(subscription_id); + if self.closed.load(Ordering::Relaxed) { + return; + } + let id = self.next_request_id(); + let params = RawValue::from_string(format!( + "[{}]", + serde_json::to_string(subscription_id).unwrap_or_else(|_| "\"\"".to_string()) + )); + if let Ok(params) = params { + let _ = self.send_request(&id, unsubscribe_method, Some(params.as_ref())); + } + } + + #[instrument(skip_all, fields(runtime.method = "host_rpc_client.handle_frame"))] + fn handle_frame(&self, frame: &str) -> Result<(), RpcError> { + let value: serde_json::Value = + serde_json::from_str(frame).map_err(RpcError::Deserialization)?; + + if value.get("method").is_some() && value.get("params").is_some() { + self.handle_notification(&value)?; + return Ok(()); + } + + let Some(request_id) = value.get("id").and_then(json_id) else { + return Ok(()); + }; + let Some(pending) = self.pending.lock().unwrap().remove(&request_id) else { + return Ok(()); + }; + + if let Some(result) = value.get("result") { + let raw = raw_value_from_json(result)?; + let _ = pending.tx.send(Ok(raw)); + return Ok(()); + } + + if let Some(error) = value.get("error") { + let _ = pending.tx.send(Err(user_error_from_json(error))); + return Ok(()); + } + + let _ = pending.tx.send(Err(client_error( + "json-rpc response missing result and error", + ))); + Ok(()) + } + + fn handle_notification(&self, value: &serde_json::Value) -> Result<(), RpcError> { + let Some(params) = value.get("params") else { + return Ok(()); + }; + let Some(subscription_id) = params.get("subscription").and_then(json_id) else { + return Ok(()); + }; + let Some(result) = params.get("result") else { + return Ok(()); + }; + let raw = raw_value_from_json(result)?; + let sink = self + .subscriptions + .lock() + .unwrap() + .get(&subscription_id) + .cloned(); + match sink { + Some(sink) => { + let _ = sink.tx.unbounded_send(Ok(raw)); + } + None => self.buffer_subscription_item(subscription_id, raw), + } + Ok(()) + } + + fn buffer_subscription_item(&self, subscription_id: String, item: Box) { + let mut buffered = self.buffered_subscription_items.lock().unwrap(); + let known = buffered.contains_key(&subscription_id); + if !known && buffered.len() >= MAX_BUFFERED_SUBSCRIPTIONS { + return; + } + let items = buffered.entry(subscription_id).or_default(); + if items.len() >= MAX_BUFFERED_ITEMS_PER_SUBSCRIPTION { + return; + } + items.push(item); + } + + fn close_with_error(&self, error: RpcError) { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + if let Some(stop) = self.stop_response_loop.lock().unwrap().take() { + let _ = stop.send(()); + } + self.connection.close(); + + let pending = { + let mut pending = self.pending.lock().unwrap(); + mem::take(&mut *pending) + }; + for (_, pending) in pending { + let _ = pending.tx.send(Err(client_error(format!( + "json-rpc connection closed: {error}" + )))); + } + + let subscriptions = mem::take(&mut *self.subscriptions.lock().unwrap()); + for (_, sink) in subscriptions { + let _ = sink.tx.unbounded_send(Err(client_error(format!( + "json-rpc connection closed: {error}" + )))); + } + self.buffered_subscription_items.lock().unwrap().clear(); + } +} + +impl Drop for HostRpcClientLease { + fn drop(&mut self) { + self.inner.release_user_handle(); + } +} + +impl RpcClientT for HostRpcClient { + fn request_raw<'a>( + &'a self, + method: &'a str, + params: Option>, + ) -> RawRpcFuture<'a, Box> { + Box::pin(async move { self.inner.request(method, params).await }) + } + + fn subscribe_raw<'a>( + &'a self, + sub: &'a str, + params: Option>, + unsub: &'a str, + ) -> RawRpcFuture<'a, RawRpcSubscription> { + let lease = self.inner.acquire_lease(); + Box::pin(async move { + self.inner + .clone() + .subscribe(sub, params, unsub, lease) + .await + }) + } +} + +struct SubscriptionStream { + inner: mpsc::UnboundedReceiver, RpcError>>, + client: Arc, + _lease: HostRpcClientLease, + subscription_id: String, + unsubscribe_method: String, + closed: bool, +} + +impl Drop for SubscriptionStream { + fn drop(&mut self) { + if !self.closed { + self.closed = true; + self.client + .unsubscribe(&self.subscription_id, &self.unsubscribe_method); + } + } +} + +impl Stream for SubscriptionStream { + type Item = Result, RpcError>; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + match Pin::new(&mut this.inner).poll_next(cx) { + Poll::Ready(None) => { + this.closed = true; + Poll::Ready(None) + } + other => other, + } + } +} + +fn raw_value_from_json(value: &serde_json::Value) -> Result, RpcError> { + RawValue::from_string(value.to_string()).map_err(RpcError::Deserialization) +} + +fn subscription_id_from_raw(raw: &RawValue) -> Result { + let value: serde_json::Value = + serde_json::from_str(raw.get()).map_err(RpcError::Deserialization)?; + json_id(&value).ok_or_else(|| client_error("json-rpc subscription id is not a string")) +} + +fn json_id(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(value) => Some(value.clone()), + serde_json::Value::Number(value) => Some(value.to_string()), + _ => None, + } +} + +fn user_error_from_json(value: &serde_json::Value) -> RpcError { + match serde_json::from_value::(value.clone()) { + Ok(error) => RpcError::User(error), + Err(error) => RpcError::Deserialization(error), + } +} + +fn client_error(reason: impl Into) -> RpcError { + RpcError::Client(Box::new(HostRpcClientError(reason.into()))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicUsize; + + use futures::executor::block_on; + use futures::stream::BoxStream; + use serde_json::{Value, json}; + use subxt_rpcs::RpcClient; + use subxt_rpcs::client::rpc_params; + + use crate::subscription::thread_per_subscription_spawner; + + struct TrackingConnection { + sender: Mutex>>, + receiver: Mutex>>, + close_count: AtomicUsize, + } + + impl TrackingConnection { + fn new() -> Arc { + let (tx, rx) = mpsc::unbounded(); + Arc::new(Self { + sender: Mutex::new(Some(tx)), + receiver: Mutex::new(Some(rx)), + close_count: AtomicUsize::new(0), + }) + } + + fn close_count(&self) -> usize { + self.close_count.load(Ordering::SeqCst) + } + } + + impl JsonRpcConnection for TrackingConnection { + fn send(&self, request: String) { + let Ok(value) = serde_json::from_str::(&request) else { + return; + }; + let Some(id) = value.get("id").cloned() else { + return; + }; + if value.get("method").and_then(Value::as_str) == Some("sub") { + let response = json!({ + "jsonrpc": "2.0", + "id": id, + "result": "sub-1", + }); + if let Some(sender) = self.sender.lock().unwrap().as_ref() { + let _ = sender.unbounded_send(response.to_string()); + } + } + } + + fn responses(&self) -> BoxStream<'static, String> { + self.receiver + .lock() + .unwrap() + .take() + .expect("responses called twice") + .boxed() + } + + fn close(&self) { + self.close_count.fetch_add(1, Ordering::SeqCst); + self.sender.lock().unwrap().take(); + } + } + + #[test] + fn dropping_one_shot_client_closes_connection_lease() { + let connection = TrackingConnection::new(); + let spawner: Spawner = Arc::new(|_| {}); + + { + let client = HostRpcClient::new(connection.clone(), spawner); + client + .send_fire_and_forget("statement_submit", None) + .unwrap(); + } + + assert_eq!(connection.close_count(), 1); + } + + #[test] + fn subscription_stream_holds_connection_lease_until_dropped() { + let connection = TrackingConnection::new(); + let client = HostRpcClient::new(connection.clone(), thread_per_subscription_spawner()); + let rpc_client = RpcClient::new(client.clone()); + + let subscription = block_on(rpc_client.subscribe::("sub", rpc_params![], "unsub")) + .expect("subscription should start"); + + drop(rpc_client); + drop(client); + assert_eq!(connection.close_count(), 0); + + drop(subscription); + assert_eq!(connection.close_count(), 1); + } +} diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index 8b56ec56..9708f559 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -1,9 +1,17 @@ //! TrUAPI server runtime support. //! -//! This layer contains host-agnostic logic shared by the runtime and target -//! adapters. Wire dispatch and platform runtime wiring are added by later stack -//! layers. +//! This layer contains host-agnostic logic, wire-frame dispatch, and chain +//! JSON-RPC mechanics shared by the runtime and target adapters. Platform +//! runtime wiring is added by a later stack layer. #![forbid(unsafe_code)] +pub(crate) mod chain_runtime; +pub(crate) mod dispatcher; +pub mod frame; pub mod host_logic; +pub(crate) mod host_rpc_client; +pub mod subscription; +pub mod transport; + +pub mod generated; diff --git a/rust/crates/truapi-server/src/subscription.rs b/rust/crates/truapi-server/src/subscription.rs new file mode 100644 index 00000000..8b24fd98 --- /dev/null +++ b/rust/crates/truapi-server/src/subscription.rs @@ -0,0 +1,495 @@ +//! Subscription lifecycle management. +//! +//! Tracks active subscriptions (start/receive/stop/interrupt) and handles +//! cleanup when either side terminates. Each registered subscription drives +//! its stream on a caller-supplied [`Spawner`]; the manager itself never +//! creates threads or runtimes. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use futures::StreamExt; +use futures::future::{BoxFuture, Either, select}; +use futures::stream::BoxStream; +use parity_scale_codec::Encode; + +use crate::frame::{Payload, ProtocolMessage}; +use crate::transport::Transport; + +type StopFn = Box; + +/// Spawns a subscription-driving future onto the caller's runtime. The +/// future is `Send` because the inner [`SubscriptionStream`] is a +/// `BoxStream<'static, _>` and every captured value the manager threads +/// through it is also `Send`. Each platform bridge supplies an +/// implementation that hands the future to the runtime driving its +/// transport (tokio `LocalSet`, `wasm_bindgen_futures::spawn_local`, ...). +pub type Spawner = Arc) + Send + Sync>; + +/// Convenience spawner for tests and embedders that don't yet wire a +/// real runtime: starts a fresh OS thread per subscription and drives the +/// future with `futures::executor::block_on`. Not available on wasm32 since +/// the platform has no threads. +#[cfg(not(target_arch = "wasm32"))] +pub fn thread_per_subscription_spawner() -> Spawner { + Arc::new(|fut: BoxFuture<'static, ()>| { + std::thread::spawn(move || futures::executor::block_on(fut)); + }) +} + +/// One yielded value of a subscription stream after SCALE-encoding. +pub enum SubscriptionOutput { + /// A regular subscription item to deliver as a `_receive` frame. + Item(Vec), + /// Stream-initiated termination delivered as an `_interrupt` frame. + Interrupt(Vec), +} + +/// Boxed stream of [`SubscriptionOutput`] consumed by the dispatcher. +pub type SubscriptionStream = BoxStream<'static, SubscriptionOutput>; + +/// Wrap a host-side stream of typed items into the SCALE-encoded +/// [`SubscriptionStream`] that the dispatcher delivers to the transport. +/// +/// `Item` is the versioned wrapper for each emitted value (e.g. +/// `versioned::account::HostAccountConnectionStatusSubscribeItem`). The +/// generated dispatcher calls this with the second type parameter inferred +/// from the host trait return. +pub fn subscription_stream(stream: S) -> SubscriptionStream +where + Item: Encode + 'static, + S: futures::Stream + Send + 'static, +{ + Box::pin(stream.map(|item| SubscriptionOutput::Item(item.encode()))) +} + +/// Generation-stamped slot tracking the lifecycle of one subscription id. +/// `request_id` is client-controlled and may be reused or raced against a +/// `_stop`, so each reservation carries a monotonic generation and only the +/// owner of the current generation may transition or remove the slot. +enum Slot { + /// Reserved by the dispatcher before its `_start` handler resolved. + /// `cancelled` flips to `true` if a `_stop` arrives in that window so + /// activation aborts instead of leaking an unstoppable stream. + Pending { generation: u64, cancelled: bool }, + /// A live subscription with its cancellation handle. + Live { generation: u64, cancel: StopFn }, +} + +/// Handle returned by [`SubscriptionManager::reserve`] and presented back to +/// [`SubscriptionManager::activate`]. Ties an activation to the exact +/// reservation it belongs to so a superseding `_start` for the same id +/// cannot be activated by a stale handler. +pub struct ReservationToken { + request_id: String, + generation: u64, +} + +/// Manages active subscriptions on the server side. +pub struct SubscriptionManager { + active: Arc>>, + next_generation: Arc, + spawner: Spawner, +} + +impl SubscriptionManager { + /// Create an empty manager driven by `spawner`. + pub fn new(spawner: Spawner) -> Self { + Self { + active: Arc::new(Mutex::new(HashMap::new())), + next_generation: Arc::new(AtomicU64::new(0)), + spawner, + } + } + + /// Reserve the slot for `request_id` before its subscription stream is + /// available. Any live subscription already under that id is stopped and + /// replaced (re-subscribe semantics). A `_stop` arriving before + /// [`activate`](Self::activate) flips the reservation to cancelled. + pub fn reserve(&self, request_id: String) -> ReservationToken { + let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); + let mut active = self.active.lock().unwrap(); + if let Some(Slot::Live { cancel, .. }) = active.insert( + request_id.clone(), + Slot::Pending { + generation, + cancelled: false, + }, + ) { + cancel(); + } + ReservationToken { + request_id, + generation, + } + } + + /// Drop a reservation whose `_start` handler failed before producing a + /// stream. No-op if the slot was superseded by a newer reservation. + pub fn cancel_reservation(&self, token: ReservationToken) { + let mut active = self.active.lock().unwrap(); + let owned = matches!( + active.get(&token.request_id), + Some(Slot::Pending { generation, .. }) if *generation == token.generation + ); + if owned { + active.remove(&token.request_id); + } + } + + /// Activate a reserved subscription with its stream, forwarding stream + /// items as `_receive` frames until the stream ends or `_stop` is + /// received. No-ops without starting the stream if the reservation was + /// cancelled by a `_stop` or superseded by a newer reservation for the + /// same id. + pub fn activate( + &self, + token: ReservationToken, + receive_id: u8, + interrupt_id: u8, + mut stream: SubscriptionStream, + transport: Arc, + ) { + let ReservationToken { + request_id, + generation, + } = token; + let rid = request_id.clone(); + let stream_transport = transport.clone(); + + // Cancellation channel. + let (cancel_tx, cancel_rx) = futures::channel::oneshot::channel::<()>(); + + // Transition the reserved slot to live, unless a `_stop` cancelled it + // or a newer reservation superseded it while the handler resolved. + { + let mut active = self.active.lock().unwrap(); + match active.get(&request_id) { + Some(Slot::Pending { + generation: g, + cancelled, + }) if *g == generation => { + if *cancelled { + active.remove(&request_id); + return; + } + } + _ => return, + } + active.insert( + request_id.clone(), + Slot::Live { + generation, + cancel: Box::new(move || { + let _ = cancel_tx.send(()); + }), + }, + ); + } + + let active = self.active.clone(); + + let future: BoxFuture<'static, ()> = Box::pin(async move { + let completed = { + let mut cancel_rx = cancel_rx; + loop { + match select(cancel_rx, stream.next()).await { + Either::Left((_cancelled, _next)) => break false, + Either::Right((item, next_cancel_rx)) => { + cancel_rx = next_cancel_rx; + match item { + Some(SubscriptionOutput::Item(value)) => { + stream_transport.send(ProtocolMessage { + request_id: rid.clone(), + payload: Payload { + id: receive_id, + value, + }, + }) + } + Some(SubscriptionOutput::Interrupt(value)) => { + stream_transport.send(ProtocolMessage { + request_id: rid.clone(), + payload: Payload { + id: interrupt_id, + value, + }, + }); + break false; + } + None => break true, + } + } + } + } + }; + + // Only remove the slot if it still holds THIS generation; a + // superseding reservation owns its own cleanup. + let removed = { + let mut active = active.lock().unwrap(); + let owned = matches!( + active.get(&request_id), + Some(Slot::Live { generation: g, .. }) if *g == generation + ); + if owned { + active.remove(&request_id); + } + owned + }; + + if completed && removed { + transport.send(ProtocolMessage { + request_id, + payload: Payload { + id: interrupt_id, + value: Vec::new(), + }, + }); + } + }); + + (self.spawner)(future); + } + + /// Convenience for callers that already hold the stream with no async gap + /// between reservation and activation (tests and synchronous embedders). + pub fn register( + &self, + request_id: String, + receive_id: u8, + interrupt_id: u8, + stream: SubscriptionStream, + transport: Arc, + ) { + let token = self.reserve(request_id); + self.activate(token, receive_id, interrupt_id, stream, transport); + } + + /// Handle a `_stop` frame from the product side. Cancels a live + /// subscription, or marks a still-pending reservation cancelled so its + /// in-flight activation aborts rather than leaking an unstoppable stream. + pub fn handle_stop(&self, request_id: &str) { + let mut active = self.active.lock().unwrap(); + match active.get_mut(request_id) { + Some(Slot::Pending { cancelled, .. }) => { + *cancelled = true; + } + Some(Slot::Live { .. }) => { + if let Some(Slot::Live { cancel, .. }) = active.remove(request_id) { + cancel(); + } + } + None => {} + } + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + use futures::stream; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// Transport that records every frame and notifies waiters when it + /// reaches a target count. Used to wait for the subscription's + /// background thread to drain a known number of frames. + struct RecordingTransport { + sent: Mutex>, + cvar: std::sync::Condvar, + } + + impl RecordingTransport { + fn new() -> Self { + Self { + sent: Mutex::new(Vec::new()), + cvar: std::sync::Condvar::new(), + } + } + fn sent(&self) -> Vec { + self.sent.lock().unwrap().clone() + } + /// Wait until at least `count` frames have been recorded, or + /// `timeout` elapses. Returns the number of frames recorded at + /// wake-up time. + fn wait_for(&self, count: usize, timeout: std::time::Duration) -> usize { + let mut guard = self.sent.lock().unwrap(); + let deadline = std::time::Instant::now() + timeout; + while guard.len() < count { + let now = std::time::Instant::now(); + if now >= deadline { + break; + } + let (new_guard, _) = self.cvar.wait_timeout(guard, deadline - now).unwrap(); + guard = new_guard; + } + guard.len() + } + } + + impl Transport for RecordingTransport { + fn send(&self, message: ProtocolMessage) { + self.sent.lock().unwrap().push(message); + self.cvar.notify_all(); + } + fn on_message( + &self, + _handler: Box, + ) -> Box { + Box::new(|| {}) + } + } + + fn dummy_stream(items: Vec>) -> SubscriptionStream { + Box::pin(stream::iter( + items.into_iter().map(SubscriptionOutput::Item), + )) + } + + /// Register a never-ending stream then immediately stop it. The + /// stream's first poll must observe cancellation and exit without + /// having pushed any frame. + #[test] + fn register_then_stop_emits_no_extra_frames() { + let transport_typed = Arc::new(RecordingTransport::new()); + let transport_dyn: Arc = transport_typed.clone(); + let manager = SubscriptionManager::new(thread_per_subscription_spawner()); + let slow_stream: SubscriptionStream = Box::pin(stream::pending()); + manager.register("p:1".to_string(), 99, 98, slow_stream, transport_dyn); + manager.handle_stop("p:1"); + // Give the worker thread a beat to observe the cancel. + std::thread::sleep(std::time::Duration::from_millis(50)); + assert!( + transport_typed.sent().is_empty(), + "stopped subscription must not push any frame" + ); + } + + /// A stream that yields 2 items then ends naturally must produce 2 + /// `_receive` frames followed by one `_interrupt` frame. + #[test] + fn register_completion_emits_interrupt() { + let transport_typed = Arc::new(RecordingTransport::new()); + let transport_dyn: Arc = transport_typed.clone(); + let manager = SubscriptionManager::new(thread_per_subscription_spawner()); + let items = dummy_stream(vec![vec![0xaa], vec![0xbb]]); + manager.register("p:1".to_string(), 99, 98, items, transport_dyn); + let observed = transport_typed.wait_for(3, std::time::Duration::from_secs(2)); + assert_eq!(observed, 3, "expected 2 receive frames + 1 interrupt"); + let frames = transport_typed.sent(); + assert_eq!(frames[0].payload.id, 99); + assert_eq!(frames[0].payload.value, vec![0xaa]); + assert_eq!(frames[1].payload.id, 99); + assert_eq!(frames[1].payload.value, vec![0xbb]); + assert_eq!(frames[2].payload.id, 98); + assert_eq!(frames[2].payload.value, Vec::::new()); + } + + /// Calling `handle_stop` twice on the same request id must be a + /// no-op the second time around (the entry has already been removed, + /// no panic, no extra frames). + #[test] + fn double_stop_is_idempotent() { + let transport_typed = Arc::new(RecordingTransport::new()); + let transport_dyn: Arc = transport_typed.clone(); + let manager = SubscriptionManager::new(thread_per_subscription_spawner()); + let slow_stream: SubscriptionStream = Box::pin(stream::pending()); + manager.register("p:1".to_string(), 99, 98, slow_stream, transport_dyn); + manager.handle_stop("p:1"); + // Second call must not panic and must not emit any frame. + manager.handle_stop("p:1"); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert!( + transport_typed.sent().is_empty(), + "double-stop must not emit any frame" + ); + } + + /// The manager must drive subscriptions through the injected spawner, + /// not by reaching out to `std::thread::spawn` itself. The counter + /// inside the test spawner is the proof. + #[test] + fn subscription_uses_provided_spawner_not_native_thread() { + let invocations = Arc::new(AtomicUsize::new(0)); + let invocations_for_spawner = invocations.clone(); + let spawner: Spawner = Arc::new(move |fut: BoxFuture<'static, ()>| { + invocations_for_spawner.fetch_add(1, Ordering::SeqCst); + std::thread::spawn(move || futures::executor::block_on(fut)); + }); + + let transport_typed = Arc::new(RecordingTransport::new()); + let transport_dyn: Arc = transport_typed.clone(); + let manager = SubscriptionManager::new(spawner); + let items = dummy_stream(vec![vec![0xcc]]); + manager.register("p:1".to_string(), 99, 98, items, transport_dyn); + + // Wait for the worker future to drain to completion so we know + // the spawner closure ran on this path. + let _ = transport_typed.wait_for(2, std::time::Duration::from_secs(2)); + assert_eq!( + invocations.load(Ordering::SeqCst), + 1, + "spawner must be invoked exactly once per register", + ); + } + + /// A `_stop` arriving before `activate` (the stop-before-register race on + /// non-serialized transports) must abort the subscription: no `_receive` + /// frames are emitted even though the stream had items to yield. + #[test] + fn stop_before_activate_aborts_subscription() { + let transport_typed = Arc::new(RecordingTransport::new()); + let transport_dyn: Arc = transport_typed.clone(); + let manager = SubscriptionManager::new(thread_per_subscription_spawner()); + let token = manager.reserve("p:1".to_string()); + manager.handle_stop("p:1"); + let items = dummy_stream(vec![vec![0x01], vec![0x02]]); + manager.activate(token, 99, 98, items, transport_dyn); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert!( + transport_typed.sent().is_empty(), + "a stop before activate must abort the subscription" + ); + } + + /// Re-using a live request id (the duplicate-`_start` case) supersedes the + /// previous subscription rather than leaking it: the first stream is + /// stopped, only the second runs, and the superseded stream leaves no + /// frames behind. + #[test] + fn duplicate_start_supersedes_previous_without_leak() { + let transport_typed = Arc::new(RecordingTransport::new()); + let transport_dyn: Arc = transport_typed.clone(); + let manager = SubscriptionManager::new(thread_per_subscription_spawner()); + + // First subscription never yields; the second reservation for the + // same id must stop it. + let pending: SubscriptionStream = Box::pin(stream::pending()); + manager.register("p:1".to_string(), 99, 98, pending, transport_dyn.clone()); + + // Second subscription yields one item then ends. + let items = dummy_stream(vec![vec![0xaa]]); + manager.register("p:1".to_string(), 99, 98, items, transport_dyn); + + // Exactly the second stream's frames appear: one receive + one + // completion interrupt. The first (pending) stream contributes none. + let observed = transport_typed.wait_for(2, std::time::Duration::from_secs(2)); + assert_eq!( + observed, 2, + "expected the second stream's receive + interrupt only" + ); + let frames = transport_typed.sent(); + assert_eq!(frames[0].payload.id, 99); + assert_eq!(frames[0].payload.value, vec![0xaa]); + assert_eq!(frames[1].payload.id, 98); + + manager.handle_stop("p:1"); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert_eq!( + transport_typed.sent().len(), + 2, + "no leaked frames from the superseded stream" + ); + } +} diff --git a/rust/crates/truapi-server/src/transport.rs b/rust/crates/truapi-server/src/transport.rs new file mode 100644 index 00000000..ba58481f --- /dev/null +++ b/rust/crates/truapi-server/src/transport.rs @@ -0,0 +1,12 @@ +//! Transport abstraction over platform-specific IPC mechanisms. + +use crate::frame::ProtocolMessage; + +/// A raw message pipe. Platform-specific implementations provide this. +pub trait Transport: Send + Sync { + /// Send a protocol message to the other side. + fn send(&self, message: ProtocolMessage); + + /// Register a handler for incoming messages. Returns an unsubscribe handle. + fn on_message(&self, handler: Box) -> Box; +} diff --git a/rust/crates/truapi-server/tests/golden_frame.rs b/rust/crates/truapi-server/tests/golden_frame.rs new file mode 100644 index 00000000..94a75050 --- /dev/null +++ b/rust/crates/truapi-server/tests/golden_frame.rs @@ -0,0 +1,53 @@ +//! Binary golden-frame regression test. +//! +//! Loads `tests/snapshots/golden-account-get.bin` (the captured raw bytes +//! of an `account_get_account_request` frame) and asserts that +//! `ProtocolMessage::decode` produces the expected in-memory shape. +//! +//! The frame encodes: +//! requestId = "p:1" +//! payload = account_get_account_request, +//! inner = HostAccountGetRequest::V1(("foo", 0u32)) +//! +//! On the wire (14 bytes): +//! [0c 70 3a 31] requestId = compact-len(3) + "p:1" +//! [16] discriminant 22 = account_get_account_request +//! [00] versioned wrapper variant V1 +//! [0c 66 6f 6f] "foo" +//! [00 00 00 00] u32 = 0 +//! +//! If this test fails after a wire-protocol change, regenerate the file +//! deliberately and re-check the change against the wire table. + +use parity_scale_codec::{Decode, Encode}; +use truapi_server::frame::{Payload, ProtocolMessage}; +use truapi_server::generated::wire_table; + +const GOLDEN: &[u8] = include_bytes!("snapshots/golden-account-get.bin"); + +#[test] +fn golden_account_get_frame_decodes_to_expected_message() { + let decoded = ProtocolMessage::decode(&mut &GOLDEN[..]) + .expect("golden frame must decode with the current wire codec"); + + let mut expected_inner = Vec::new(); + expected_inner.push(0x00u8); // V1 variant + "foo".to_string().encode_to(&mut expected_inner); + 0u32.encode_to(&mut expected_inner); + + let expected = ProtocolMessage { + request_id: "p:1".to_string(), + payload: Payload { + id: wire_table::ACCOUNT_GET_ACCOUNT.request_id, + value: expected_inner, + }, + }; + assert_eq!(decoded, expected); +} + +#[test] +fn golden_account_get_frame_round_trips() { + // Encoding the in-memory shape must reproduce the on-disk bytes exactly. + let decoded = ProtocolMessage::decode(&mut &GOLDEN[..]).expect("decode"); + assert_eq!(decoded.encode(), GOLDEN); +} diff --git a/rust/crates/truapi-server/tests/snapshots/golden-account-get.bin b/rust/crates/truapi-server/tests/snapshots/golden-account-get.bin new file mode 100644 index 0000000000000000000000000000000000000000..c66be11b9bf19e8c751b7faa4996bf36cd7e90b4 GIT binary patch literal 14 Tcmd-nurd^5;7QBRX8-~K6a)fJ literal 0 HcmV?d00001 diff --git a/rust/crates/truapi-server/tests/wire_table_ts_parity.rs b/rust/crates/truapi-server/tests/wire_table_ts_parity.rs new file mode 100644 index 00000000..e349139c --- /dev/null +++ b/rust/crates/truapi-server/tests/wire_table_ts_parity.rs @@ -0,0 +1,228 @@ +//! Cross-language parity check: the Rust `WIRE_TABLE` and the TS +//! `wire-table.ts` must list the exact same `(method, request_id, response_id)` +//! tuples in the same order. A drift here means a product built against one +//! side will fail to decode frames produced by the other. +//! +//! Both files are auto-generated text artifacts of `truapi-codegen`; the +//! parser is a small line scanner so the test runs as part of `cargo test` +//! without any node/bun dependency. +//! +//! The TS file lives under `js/packages/truapi/src/generated/wire-table.ts` +//! and is `.gitignore`d (regenerated by `scripts/codegen.sh`). When the +//! generated file is absent, the test logs a skip notice and passes, unless +//! `TRUAPI_REQUIRE_GENERATED_TS=1` is set (CI sets it after running codegen), +//! in which case the missing file is a hard failure. + +use std::path::PathBuf; + +const RUST_TABLE: &str = include_str!("../src/generated/wire_table.rs"); + +#[derive(Debug, PartialEq, Eq)] +struct Row { + method: String, + request_or_start: u8, + response_or_receive: u8, + /// Subscription `_stop` / `_interrupt` ids; `None` for request methods. + stop: Option, + interrupt: Option, + is_subscription: bool, +} + +/// Parse a wire id. A malformed id is a hard failure, never a silent `0`: a +/// defensive fallback here would let a symmetric codegen-format change collapse +/// both tables to `0`s and pass the parity check while real drift slipped by. +fn parse_id(raw: &str, method: &str) -> u8 { + raw.trim_end_matches(',') + .trim() + .parse() + .unwrap_or_else(|_| panic!("unparseable wire id for `{method}`: {raw:?}")) +} + +fn parse_rust(src: &str) -> Vec { + // The Rust codegen emits one named `pub const FOO_BAR: RequestFrameIds = ...` + // (or `SubscriptionFrameIds`) per method. The const name is + // `SCREAMING_SNAKE_CASE` of the method name; we lowercase it to match the + // TS const names. This mirrors `parse_ts` below. + let mut out = Vec::new(); + let mut iter = src.lines(); + while let Some(line) = iter.next() { + let trimmed = line.trim(); + let Some(rest) = trimmed.strip_prefix("pub const ") else { + continue; + }; + let Some(colon) = rest.find(':') else { + continue; + }; + let is_subscription = rest.contains("SubscriptionFrameIds"); + // Skip non-id consts (e.g. `WIRE_TABLE: &[WireEntry]`). + if !is_subscription && !rest.contains("RequestFrameIds") { + continue; + } + let method = rest[..colon].trim().to_ascii_lowercase(); + let mut request_or_start = None; + let mut response_or_receive = None; + let mut stop = None; + let mut interrupt = None; + for inner in iter.by_ref() { + let t = inner.trim(); + if t.starts_with("};") { + break; + } + if let Some(rest) = t + .strip_prefix("request_id: ") + .or_else(|| t.strip_prefix("start_id: ")) + { + request_or_start = Some(parse_id(rest, &method)); + } + if let Some(rest) = t + .strip_prefix("response_id: ") + .or_else(|| t.strip_prefix("receive_id: ")) + { + response_or_receive = Some(parse_id(rest, &method)); + } + if let Some(rest) = t.strip_prefix("stop_id: ") { + stop = Some(parse_id(rest, &method)); + } + if let Some(rest) = t.strip_prefix("interrupt_id: ") { + interrupt = Some(parse_id(rest, &method)); + } + } + if let (Some(rs), Some(rr)) = (request_or_start, response_or_receive) { + out.push(Row { + method, + request_or_start: rs, + response_or_receive: rr, + stop, + interrupt, + is_subscription, + }); + } + } + out +} + +fn parse_ts(src: &str) -> Vec { + // The TS codegen emits one named `export const FOO_BAR = { ... }` per + // method. The const name is `SCREAMING_SNAKE_CASE` of the method name; + // we lowercase it to match the Rust `method:` strings. + let mut out = Vec::new(); + let mut iter = src.lines().peekable(); + while let Some(line) = iter.next() { + let trimmed = line.trim(); + let Some(rest) = trimmed.strip_prefix("export const ") else { + continue; + }; + let Some(name_end) = rest.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) else { + continue; + }; + let method = rest[..name_end].to_ascii_lowercase(); + let mut request_or_start = None; + let mut response_or_receive = None; + let mut stop = None; + let mut interrupt = None; + let mut is_subscription = false; + for inner in iter.by_ref() { + let t = inner.trim(); + if t.starts_with("start:") || t.contains("SubscriptionFrameIds") { + is_subscription = true; + } + if let Some(rest) = t + .strip_prefix("request: ") + .or_else(|| t.strip_prefix("start: ")) + { + request_or_start = Some(parse_id(rest, &method)); + } + if let Some(rest) = t + .strip_prefix("response: ") + .or_else(|| t.strip_prefix("receive: ")) + { + response_or_receive = Some(parse_id(rest, &method)); + } + if let Some(rest) = t.strip_prefix("stop: ") { + stop = Some(parse_id(rest, &method)); + } + if let Some(rest) = t.strip_prefix("interrupt: ") { + interrupt = Some(parse_id(rest, &method)); + } + if t.starts_with("} as const") || t == "}" { + if let (Some(rs), Some(rr)) = (request_or_start, response_or_receive) { + out.push(Row { + method, + request_or_start: rs, + response_or_receive: rr, + stop, + interrupt, + is_subscription, + }); + } + break; + } + } + } + out +} + +#[test] +fn rust_and_ts_wire_tables_agree() { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let ts_path = manifest + .join("../../../js/packages/truapi/src/generated/wire-table.ts") + .canonicalize(); + + let require_ts = std::env::var("TRUAPI_REQUIRE_GENERATED_TS").as_deref() == Ok("1"); + + let ts_path = match ts_path { + Ok(p) => p, + Err(_) => { + assert!( + !require_ts, + "TRUAPI_REQUIRE_GENERATED_TS=1 but wire-table.ts is missing; run scripts/codegen.sh" + ); + eprintln!( + "skipping wire-table parity check: TS wire-table.ts is not present \ + (run scripts/codegen.sh to generate it)" + ); + return; + } + }; + + let ts_src = match std::fs::read_to_string(&ts_path) { + Ok(s) => s, + Err(_) => { + assert!( + !require_ts, + "TRUAPI_REQUIRE_GENERATED_TS=1 but {} is unreadable", + ts_path.display() + ); + eprintln!( + "skipping wire-table parity check: could not read {}", + ts_path.display() + ); + return; + } + }; + + let rust_rows = parse_rust(RUST_TABLE); + let ts_rows = parse_ts(&ts_src); + // Lower bound pinned to the known table size so a parser/codegen regression + // that quietly shrinks both tables in lockstep cannot pass: `assert_eq!` + // alone is satisfied by two equal-but-truncated tables. + const MIN_EXPECTED_ROWS: usize = 60; + assert!( + rust_rows.len() >= MIN_EXPECTED_ROWS, + "rust parser produced {} entries (expected >= {MIN_EXPECTED_ROWS}); \ + wire_table.rs format may have changed", + rust_rows.len() + ); + assert!( + ts_rows.len() >= MIN_EXPECTED_ROWS, + "ts parser produced {} entries (expected >= {MIN_EXPECTED_ROWS}); \ + wire-table.ts format may have changed", + ts_rows.len() + ); + assert_eq!( + rust_rows, ts_rows, + "Rust WIRE_TABLE and TS wire-table.ts diverged. Regenerate both via \ + `scripts/codegen.sh` so the codegen pipeline produces them in lockstep.", + ); +} From d9597265bfde3caa1bb5b3b33a98f5b432f3f173 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 17:13:51 +0200 Subject: [PATCH 19/56] fixup! feat(truapi-server): add wire and chain infrastructure --- rust/crates/truapi-server/src/chain_runtime.rs | 6 ++---- rust/crates/truapi-server/src/host_rpc_client.rs | 4 +--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index b8fcacca..373dfbe8 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -1,4 +1,4 @@ -//! ChainHead v1 state machine used by `PlatformRuntimeHost`. +//! ChainHead v1 state machine used by `ProductRuntimeHost`. //! //! [`ChainRuntime`] keeps one [`ChainConnection`] per chain (keyed by genesis //! hash) on top of the platform-provided [`JsonRpcConnection`]. The generic @@ -12,8 +12,6 @@ //! (`Unsupported`, `HostFailure`, ...). This avoids leaking json-rpc plumbing //! into the public API. -#![allow(dead_code)] - use core::pin::Pin; use core::task::{Context, Poll}; use std::collections::HashMap; @@ -96,7 +94,7 @@ type ConnectionSetup = Shared, Ru /// Classification of framework-level chain failures separate from JSON-RPC /// domain errors. Maps cleanly to [`truapi::CallError`] variants at the -/// `PlatformRuntimeHost` boundary. +/// `ProductRuntimeHost` boundary. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RuntimeFailureKind { /// Backend is not wired or refused the request for plumbing reasons. diff --git a/rust/crates/truapi-server/src/host_rpc_client.rs b/rust/crates/truapi-server/src/host_rpc_client.rs index 88229e03..0d46e586 100644 --- a/rust/crates/truapi-server/src/host_rpc_client.rs +++ b/rust/crates/truapi-server/src/host_rpc_client.rs @@ -5,8 +5,6 @@ //! [`subxt_rpcs::RpcClientT`]: request correlation, subscription routing, and //! best-effort unsubscribe on subscription drop. -#![allow(dead_code)] - use core::fmt; use core::mem; use core::pin::Pin; @@ -484,7 +482,7 @@ fn client_error(reason: impl Into) -> RpcError { RpcError::Client(Box::new(HostRpcClientError(reason.into()))) } -#[cfg(test)] +#[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; use std::sync::atomic::AtomicUsize; From fa642c132b2e95128f454b29511ebf27038bd66c Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 17:26:11 +0200 Subject: [PATCH 20/56] fixup! feat(truapi-server): add host logic primitives --- .../src/host_logic/sso/messages.rs | 72 ++++++++----------- .../src/host_logic/sso/messages/v1.rs | 28 ++++++++ 2 files changed, 56 insertions(+), 44 deletions(-) create mode 100644 rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs 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 abbd7e5d..b4ece9a0 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -33,6 +33,8 @@ use crate::host_logic::statement_store::{ statement_expiry_elapsed, }; +pub mod v1; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, derive_more::Display)] enum SsoResponseCode { #[codec(index = 0)] @@ -71,27 +73,7 @@ pub struct RemoteMessage { /// Versioned remote message body. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum RemoteMessageData { - V1(RemoteMessageV1), -} - -/// v1 messages exchanged with the paired signing host over the encrypted SSO channel. -/// -/// The variant order is part of the SCALE wire protocol used inside -/// statement-store session statements. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum RemoteMessageV1 { - Disconnected, - SignRequest(Box), - SignResponse(SigningResponse), - RingVrfAliasRequest(RingVrfAliasRequest), - RingVrfAliasResponse(RingVrfAliasResponse), - ResourceAllocationRequest(ResourceAllocationRequest), - ResourceAllocationResponse(ResourceAllocationResponse), - CreateTransactionRequest(CreateTransactionRequest), - CreateTransactionResponse(CreateTransactionResponse), - CreateTransactionLegacyRequest(CreateTransactionLegacyRequest), - SignRawLegacyRequest(SignRawLegacyRequest), - SignRawLegacyResponse(SignRawLegacyResponse), + V1(v1::RemoteMessage), } /// Signing request flavor sent to the signing host. @@ -155,7 +137,7 @@ impl From for SigningPayloadRequest { /// string message with a product-derived account. /// /// Built from [`HostSignRawRequest`] and wrapped in -/// [`RemoteMessageV1::SignRequest`] before being encrypted into an SSO session +/// [`v1::RemoteMessage::SignRequest`] before being encrypted into an SSO session /// statement. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SigningRawRequest { @@ -205,7 +187,7 @@ impl From for SigningRawPayload { /// Response returned by the signing host for a product-account signing request. /// -/// Decoded from [`RemoteMessageV1::SignResponse`] while the runtime is waiting +/// Decoded from [`v1::RemoteMessage::SignResponse`] while the runtime is waiting /// for a matching SSO remote message id. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SigningResponse { @@ -222,7 +204,7 @@ pub struct SigningPayloadResponseData { /// Response returned by the signing host for a legacy-account raw signing request. /// -/// Decoded from [`RemoteMessageV1::SignRawLegacyResponse`] and mapped back to +/// Decoded from [`v1::RemoteMessage::SignRawLegacyResponse`] and mapped back to /// the public raw-signing response shape. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SignRawLegacyResponse { @@ -419,7 +401,7 @@ pub fn decode_sso_session_statement( .map_err(|err| format!("invalid SSO remote message: {err}"))?; if matches!( &message.data, - RemoteMessageData::V1(RemoteMessageV1::Disconnected) + RemoteMessageData::V1(v1::RemoteMessage::Disconnected) ) { return Ok(Some(SsoSessionStatement::Disconnected)); } @@ -451,27 +433,27 @@ fn remote_response_for_message( ) -> Option { let RemoteMessageData::V1(data) = message.data; match data { - RemoteMessageV1::SignResponse(response) + v1::RemoteMessage::SignResponse(response) if response.responding_to == expected_remote_message_id => { Some(SsoRemoteResponse::Sign(response)) } - RemoteMessageV1::RingVrfAliasResponse(response) + v1::RemoteMessage::RingVrfAliasResponse(response) if response.responding_to == expected_remote_message_id => { Some(SsoRemoteResponse::RingVrfAlias(response)) } - RemoteMessageV1::SignRawLegacyResponse(response) + v1::RemoteMessage::SignRawLegacyResponse(response) if response.responding_to == expected_remote_message_id => { Some(SsoRemoteResponse::SignRawLegacy(response)) } - RemoteMessageV1::ResourceAllocationResponse(response) + v1::RemoteMessage::ResourceAllocationResponse(response) if response.responding_to == expected_remote_message_id => { Some(SsoRemoteResponse::ResourceAllocation(response)) } - RemoteMessageV1::CreateTransactionResponse(response) + v1::RemoteMessage::CreateTransactionResponse(response) if response.responding_to == expected_remote_message_id => { Some(SsoRemoteResponse::CreateTransaction(response)) @@ -484,7 +466,7 @@ fn remote_response_for_message( pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) -> RemoteMessage { RemoteMessage { message_id, - data: RemoteMessageData::V1(RemoteMessageV1::SignRequest(Box::new( + data: RemoteMessageData::V1(v1::RemoteMessage::SignRequest(Box::new( SigningRequest::Payload(Box::new(request.into())), ))), } @@ -494,9 +476,9 @@ pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) pub fn sign_raw_message(message_id: String, request: HostSignRawRequest) -> RemoteMessage { RemoteMessage { message_id, - data: RemoteMessageData::V1(RemoteMessageV1::SignRequest(Box::new(SigningRequest::Raw( - request.into(), - )))), + data: RemoteMessageData::V1(v1::RemoteMessage::SignRequest(Box::new( + SigningRequest::Raw(request.into()), + ))), } } @@ -508,7 +490,7 @@ pub fn sign_raw_legacy_message( ) -> RemoteMessage { RemoteMessage { message_id, - data: RemoteMessageData::V1(RemoteMessageV1::SignRawLegacyRequest( + data: RemoteMessageData::V1(v1::RemoteMessage::SignRawLegacyRequest( SignRawLegacyRequest { account, data: payload.into(), @@ -525,10 +507,12 @@ pub fn alias_request_message( ) -> RemoteMessage { RemoteMessage { message_id, - data: RemoteMessageData::V1(RemoteMessageV1::RingVrfAliasRequest(RingVrfAliasRequest { - product_account_id, - product_id, - })), + data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfAliasRequest( + RingVrfAliasRequest { + product_account_id, + product_id, + }, + )), } } @@ -541,7 +525,7 @@ pub fn resource_allocation_message( ) -> RemoteMessage { RemoteMessage { message_id, - data: RemoteMessageData::V1(RemoteMessageV1::ResourceAllocationRequest( + data: RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationRequest( ResourceAllocationRequest { calling_product_id, resources: resources.into_iter().map(Into::into).collect(), @@ -558,7 +542,7 @@ pub fn create_transaction_message( ) -> RemoteMessage { RemoteMessage { message_id, - data: RemoteMessageData::V1(RemoteMessageV1::CreateTransactionRequest( + data: RemoteMessageData::V1(v1::RemoteMessage::CreateTransactionRequest( CreateTransactionRequest { payload: CreateTransactionPayload::V1(payload), }, @@ -573,7 +557,7 @@ pub fn create_transaction_legacy_message( ) -> RemoteMessage { RemoteMessage { message_id, - data: RemoteMessageData::V1(RemoteMessageV1::CreateTransactionLegacyRequest( + data: RemoteMessageData::V1(v1::RemoteMessage::CreateTransactionLegacyRequest( CreateTransactionLegacyRequest { payload: CreateTransactionLegacyPayload::V1(payload), }, @@ -698,7 +682,7 @@ mod tests { fn disconnected_message_matches_host_papp_variant_order() { let message = RemoteMessage { message_id: String::new(), - data: RemoteMessageData::V1(RemoteMessageV1::Disconnected), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), }; assert_eq!(message.encode(), vec![0, 0, 0]); @@ -920,7 +904,7 @@ mod tests { ], OnExistingAllowancePolicy::Increase, ); - let RemoteMessageData::V1(RemoteMessageV1::ResourceAllocationRequest(request)) = + let RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationRequest(request)) = message.data else { panic!("expected resource allocation request"); diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs new file mode 100644 index 00000000..9e2b9cbd --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs @@ -0,0 +1,28 @@ +use parity_scale_codec::{Decode, Encode}; + +use super::{ + CreateTransactionLegacyRequest, CreateTransactionRequest, CreateTransactionResponse, + ResourceAllocationRequest, ResourceAllocationResponse, RingVrfAliasRequest, + RingVrfAliasResponse, SignRawLegacyRequest, SignRawLegacyResponse, SigningRequest, + SigningResponse, +}; + +/// v1 messages exchanged with the paired signing host over the encrypted SSO channel. +/// +/// The variant order is part of the SCALE wire protocol used inside +/// statement-store session statements. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum RemoteMessage { + Disconnected, + SignRequest(Box), + SignResponse(SigningResponse), + RingVrfAliasRequest(RingVrfAliasRequest), + RingVrfAliasResponse(RingVrfAliasResponse), + ResourceAllocationRequest(ResourceAllocationRequest), + ResourceAllocationResponse(ResourceAllocationResponse), + CreateTransactionRequest(CreateTransactionRequest), + CreateTransactionResponse(CreateTransactionResponse), + CreateTransactionLegacyRequest(CreateTransactionLegacyRequest), + SignRawLegacyRequest(SignRawLegacyRequest), + SignRawLegacyResponse(SignRawLegacyResponse), +} From 176c6b1bde93189563cbdd56e12ec15c04e05767 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 1 Jul 2026 11:19:07 +0200 Subject: [PATCH 21/56] feat(truapi-server): add platform runtime and host bridge --- .../truapi-codegen/tests/golden_rust_emit.rs | 7 +- rust/crates/truapi-server/src/core.rs | 507 +++ rust/crates/truapi-server/src/host_core.rs | 217 + rust/crates/truapi-server/src/lib.rs | 30 +- rust/crates/truapi-server/src/logging.rs | 210 + rust/crates/truapi-server/src/runtime.rs | 3482 +++++++++++++++++ .../truapi-server/src/runtime/auth_state.rs | 168 + .../truapi-server/src/runtime/identity.rs | 339 ++ .../truapi-server/src/runtime/sso_pairing.rs | 787 ++++ .../truapi-server/src/runtime/sso_remote.rs | 509 +++ .../src/runtime/statement_store.rs | 660 ++++ .../src/runtime/statement_store_rpc.rs | 139 + rust/crates/truapi-server/src/test_support.rs | 1075 +++++ rust/crates/truapi-server/src/wasm.rs | 1158 ++++++ rust/crates/truapi-server/tests/common/mod.rs | 198 + .../tests/wasm_crypto_vectors.rs | 229 ++ .../truapi-server/tests/wire_result_shape.rs | 525 +++ 17 files changed, 10231 insertions(+), 9 deletions(-) create mode 100644 rust/crates/truapi-server/src/core.rs create mode 100644 rust/crates/truapi-server/src/host_core.rs create mode 100644 rust/crates/truapi-server/src/logging.rs create mode 100644 rust/crates/truapi-server/src/runtime.rs create mode 100644 rust/crates/truapi-server/src/runtime/auth_state.rs create mode 100644 rust/crates/truapi-server/src/runtime/identity.rs create mode 100644 rust/crates/truapi-server/src/runtime/sso_pairing.rs create mode 100644 rust/crates/truapi-server/src/runtime/sso_remote.rs create mode 100644 rust/crates/truapi-server/src/runtime/statement_store.rs create mode 100644 rust/crates/truapi-server/src/runtime/statement_store_rpc.rs create mode 100644 rust/crates/truapi-server/src/test_support.rs create mode 100644 rust/crates/truapi-server/src/wasm.rs create mode 100644 rust/crates/truapi-server/tests/common/mod.rs create mode 100644 rust/crates/truapi-server/tests/wasm_crypto_vectors.rs create mode 100644 rust/crates/truapi-server/tests/wire_result_shape.rs diff --git a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs index 98f7a095..b26d9e6e 100644 --- a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -32,11 +32,8 @@ fn quoted_strings_in_const_array(src: &str, const_name: &str) -> Vec { } fn wasm_optional_callback_names(workspace: &Path) -> Vec { - let wasm_rs = workspace.join("rust/crates/truapi-server/src/wasm.rs"); - if !wasm_rs.exists() { - return Vec::new(); - } - let src = fs::read_to_string(wasm_rs).expect("read wasm.rs"); + 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| { diff --git a/rust/crates/truapi-server/src/core.rs b/rust/crates/truapi-server/src/core.rs new file mode 100644 index 00000000..16a4c373 --- /dev/null +++ b/rust/crates/truapi-server/src/core.rs @@ -0,0 +1,507 @@ +//! Internal dispatcher/runtime core. +//! +//! Public host adapters should wrap this through [`crate::HostCore`], which +//! owns the stable byte-frame ingress/egress and lifecycle API. + +use std::sync::{Arc, Mutex}; + +use futures::future::BoxFuture; +use parity_scale_codec::{Decode, Encode}; +use tracing::instrument; +use truapi::api::TrUApi; +use truapi::v01; +use truapi_platform::{Platform, RuntimeConfig}; + +use crate::dispatcher::Dispatcher; +use crate::frame::ProtocolMessage; +use crate::generated::dispatcher; +use crate::host_logic::session::SessionState; +use crate::host_logic::session_store::SessionStoreChangeNotifier; +use crate::runtime::PlatformRuntimeHost; +use crate::subscription::Spawner; +use crate::transport::Transport; +use truapi_platform::{PermissionAuthorizationRequest, PermissionAuthorizationStatus}; + +type DisconnectFn = Arc BoxFuture<'static, ()> + Send + Sync>; +type CancelLoginFn = Arc; +type PermissionAuthorizationStatusFn = Arc< + dyn Fn( + PermissionAuthorizationRequest, + ) -> BoxFuture<'static, Result> + + Send + + Sync, +>; +type PermissionAuthorizationStatusesFn = Arc< + dyn Fn( + Vec, + ) + -> BoxFuture<'static, Result, v01::GenericError>> + + Send + + Sync, +>; +type SetPermissionAuthorizationStatusFn = Arc< + dyn Fn( + PermissionAuthorizationRequest, + PermissionAuthorizationStatus, + ) -> BoxFuture<'static, Result<(), v01::GenericError>> + + Send + + Sync, +>; + +/// Top-level core. Owns the dispatcher and, on the platform path, the shared +/// session-state holder. +pub struct TrUApiCore { + dispatcher: Dispatcher, + /// Always present; empty for [`Self::new`] (direct host path), connected + /// to a [`PlatformRuntimeHost`] for [`Self::from_platform_with_config`]. + session_state: Arc, + session_store_changes: Arc, + disconnect: DisconnectFn, + cancel_login: CancelLoginFn, + permission_authorization_status: PermissionAuthorizationStatusFn, + permission_authorization_statuses: PermissionAuthorizationStatusesFn, + set_permission_authorization_status: SetPermissionAuthorizationStatusFn, +} + +impl TrUApiCore { + /// Build a core around a direct `TrUApi` implementation. The session + /// state holder is unused on this path (no platform pushes updates), + /// but is created anyway so the public API surface stays consistent. + /// Subscription work runs on `spawner`. + #[instrument(skip_all, fields(runtime.method = "core.new"))] + pub fn new

(host: Arc

, spawner: Spawner) -> Self + where + P: TrUApi + 'static, + { + let mut dispatcher = Dispatcher::new(spawner); + dispatcher::register(&mut dispatcher, host); + let session_state = SessionState::new(); + let session_store_changes = SessionStoreChangeNotifier::new(); + let disconnect_state = session_state.clone(); + Self { + dispatcher, + session_state, + session_store_changes, + disconnect: Arc::new(move || { + let state = disconnect_state.clone(); + Box::pin(async move { + state.clear_session(); + }) + }), + cancel_login: Arc::new(|| {}), + permission_authorization_status: Arc::new(|_| { + Box::pin(async { + Err(v01::GenericError { + reason: + "permission authorization is only available on platform-backed cores" + .into(), + }) + }) + }), + permission_authorization_statuses: Arc::new(|_| { + Box::pin(async { + Err(v01::GenericError { + reason: + "permission authorization is only available on platform-backed cores" + .into(), + }) + }) + }), + set_permission_authorization_status: Arc::new(|_, _| { + Box::pin(async { + Err(v01::GenericError { + reason: + "permission authorization is only available on platform-backed cores" + .into(), + }) + }) + }), + } + } + + /// Build a core around a [`Platform`] implementation and explicit product + /// runtime configuration. + #[instrument(skip_all, fields(runtime.method = "core.from_platform_with_config"))] + pub fn from_platform_with_config

( + platform: Arc

, + runtime_config: RuntimeConfig, + spawner: Spawner, + ) -> Self + where + P: Platform + 'static, + { + let runtime = Arc::new(PlatformRuntimeHost::new( + platform, + runtime_config, + spawner.clone(), + )); + runtime.start_session_store_sync(spawner.clone()); + let session_state = runtime.session_state(); + let session_store_changes = runtime.session_store_changes(); + let disconnect_runtime = runtime.clone(); + let cancel_login_runtime = runtime.clone(); + let permission_status_runtime = runtime.clone(); + let permission_statuses_runtime = runtime.clone(); + let set_permission_status_runtime = runtime.clone(); + let mut dispatcher = Dispatcher::new(spawner); + dispatcher::register(&mut dispatcher, runtime); + Self { + dispatcher, + session_state, + session_store_changes, + disconnect: Arc::new(move || { + let runtime = disconnect_runtime.clone(); + Box::pin(async move { + runtime.disconnect().await; + }) + }), + cancel_login: Arc::new(move || cancel_login_runtime.cancel_login()), + permission_authorization_status: Arc::new(move |request| { + let runtime = permission_status_runtime.clone(); + Box::pin(async move { runtime.permission_authorization_status(request).await }) + }), + permission_authorization_statuses: Arc::new(move |requests| { + let runtime = permission_statuses_runtime.clone(); + Box::pin(async move { runtime.permission_authorization_statuses(requests).await }) + }), + set_permission_authorization_status: Arc::new(move |request, status| { + let runtime = set_permission_status_runtime.clone(); + Box::pin(async move { + runtime + .set_permission_authorization_status(request, status) + .await + }) + }), + } + } + + /// Handle to the shared session-state holder used by subscriptions and + /// tests. Real host lifecycle flows through CoreStorage session sync and + /// `disconnect`. + pub fn session_state(&self) -> Arc { + self.session_state.clone() + } + + /// Notify the platform-backed session sync loop that the host-global auth + /// session slot may have changed. + #[instrument(skip_all, fields(runtime.method = "core.notify_session_store_changed"))] + pub fn notify_session_store_changed(&self) { + self.session_store_changes.notify(); + } + + /// Core-owned logout/disconnect. Platform-backed cores best-effort notify + /// the SSO peer and clear the host-global auth session; direct cores only + /// clear their in-memory session state. + #[instrument(skip_all, fields(runtime.method = "core.disconnect"))] + pub async fn disconnect_async(&self) { + (self.disconnect)().await; + } + + /// Blocking wrapper for embedders that do not drive async directly. + #[instrument(skip_all, fields(runtime.method = "core.disconnect_blocking"))] + pub fn disconnect(&self) { + futures::executor::block_on(self.disconnect_async()); + } + + /// Cancel any in-flight `request_login` pairing. The host UI receives a + /// `Disconnected` auth state immediately and the pending login resolves + /// to `Rejected`. A no-op when no login is in progress (and always a + /// no-op on the direct host path). + #[instrument(skip_all, fields(runtime.method = "core.cancel_login"))] + pub fn cancel_login(&self) { + (self.cancel_login)(); + } + + /// Read a stored permission authorization status without prompting. + #[instrument(skip_all, fields(runtime.method = "core.permission_authorization_status"))] + pub async fn permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + ) -> Result { + (self.permission_authorization_status)(request).await + } + + /// Read stored permission authorization statuses without prompting. + #[instrument(skip_all, fields(runtime.method = "core.permission_authorization_statuses"))] + pub async fn permission_authorization_statuses( + &self, + requests: Vec, + ) -> Result, v01::GenericError> { + (self.permission_authorization_statuses)(requests).await + } + + /// Update a stored permission authorization status. `NotDetermined` + /// clears the stored value so the next product request prompts again. + #[instrument(skip_all, fields(runtime.method = "core.set_permission_authorization_status"))] + pub async fn set_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + (self.set_permission_authorization_status)(request, status).await + } + + /// Asynchronous form of [`Self::receive_from_product`]. Decodes the + /// incoming frame, runs it through the dispatcher, and returns the + /// SCALE-encoded response (if any). + #[instrument(skip_all, fields(runtime.method = "core.receive_from_product"))] + pub async fn receive_from_product_async(&self, frame: &[u8]) -> Option> { + let message = ProtocolMessage::decode(&mut &*frame).ok()?; + let transport = Arc::new(ResponseTransport::default()); + self.dispatcher + .dispatch(message, transport.clone() as Arc) + .await; + transport.take().map(|response| response.encode()) + } + + /// Synchronous wrapper that blocks the current thread until the inner + /// future resolves. Convenient for embedding contexts that don't already + /// drive an async runtime. + #[instrument(skip_all, fields(runtime.method = "core.receive_from_product_blocking"))] + pub fn receive_from_product(&self, frame: &[u8]) -> Option> { + futures::executor::block_on(self.receive_from_product_async(frame)) + } + + /// Dispatch an already-decoded protocol message through the underlying + /// dispatcher. Bridges that own a long-lived transport (e.g. WebSocket, + /// JS callback) call this directly so subscription items flow back + /// through the bridge transport instead of the single-slot capture used + /// by [`Self::receive_from_product`]. + #[instrument(skip_all, fields(runtime.method = "core.dispatch"))] + pub async fn dispatch(&self, message: ProtocolMessage, transport: Arc) { + self.dispatcher.dispatch(message, transport).await; + } +} + +/// Single-slot transport that captures the next response the dispatcher +/// emits. Used by [`TrUApiCore::receive_from_product`] to bridge between the +/// dispatcher's push model and the synchronous "decode in, encoded out" +/// shape exposed to embedders. +#[derive(Default)] +struct ResponseTransport { + response: Mutex>, +} + +impl ResponseTransport { + fn take(&self) -> Option { + self.response.lock().unwrap().take() + } +} + +impl Transport for ResponseTransport { + fn send(&self, message: ProtocolMessage) { + *self.response.lock().unwrap() = Some(message); + } + + fn on_message( + &self, + _handler: Box, + ) -> Box { + Box::new(|| {}) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use parity_scale_codec::Encode; + use truapi::v01; + use truapi::versioned::local_storage::{ + HostLocalStorageClearRequest, HostLocalStorageReadRequest, HostLocalStorageWriteRequest, + }; + use truapi::versioned::notifications::HostPushNotificationRequest; + use truapi::versioned::permissions::RemotePermissionRequest; + use truapi::versioned::system::HostFeatureSupportedRequest; + + use crate::frame::{Payload, request_ids, subscription_ids}; + use crate::test_support::{StubPlatform, runtime_config, test_spawner}; + + #[test] + fn from_platform_dispatches_feature_supported() { + let core = TrUApiCore::from_platform_with_config( + Arc::new(StubPlatform::default()), + runtime_config("dotli.dot"), + test_spawner(), + ); + let request = HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + }); + let ids = request_ids("system_feature_supported").expect("known request method"); + let frame = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }; + let encoded = frame.encode(); + let response_bytes = core + .receive_from_product(&encoded) + .expect("dispatcher should emit a response"); + let response = ProtocolMessage::decode(&mut &response_bytes[..]).expect("decode response"); + assert_eq!(response.request_id, "p:1"); + assert_eq!(response.payload.id, ids.response_id); + // Wire payload is `Result`-shaped: + // [Ok disc=0x00][V1 variant 0x00][supported=1] + assert_eq!(response.payload.value, vec![0x00, 0x00, 0x01]); + } + + /// Drive a request frame through `TrUApiCore::receive_from_product`, + /// decode the response envelope, and return its payload bytes (without + /// the wrapping ProtocolMessage). Shared by the runtime-delegation + /// tests below. + fn run_request(core: &TrUApiCore, method: &str, request_bytes: Vec) -> Vec { + let ids = request_ids(method).expect("known request method"); + let frame = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.request_id, + value: request_bytes, + }, + }; + let response_bytes = core + .receive_from_product(&frame.encode()) + .expect("dispatcher should emit a response"); + let response = ProtocolMessage::decode(&mut &response_bytes[..]).expect("decode response"); + assert_eq!(response.request_id, "p:1"); + assert_eq!(response.payload.id, ids.response_id); + response.payload.value + } + + fn make_core() -> TrUApiCore { + TrUApiCore::from_platform_with_config( + Arc::new(StubPlatform::default()), + runtime_config("dotli.dot"), + test_spawner(), + ) + } + + #[test] + fn local_storage_read_round_trips_none() { + let core = make_core(); + let request = HostLocalStorageReadRequest::V1(v01::HostLocalStorageReadRequest { + key: "missing".into(), + }); + let payload = run_request(&core, "local_storage_read", request.encode()); + // Ok disc 0x00, V1 variant 0x00, Option::None = 0x00. + assert_eq!(payload, vec![0x00, 0x00, 0x00]); + } + + #[test] + fn local_storage_write_round_trips_unit_ok() { + let core = make_core(); + let request = HostLocalStorageWriteRequest::V1(v01::HostLocalStorageWriteRequest { + key: "k".into(), + value: vec![1, 2, 3], + }); + let payload = run_request(&core, "local_storage_write", request.encode()); + // Ok disc 0x00, V1 variant 0x00. + assert_eq!(payload, vec![0x00, 0x00]); + } + + #[test] + fn local_storage_clear_round_trips_unit_ok() { + let core = make_core(); + let request = + HostLocalStorageClearRequest::V1(v01::HostLocalStorageClearRequest { key: "k".into() }); + let payload = run_request(&core, "local_storage_clear", request.encode()); + // Ok disc 0x00, V1 variant 0x00. + assert_eq!(payload, vec![0x00, 0x00]); + } + + #[test] + fn send_push_notification_delegates_to_platform() { + let core = make_core(); + let request = HostPushNotificationRequest::V1(v01::HostPushNotificationRequest { + text: "hi".into(), + deeplink: None, + scheduled_at: None, + }); + let payload = run_request( + &core, + "notifications_send_push_notification", + request.encode(), + ); + // Ok disc 0x00, V1 variant 0x00, notification id 0. + let mut expected = vec![0x00u8]; + truapi::versioned::notifications::HostPushNotificationResponse::V1( + v01::HostPushNotificationResponse { id: 0 }, + ) + .encode_to(&mut expected); + assert_eq!(payload, expected); + } + + #[test] + fn request_remote_permission_round_trips_granted() { + let core = make_core(); + let request = RemotePermissionRequest::V1(v01::RemotePermissionRequest { + permission: v01::RemotePermission::ChainSubmit, + }); + let payload = run_request( + &core, + "permissions_request_remote_permission", + request.encode(), + ); + // Stub permissions grants every request. Wire is Ok disc 0x00, V1 + // variant 0x00, granted=1. + assert_eq!(payload, vec![0x00, 0x00, 0x01]); + } + + /// `connection_status_subscribe` produces a stream whose first item is + /// the current session state. Drive it through the dispatcher with a + /// recording transport and assert exactly one `_receive` frame appears. + #[test] + fn connection_status_subscribe_yields_initial_disconnected() { + use std::sync::Mutex; + + #[derive(Default)] + struct RecordingTransport { + sent: Mutex>, + } + impl Transport for RecordingTransport { + fn send(&self, message: ProtocolMessage) { + self.sent.lock().unwrap().push(message); + } + fn on_message( + &self, + _handler: Box, + ) -> Box { + Box::new(|| {}) + } + } + + let core = make_core(); + let transport = Arc::new(RecordingTransport::default()); + let dyn_transport: Arc = transport.clone(); + + let sub_ids = + subscription_ids("account_connection_status_subscribe").expect("known subscription"); + let frame = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: sub_ids.start_id, + value: Vec::new(), + }, + }; + futures::executor::block_on(core.dispatch(frame, dyn_transport)); + + // Wait briefly for the spawned thread to emit the initial item. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if !transport.sent.lock().unwrap().is_empty() { + break; + } + if std::time::Instant::now() > deadline { + panic!("subscription did not yield an item in time"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + let sent = transport.sent.lock().unwrap().clone(); + assert!(!sent.is_empty(), "expected at least one _receive frame"); + let first = &sent[0]; + assert_eq!(first.payload.id, sub_ids.receive_id); + // V1(Disconnected): V1 variant 0x00, Disconnected discriminant 0x00. + assert_eq!(first.payload.value, vec![0x00, 0x00]); + } +} diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs new file mode 100644 index 00000000..aa0426ab --- /dev/null +++ b/rust/crates/truapi-server/src/host_core.rs @@ -0,0 +1,217 @@ +//! Stable host-embedding API for the TrUAPI server runtime. +//! +//! `HostCore` is the target-neutral boundary embedders should use. Platform +//! adapters provide a [`truapi_platform::Platform`] implementation, a task +//! [`Spawner`], and a [`FrameSink`] for outgoing protocol frames. Target-specific +//! shells such as wasm-bindgen, iOS FFI, or desktop IPC should keep their +//! conversion code outside this module. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use futures::future::{AbortHandle, Abortable}; +use parity_scale_codec::{Decode, Encode}; +use thiserror::Error; +use tracing::instrument; +use truapi::v01; +use truapi_platform::{ + PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, RuntimeConfig, +}; + +use crate::core::TrUApiCore; +use crate::frame::ProtocolMessage; +use crate::subscription::Spawner; +use crate::transport::Transport; + +/// Outgoing frame sink owned by a host adapter. +/// +/// Implementations bridge encoded TrUAPI protocol frames to their target +/// transport: JS callbacks, native callbacks, IPC, channels, or another +/// host-specific mechanism. +pub trait FrameSink: Send + Sync { + /// Emit one SCALE-encoded [`ProtocolMessage`] frame. + fn emit_frame(&self, frame: Vec); +} + +/// Errors returned by [`HostCore::receive_frame`]. +#[derive(Debug, Error)] +pub enum HostCoreError { + /// Incoming bytes did not decode as a protocol frame. + #[error("invalid frame: {reason}")] + InvalidFrame { + /// Decode failure reason. + reason: String, + }, +} + +/// Target-neutral host runtime wrapper. +/// +/// `HostCore` owns the dispatcher/runtime core and handles byte-frame ingress, +/// response/subscription egress, in-flight dispatch cancellation on dispose, +/// and core-owned auth/session lifecycle operations. +pub struct HostCore { + core: TrUApiCore, + transport: Arc, + disposed: Arc, + in_flight: Mutex>, + next_dispatch_id: AtomicU64, +} + +impl HostCore { + /// Build a host core around a platform implementation and outgoing frame + /// sink. + #[instrument(skip_all, fields(runtime.method = "host_core.from_platform_with_config"))] + pub fn from_platform_with_config

( + platform: Arc

, + runtime_config: RuntimeConfig, + spawner: Spawner, + sink: Arc, + ) -> Self + where + P: Platform + 'static, + { + let disposed = Arc::new(AtomicBool::new(false)); + let transport = Arc::new(SinkTransport { + sink, + disposed: disposed.clone(), + }); + Self { + core: TrUApiCore::from_platform_with_config(platform, runtime_config, spawner), + transport, + disposed, + in_flight: Mutex::new(HashMap::new()), + next_dispatch_id: AtomicU64::new(0), + } + } + + /// Push one SCALE-encoded protocol frame into the dispatcher. + /// + /// Calls after [`Self::dispose`] are ignored and return `Ok(())` without + /// decoding. If dispose happens while a dispatch is in flight, the dispatch + /// is aborted and this method still returns `Ok(())`. + #[instrument(skip_all, fields(runtime.method = "host_core.receive_frame"))] + pub async fn receive_frame(&self, frame: Vec) -> Result<(), HostCoreError> { + if self.disposed.load(Ordering::Acquire) { + return Ok(()); + } + + let message = ProtocolMessage::decode(&mut frame.as_slice()).map_err(|err| { + HostCoreError::InvalidFrame { + reason: err.to_string(), + } + })?; + let dispatch_id = self.next_dispatch_id.fetch_add(1, Ordering::Relaxed); + let (abort_handle, abort_registration) = AbortHandle::new_pair(); + self.in_flight + .lock() + .expect("host core in-flight dispatch mutex poisoned") + .insert(dispatch_id, abort_handle); + + let transport: Arc = self.transport.clone(); + let _ = Abortable::new(self.core.dispatch(message, transport), abort_registration).await; + + self.in_flight + .lock() + .expect("host core in-flight dispatch mutex poisoned") + .remove(&dispatch_id); + Ok(()) + } + + /// Core-owned logout/disconnect. Best-effort notifies the SSO peer when + /// the session has channel material, then clears in-memory and persisted + /// session state. + #[instrument(skip_all, fields(runtime.method = "host_core.disconnect_session"))] + pub async fn disconnect_session(&self) { + self.core.disconnect_async().await; + } + + /// Cancel an in-flight pairing request. No-op when no pairing is active. + #[instrument(skip_all, fields(runtime.method = "host_core.cancel_pairing"))] + pub fn cancel_pairing(&self) { + self.core.cancel_login(); + } + + /// Notify the core that the host-global auth session slot may have changed. + /// The core re-reads the persisted blob and emits any resulting + /// session/auth state changes. + #[instrument(skip_all, fields(runtime.method = "host_core.notify_session_store_changed"))] + pub fn notify_session_store_changed(&self) { + if self.disposed.load(Ordering::Acquire) { + return; + } + self.core.notify_session_store_changed(); + } + + /// Read a stored permission authorization status without prompting. + #[instrument(skip_all, fields(runtime.method = "host_core.permission_authorization_status"))] + pub async fn permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + ) -> Result { + self.core.permission_authorization_status(request).await + } + + /// Read stored permission authorization statuses without prompting. + #[instrument(skip_all, fields(runtime.method = "host_core.permission_authorization_statuses"))] + pub async fn permission_authorization_statuses( + &self, + requests: Vec, + ) -> Result, v01::GenericError> { + self.core.permission_authorization_statuses(requests).await + } + + /// Update a stored permission authorization status. `NotDetermined` + /// clears the stored value so the next product request prompts again. + #[instrument(skip_all, fields(runtime.method = "host_core.set_permission_authorization_status"))] + pub async fn set_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + self.core + .set_permission_authorization_status(request, status) + .await + } + + /// Dispose this host core. Idempotent. + /// + /// Disposal suppresses future outgoing frames and aborts in-flight dispatch + /// futures. Adapter-specific resource cleanup remains the adapter's + /// responsibility. + #[instrument(skip_all, fields(runtime.method = "host_core.dispose"))] + pub fn dispose(&self) { + if self.disposed.swap(true, Ordering::AcqRel) { + return; + } + for (_, handle) in self + .in_flight + .lock() + .expect("host core in-flight dispatch mutex poisoned") + .drain() + { + handle.abort(); + } + } +} + +struct SinkTransport { + sink: Arc, + disposed: Arc, +} + +impl Transport for SinkTransport { + fn send(&self, message: ProtocolMessage) { + if self.disposed.load(Ordering::Acquire) { + return; + } + self.sink.emit_frame(message.encode()); + } + + fn on_message( + &self, + _handler: Box, + ) -> Box { + Box::new(|| {}) + } +} diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index 9708f559..4597a94e 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -1,17 +1,39 @@ -//! TrUAPI server runtime support. +//! TrUAPI server runtime: dispatcher, frames, SCALE encoding, stream management. //! -//! This layer contains host-agnostic logic, wire-frame dispatch, and chain -//! JSON-RPC mechanics shared by the runtime and target adapters. Platform -//! runtime wiring is added by a later stack layer. +//! The host embedding path is [`HostCore::from_platform_with_config`]. It +//! wraps a [`truapi_platform::Platform`] implementation and exposes a stable +//! byte-frame API that target adapters can use from WASM, native mobile, or +//! desktop shells. +//! +//! Host-facing bridges: +//! - [`wasm`] (wasm32 only): wasm-bindgen surface exposing `WasmHostCore`. #![forbid(unsafe_code)] pub(crate) mod chain_runtime; +pub mod core; pub(crate) mod dispatcher; pub mod frame; +pub(crate) mod host_core; pub mod host_logic; pub(crate) mod host_rpc_client; +pub mod logging; +pub(crate) mod runtime; pub mod subscription; pub mod transport; +#[cfg(test)] +pub(crate) mod test_support; + pub mod generated; + +#[cfg(target_arch = "wasm32")] +pub mod wasm; + +pub use host_core::{FrameSink, HostCore, HostCoreError}; +pub use truapi_platform::{ + PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, RuntimeConfig, +}; + +#[cfg(target_arch = "wasm32")] +pub use wasm::*; diff --git a/rust/crates/truapi-server/src/logging.rs b/rust/crates/truapi-server/src/logging.rs new file mode 100644 index 00000000..6141aad9 --- /dev/null +++ b/rust/crates/truapi-server/src/logging.rs @@ -0,0 +1,210 @@ +//! Level-controlled `tracing` output, routed to the host console. +//! +//! Events emitted via the `tracing` macros (`info!`, `debug!`, …) and +//! `#[instrument]` spans flow through a single subscriber installed once by +//! [`init`]. A reloadable [`LevelFilter`] decides what reaches the console, so +//! the verbosity is tunable at runtime via [`set_level`] (exposed to JS as +//! `setLogLevel`). Disabled by default ([`LevelFilter::OFF`]). +//! +//! On wasm each level maps to the matching `console` method +//! (`error`/`warn`/`info`/`debug`); on native everything goes to stderr. +//! In Chrome, `debug`/`trace` land on `console.debug`, which the DevTools +//! console hides unless its level dropdown includes "Verbose". +//! +//! Output is plaintext, so never log secret material (key bytes, session +//! tokens, signatures). + +use core::fmt::{self, Write as _}; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; + +use tracing::field::{Field, Visit}; +use tracing::span::{Attributes, Record}; +use tracing::{Event, Id, Level, Subscriber}; +use tracing_subscriber::Registry; +use tracing_subscriber::filter::LevelFilter; +use tracing_subscriber::layer::{Context, Layer, SubscriberExt as _}; +use tracing_subscriber::registry::LookupSpan; +use tracing_subscriber::reload; + +static RELOAD_HANDLE: OnceLock> = OnceLock::new(); +static TRACE_SPANS: AtomicBool = AtomicBool::new(false); + +/// Install the global subscriber. Idempotent: the first call wins, later +/// calls (and a foreign subscriber already being set) are no-ops. +pub fn init() { + if RELOAD_HANDLE.get().is_some() { + return; + } + let (filter, handle) = reload::Layer::::new(LevelFilter::OFF); + let subscriber = Registry::default().with(ConsoleLayer.with_filter(filter)); + if tracing::subscriber::set_global_default(subscriber).is_ok() { + let _ = RELOAD_HANDLE.set(handle); + } +} + +/// Set the live verbosity threshold. No-op until [`init`] has run. +pub fn set_level(level: LevelFilter) { + TRACE_SPANS.store(level == LevelFilter::TRACE, Ordering::Relaxed); + if let Some(handle) = RELOAD_HANDLE.get() { + let _ = handle.reload(level); + } +} + +/// Apply a host-supplied level string, installing the subscriber first so the +/// call works regardless of whether the core has been constructed yet, then +/// emitting a confirmation event so hosts can verify the logging pipeline end +/// to end. The confirmation is logged at `INFO` (mapping to `console.info`, +/// visible without DevTools "Verbose") rather than at the level just set, so it +/// surfaces even when `debug`/`trace` events land on the hidden `console.debug`. +pub fn set_level_from_str(level: &str) { + init(); + set_level(parse_level(level)); + tracing::info!(level, "log level set"); +} + +/// Parse a host-supplied level string. Unknown values disable logging. +pub fn parse_level(level: &str) -> LevelFilter { + match level.to_ascii_lowercase().as_str() { + "error" => LevelFilter::ERROR, + "warn" | "warning" => LevelFilter::WARN, + "info" => LevelFilter::INFO, + "debug" => LevelFilter::DEBUG, + "trace" => LevelFilter::TRACE, + _ => LevelFilter::OFF, + } +} + +/// Routes each event to the console method matching its level. +struct ConsoleLayer; + +impl Layer for ConsoleLayer +where + S: Subscriber, + S: for<'a> LookupSpan<'a>, +{ + fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) { + let Some(span) = ctx.span(id) else { + return; + }; + let mut visitor = EventVisitor::default(); + attrs.record(&mut visitor); + span.extensions_mut().insert(SpanFields { + fields: visitor.fields, + }); + if trace_spans_enabled() { + emit_span("new", &span); + } + } + + fn on_record(&self, id: &Id, values: &Record<'_>, ctx: Context<'_, S>) { + let Some(span) = ctx.span(id) else { + return; + }; + let mut visitor = EventVisitor::default(); + values.record(&mut visitor); + if visitor.fields.is_empty() { + return; + } + let mut extensions = span.extensions_mut(); + if let Some(fields) = extensions.get_mut::() { + if !fields.fields.is_empty() { + fields.fields.push_str(", "); + } + fields.fields.push_str(&visitor.fields); + } else { + extensions.insert(SpanFields { + fields: visitor.fields, + }); + } + } + + fn on_close(&self, id: Id, ctx: Context<'_, S>) { + if !trace_spans_enabled() { + return; + } + let Some(span) = ctx.span(&id) else { + return; + }; + emit_span("close", &span); + } + + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let meta = event.metadata(); + let mut visitor = EventVisitor::default(); + event.record(&mut visitor); + + let mut line = format!("[truapi] {} {}", meta.level(), meta.target()); + if !visitor.message.is_empty() { + let _ = write!(line, ": {}", visitor.message); + } + if !visitor.fields.is_empty() { + let _ = write!(line, " {{{}}}", visitor.fields); + } + emit(*meta.level(), &line); + } +} + +#[derive(Default)] +struct SpanFields { + fields: String, +} + +fn trace_spans_enabled() -> bool { + TRACE_SPANS.load(Ordering::Relaxed) +} + +fn emit_span(kind: &str, span: &tracing_subscriber::registry::SpanRef<'_, S>) +where + S: Subscriber, + S: for<'a> LookupSpan<'a>, +{ + let meta = span.metadata(); + let mut line = format!("[truapi] TRACE {}: span {}", meta.target(), kind); + let extensions = span.extensions(); + let fields = extensions.get::(); + let _ = write!(line, " {{span={:?}", meta.name()); + if let Some(fields) = fields + && !fields.fields.is_empty() + { + let _ = write!(line, ", {}", fields.fields); + } + line.push('}'); + emit(Level::TRACE, &line); +} + +/// Collects the implicit `message` field separately from explicit key-values. +#[derive(Default)] +struct EventVisitor { + message: String, + fields: String, +} + +impl Visit for EventVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + if field.name() == "message" { + let _ = write!(self.message, "{value:?}"); + } else { + if !self.fields.is_empty() { + self.fields.push_str(", "); + } + let _ = write!(self.fields, "{}={value:?}", field.name()); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn emit(_level: Level, line: &str) { + eprintln!("{line}"); +} + +#[cfg(target_arch = "wasm32")] +fn emit(level: Level, line: &str) { + let js = wasm_bindgen::JsValue::from_str(line); + match level { + Level::ERROR => web_sys::console::error_1(&js), + Level::WARN => web_sys::console::warn_1(&js), + Level::INFO => web_sys::console::info_1(&js), + Level::DEBUG | Level::TRACE => web_sys::console::debug_1(&js), + } +} diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs new file mode 100644 index 00000000..3cb10358 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime.rs @@ -0,0 +1,3482 @@ +//! `PlatformRuntimeHost` adapts a [`truapi_platform::Platform`] into the +//! typed `truapi::api::*` host traits the generated dispatcher routes to. +//! +//! Most methods are straight delegations to the platform; the rest carry +//! host-agnostic logic owned by the core (the chainHead-v1 runtime behind +//! the Chain surface, `dotns` URL parsing for `navigate_to`, and the +//! permission cache layer). Methods with no platform backing return +//! `CallError::unavailable()`. + +pub(crate) mod auth_state; +mod identity; +pub(crate) mod sso_pairing; +pub(crate) mod sso_remote; +pub(crate) mod statement_store; +mod statement_store_rpc; + +use std::sync::{Arc, Mutex}; + +use crate::chain_runtime::{ChainRuntime, RuntimeChainProvider, RuntimeFailure}; +use crate::host_logic::dotns::{NavigateDecision, parse_navigate}; +use crate::host_logic::entropy::derive_product_entropy_from_source; +use crate::host_logic::features::feature_supported; +use crate::host_logic::permissions::PermissionsService; +use crate::host_logic::product_account::{ + derive_product_public_key, is_product_identifier, normalize_product_identifier, + product_public_key_to_address, +}; +use crate::host_logic::session::{ + SessionInfo, SessionState, decode_persisted_session, encode_persisted_session, +}; +use crate::host_logic::session_store::SessionStoreChangeNotifier; +use crate::host_logic::sso::messages::SsoSessionStatement; +use crate::host_logic::sso::messages::{ + OnExistingAllowancePolicy, SsoAllocationOutcome, SsoRemoteResponse, alias_request_message, + create_transaction_message, decode_sso_session_statement, resource_allocation_message, + sign_payload_message, sign_raw_message, +}; +use crate::host_logic::statement_store::parse_new_statements_result; +use crate::subscription::Spawner; +use auth_state::AuthStateMachine; +use identity::resolve_session_identity_with_chain; +use sso_remote::{ + SSO_LOCAL_DISCONNECT_REASON, SSO_PEER_DISCONNECT_REASON, SessionDisconnects, sso_message_id, +}; +use statement_store_rpc::StatementStoreRpc; + +use futures::future::{AbortHandle, Abortable}; +use futures::{FutureExt, StreamExt}; +#[cfg(test)] +use parity_scale_codec::Encode; +use tracing::{debug, info, instrument}; +#[cfg(debug_assertions)] +use truapi::api::Testing; +use truapi::api::{ + Account, Chain, Chat, CoinPayment, Entropy, LocalStorage, Notifications, Payment, Permissions, + Preimage, ResourceAllocation, Signing, System, Theme, +}; +use truapi::v01; +use truapi::versioned::account::{ + HostAccountConnectionStatusSubscribeItem, HostAccountCreateProofError, + HostAccountCreateProofRequest, HostAccountCreateProofResponse, HostAccountGetAliasError, + HostAccountGetAliasRequest, HostAccountGetAliasResponse, HostAccountGetError, + HostAccountGetRequest, HostAccountGetResponse, HostGetLegacyAccountsError, + HostGetLegacyAccountsRequest, HostGetLegacyAccountsResponse, HostGetUserIdError, + HostGetUserIdRequest, HostGetUserIdResponse, HostRequestLoginError, HostRequestLoginRequest, + HostRequestLoginResponse, +}; +use truapi::versioned::chain::{ + RemoteChainHeadBodyError, RemoteChainHeadBodyRequest, RemoteChainHeadBodyResponse, + RemoteChainHeadCallError, RemoteChainHeadCallRequest, RemoteChainHeadCallResponse, + RemoteChainHeadContinueError, RemoteChainHeadContinueRequest, RemoteChainHeadContinueResponse, + RemoteChainHeadFollowItem, RemoteChainHeadFollowRequest, RemoteChainHeadHeaderError, + RemoteChainHeadHeaderRequest, RemoteChainHeadHeaderResponse, RemoteChainHeadStopOperationError, + RemoteChainHeadStopOperationRequest, RemoteChainHeadStopOperationResponse, + RemoteChainHeadStorageError, RemoteChainHeadStorageRequest, RemoteChainHeadStorageResponse, + RemoteChainHeadUnpinError, RemoteChainHeadUnpinRequest, RemoteChainHeadUnpinResponse, + RemoteChainSpecChainNameError, RemoteChainSpecChainNameRequest, + RemoteChainSpecChainNameResponse, RemoteChainSpecGenesisHashError, + RemoteChainSpecGenesisHashRequest, RemoteChainSpecGenesisHashResponse, + RemoteChainSpecPropertiesError, RemoteChainSpecPropertiesRequest, + RemoteChainSpecPropertiesResponse, RemoteChainTransactionBroadcastError, + RemoteChainTransactionBroadcastRequest, RemoteChainTransactionBroadcastResponse, + RemoteChainTransactionStopError, RemoteChainTransactionStopRequest, + RemoteChainTransactionStopResponse, +}; +use truapi::versioned::entropy::{ + HostDeriveEntropyError, HostDeriveEntropyRequest, HostDeriveEntropyResponse, +}; +use truapi::versioned::local_storage::{ + HostLocalStorageClearError, HostLocalStorageClearRequest, HostLocalStorageClearResponse, + HostLocalStorageReadError, HostLocalStorageReadRequest, HostLocalStorageReadResponse, + HostLocalStorageWriteError, HostLocalStorageWriteRequest, HostLocalStorageWriteResponse, +}; +use truapi::versioned::notifications::{ + HostPushNotificationCancelError, HostPushNotificationCancelRequest, + HostPushNotificationCancelResponse, HostPushNotificationError, HostPushNotificationRequest, + HostPushNotificationResponse, +}; +use truapi::versioned::payment::{ + HostPaymentBalanceSubscribeError, HostPaymentBalanceSubscribeItem, + HostPaymentBalanceSubscribeRequest, HostPaymentError, HostPaymentRequest, HostPaymentResponse, + HostPaymentStatusSubscribeError, HostPaymentStatusSubscribeItem, + HostPaymentStatusSubscribeRequest, HostPaymentTopUpError, HostPaymentTopUpRequest, + HostPaymentTopUpResponse, +}; +use truapi::versioned::permissions::{ + HostDevicePermissionError, HostDevicePermissionRequest, HostDevicePermissionResponse, + RemotePermissionError, RemotePermissionRequest, RemotePermissionResponse, +}; +use truapi::versioned::preimage::{ + RemotePreimageLookupSubscribeItem, RemotePreimageLookupSubscribeRequest, + RemotePreimageSubmitError, RemotePreimageSubmitRequest, RemotePreimageSubmitResponse, +}; +use truapi::versioned::resource_allocation::{ + HostRequestResourceAllocationError, HostRequestResourceAllocationRequest, + HostRequestResourceAllocationResponse, +}; +use truapi::versioned::signing::{ + HostCreateTransactionError, HostCreateTransactionRequest, HostCreateTransactionResponse, + HostCreateTransactionWithLegacyAccountError, HostCreateTransactionWithLegacyAccountRequest, + HostCreateTransactionWithLegacyAccountResponse, HostSignPayloadError, HostSignPayloadRequest, + HostSignPayloadResponse, HostSignPayloadWithLegacyAccountError, + HostSignPayloadWithLegacyAccountRequest, HostSignPayloadWithLegacyAccountResponse, + HostSignRawError, HostSignRawRequest, HostSignRawResponse, HostSignRawWithLegacyAccountError, + HostSignRawWithLegacyAccountRequest, HostSignRawWithLegacyAccountResponse, +}; +use truapi::versioned::system::{ + HostFeatureSupportedError, HostFeatureSupportedRequest, HostFeatureSupportedResponse, + HostNavigateToError, HostNavigateToRequest, HostNavigateToResponse, +}; +use truapi::versioned::theme::HostThemeSubscribeItem; +use truapi::{CallContext, CallError, Subscription}; +use truapi_platform::{ + AccountAliasReview, CoreStorageKey, CreateTransactionReview, JsonRpcConnection, + PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, PreimageSubmitReview, + RuntimeConfig, SessionUiInfo, SignPayloadReview, SignRawReview, UserConfirmationReview, +}; + +/// Adapter that exposes a [`truapi_platform::Platform`] through the +/// `truapi::api::*` trait set the generated dispatcher routes to. +pub struct PlatformRuntimeHost { + platform: Arc, + runtime_config: RuntimeConfig, + /// chainHead-v1 state machine. The provider adapter forwards + /// [`truapi_platform::ChainProvider::connect`] into the json-rpc layer. + chain: ChainRuntime, + /// Currently-paired session, pushed by the host through a side channel. + /// Account-management subscriptions read from this in lieu of round-tripping + /// a callback on every connection-status query. + session_state: Arc, + session_store_changes: Arc, + session_disconnects: Arc, + sso_disconnect_monitor: Arc>>, + spawner: Spawner, + /// Auth UI state machine; the single funnel for `auth_state_changed`. + auth_state: AuthStateMachine, +} + +struct SsoDisconnectMonitor { + session_id_own: [u8; 32], + session_id_peer: [u8; 32], + abort: AbortHandle, +} + +impl Drop for SsoDisconnectMonitor { + fn drop(&mut self) { + self.abort.abort(); + } +} + +impl PlatformRuntimeHost { + /// Wrap a platform implementation. The runtime takes ownership via `Arc`. + /// `spawner` is used by the embedded chain runtime to drive json-rpc + /// response loops and follow-setup futures. + pub fn new( + platform: Arc, + runtime_config: RuntimeConfig, + spawner: Spawner, + ) -> Self { + let chain_provider = Self::chain_provider(platform.clone()); + Self { + auth_state: AuthStateMachine::new(platform.clone()), + platform, + runtime_config, + chain: ChainRuntime::new(chain_provider, spawner.clone()), + session_state: SessionState::new(), + session_store_changes: SessionStoreChangeNotifier::new(), + session_disconnects: Arc::new(SessionDisconnects::default()), + sso_disconnect_monitor: Arc::new(Mutex::new(None)), + spawner, + } + } + + /// Compatibility constructor used only by tests that do not exercise + /// product-scoped behavior. + #[cfg(test)] + fn new_compat(platform: Arc, spawner: Spawner) -> Self { + Self::new( + platform, + RuntimeConfig { + product_id: "unknown.dot".to_string(), + host_info: truapi_platform::HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://example.invalid/dotli.png".to_string()), + version: None, + }, + platform_info: truapi_platform::PlatformInfo::default(), + people_chain_genesis_hash: [0; 32], + pairing_deeplink_scheme: "polkadotapp".to_string(), + }, + spawner, + ) + } + + /// Chain provider backing the chainHead-v1 runtime. Chain access routes + /// through the platform's host-owned `ChainProvider`. + fn chain_provider(platform: Arc) -> Arc { + Arc::new(HostChainProvider { platform }) + } + + /// Clone of the shared session-state holder used by core subscriptions + /// and tests. Real host lifecycle flows through CoreStorage session sync + /// and `disconnect`. + pub fn session_state(&self) -> Arc { + self.session_state.clone() + } + + /// Clone of the notifier used to wake the session-store sync loop. + pub fn session_store_changes(&self) -> Arc { + self.session_store_changes.clone() + } + + fn start_sso_disconnect_monitor(&self, session: &SessionInfo) { + start_sso_disconnect_monitor( + self.platform.clone(), + self.runtime_config.clone(), + self.session_state.clone(), + self.session_disconnects.clone(), + self.auth_state.clone(), + self.sso_disconnect_monitor.clone(), + self.spawner.clone(), + session, + ); + } + + /// Start syncing the in-memory session from the host-global auth session + /// slot. + /// The store emits coarse ticks; each tick triggers a fresh read so same- + /// runtime writes and cross-runtime logout/re-pair take the same path. + #[instrument(skip_all, fields(runtime.method = "session_store.sync"))] + pub(crate) fn start_session_store_sync(&self, spawner: Spawner) { + let platform = self.platform.clone(); + let chain = self.chain.clone(); + let runtime_config = self.runtime_config.clone(); + let session_state = self.session_state.clone(); + let auth_state = self.auth_state.clone(); + let session_disconnects = self.session_disconnects.clone(); + let sso_disconnect_monitor = self.sso_disconnect_monitor.clone(); + let spawner_for_monitor = self.spawner.clone(); + let session_store_changes = self.session_store_changes.clone(); + spawner(Box::pin(async move { + let mut ticks = session_store_changes.subscribe(); + // Clearing the store can itself notify this subscription; clear at + // most once per read-error streak so a persistently failing read + // cannot spin the loop through its own clear notifications. + let mut cleared_after_read_error = false; + while ticks.next().await.is_some() { + match platform + .read_core_storage(CoreStorageKey::AuthSession) + .await + { + Ok(Some(blob)) => { + cleared_after_read_error = false; + match decode_persisted_session(&blob) { + Ok(session) => { + let resolved = resolve_session_identity_with_chain( + &chain, + runtime_config.people_chain_genesis_hash, + session, + ) + .await; + if encode_persisted_session(&resolved) != blob { + let _ = platform + .write_core_storage( + CoreStorageKey::AuthSession, + encode_persisted_session(&resolved), + ) + .await; + } + auth_state.connected(&connected_session_ui_info(&resolved)); + session_state.set_session(resolved); + if let Some(session) = session_state.current() { + start_sso_disconnect_monitor( + platform.clone(), + runtime_config.clone(), + session_state.clone(), + session_disconnects.clone(), + auth_state.clone(), + sso_disconnect_monitor.clone(), + spawner_for_monitor.clone(), + &session, + ); + } + } + Err(_) => { + session_state.clear_session(); + stop_sso_disconnect_monitor(&sso_disconnect_monitor); + let _ = platform + .clear_core_storage(CoreStorageKey::AuthSession) + .await; + auth_state.store_disconnected(); + } + } + } + Ok(None) => { + cleared_after_read_error = false; + session_state.clear_session(); + stop_sso_disconnect_monitor(&sso_disconnect_monitor); + auth_state.store_disconnected(); + } + Err(_) => { + session_state.clear_session(); + stop_sso_disconnect_monitor(&sso_disconnect_monitor); + auth_state.store_disconnected(); + if !cleared_after_read_error { + cleared_after_read_error = true; + let _ = platform + .clear_core_storage(CoreStorageKey::AuthSession) + .await; + } + } + } + } + })); + } + + /// Core-owned logout/disconnect path. It best-effort notifies the SSO + /// peer, then clears in-memory and persisted session state regardless of + /// any transport failure. + #[instrument(skip_all, fields(runtime.method = "account.disconnect"))] + pub(crate) async fn disconnect(&self) { + self.cancel_login(); + self.session_disconnects.notify(SSO_LOCAL_DISCONNECT_REASON); + let session = self.session_state.current(); + self.clear_disconnected_session().await; + if let Some(session) = session.as_ref() { + let _ = self.submit_sso_disconnected(session).await; + } + } + + /// Cancel any in-flight `request_login` pairing: the host UI gets a + /// `Disconnected` state immediately and the login flow resolves to + /// `Rejected`. A no-op when no login is in progress. + #[instrument(skip_all, fields(runtime.method = "account.cancel_login"))] + pub(crate) fn cancel_login(&self) { + self.auth_state.login_cancelled(); + } + + /// Static product/host configuration for this runtime instance. + #[allow(dead_code)] + pub fn runtime_config(&self) -> &RuntimeConfig { + &self.runtime_config + } + + fn is_product_account_valid_for_caller(&self, dot_ns_identifier: &str) -> bool { + let dot_ns_identifier = normalize_product_identifier(dot_ns_identifier); + let product_id = normalize_product_identifier(&self.runtime_config.product_id); + if self.runtime_config.product_id.starts_with("localhost:") { + is_product_identifier(&dot_ns_identifier) + } else { + dot_ns_identifier == product_id + } + } + + fn normalize_product_account_id( + product_account_id: v01::ProductAccountId, + ) -> v01::ProductAccountId { + v01::ProductAccountId { + dot_ns_identifier: normalize_product_identifier(&product_account_id.dot_ns_identifier), + derivation_index: product_account_id.derivation_index, + } + } + + fn product_id(&self) -> String { + normalize_product_identifier(&self.runtime_config.product_id) + } + + fn legacy_slot_zero_public_key(&self, session: &SessionInfo) -> Result<[u8; 32], String> { + derive_product_public_key(session.public_key, &self.product_id(), 0) + .map_err(|err| err.to_string()) + } + + #[instrument(skip_all, fields(runtime.method = "session_store.clear_disconnected"))] + async fn clear_disconnected_session(&self) { + debug!("clearing disconnected SSO session state"); + stop_sso_disconnect_monitor(&self.sso_disconnect_monitor); + self.session_state.clear_session(); + let _ = self + .platform + .clear_core_storage(CoreStorageKey::AuthSession) + .await; + self.auth_state.store_disconnected(); + let _ = self + .platform + .clear_core_storage(CoreStorageKey::PairingDeviceIdentity) + .await; + } +} + +fn stop_sso_disconnect_monitor(monitor: &Arc>>) { + monitor + .lock() + .expect("SSO disconnect monitor mutex poisoned") + .take(); +} + +/// True when an own/peer session-id pair matches the captured SSO session. +fn same_sso_session( + own: [u8; 32], + peer: [u8; 32], + sso: &crate::host_logic::session::SsoSessionInfo, +) -> bool { + own == sso.session_id_own && peer == sso.session_id_peer +} + +#[allow(clippy::too_many_arguments)] +fn start_sso_disconnect_monitor( + platform: Arc, + runtime_config: RuntimeConfig, + session_state: Arc, + session_disconnects: Arc, + auth_state: AuthStateMachine, + monitor: Arc>>, + spawner: Spawner, + session: &SessionInfo, +) { + let Some(sso) = session.sso.clone() else { + stop_sso_disconnect_monitor(&monitor); + return; + }; + + { + let mut current = monitor + .lock() + .expect("SSO disconnect monitor mutex poisoned"); + if current.as_ref().is_some_and(|active| { + same_sso_session(active.session_id_own, active.session_id_peer, &sso) + }) { + return; + } + let (abort, registration) = AbortHandle::new_pair(); + *current = Some(SsoDisconnectMonitor { + session_id_own: sso.session_id_own, + session_id_peer: sso.session_id_peer, + abort, + }); + + let monitor_slot = monitor.clone(); + let monitor_spawner = spawner.clone(); + let future = async move { + let result = wait_for_sso_peer_disconnect( + platform.clone(), + runtime_config.people_chain_genesis_hash, + sso.clone(), + monitor_spawner.clone(), + ) + .await; + let peer_disconnected = result.is_ok(); + let should_clear = peer_disconnected + && session_state.current().as_ref().is_some_and(|current| { + current.sso.as_ref().is_some_and(|current_sso| { + same_sso_session( + current_sso.session_id_own, + current_sso.session_id_peer, + &sso, + ) + }) + }); + { + let mut active = monitor_slot + .lock() + .expect("SSO disconnect monitor mutex poisoned"); + if active.as_ref().is_some_and(|active| { + same_sso_session(active.session_id_own, active.session_id_peer, &sso) + }) { + *active = None; + } + } + if should_clear { + session_disconnects.notify(SSO_PEER_DISCONNECT_REASON); + session_state.clear_session(); + let _ = platform + .clear_core_storage(CoreStorageKey::AuthSession) + .await; + auth_state.store_disconnected(); + let _ = platform + .clear_core_storage(CoreStorageKey::PairingDeviceIdentity) + .await; + } + }; + spawner(Box::pin(Abortable::new(future, registration).map(|_| ()))); + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.peer_disconnect.monitor"))] +async fn wait_for_sso_peer_disconnect( + platform: Arc, + people_chain_genesis_hash: [u8; 32], + session: crate::host_logic::session::SsoSessionInfo, + spawner: Spawner, +) -> Result<(), String> { + let statement_store = StatementStoreRpc::new(platform, people_chain_genesis_hash, spawner); + let rpc_client = statement_store.client("SSO disconnect monitor").await?; + let mut subscription = + statement_store_rpc::subscribe_match_all(&rpc_client, &[session.session_id_peer]) + .await + .map_err(|err| format!("SSO disconnect monitor subscribe failed: {err}"))?; + while let Some(item) = subscription.next().await { + let value = item.map_err(|err| format!("SSO disconnect monitor item failed: {err}"))?; + let page = parse_new_statements_result("sso-peer-disconnect-monitor".to_string(), &value) + .map_err(|err| err.to_string())?; + for statement in page.statements { + if matches!( + decode_sso_session_statement( + &session, + &statement, + "truapi:sso-peer-disconnect-monitor", + "truapi:sso-peer-disconnect-monitor", + )?, + Some(SsoSessionStatement::Disconnected) + ) { + return Ok(()); + } + } + } + Err("SSO disconnect monitor response stream ended".to_string()) +} + +impl PlatformRuntimeHost { + /// Read a stored permission authorization status without prompting. + #[instrument(skip_all, fields(runtime.method = "permissions.authorization_status"))] + pub(crate) async fn permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + ) -> Result { + let service = PermissionsService::new( + self.platform.as_ref(), + self.platform.as_ref(), + &self.runtime_config.product_id, + ); + service.authorization_status(&request).await + } + + /// Read stored permission authorization statuses without prompting. + #[instrument(skip_all, fields(runtime.method = "permissions.authorization_statuses"))] + pub(crate) async fn permission_authorization_statuses( + &self, + requests: Vec, + ) -> Result, v01::GenericError> { + let service = PermissionsService::new( + self.platform.as_ref(), + self.platform.as_ref(), + &self.runtime_config.product_id, + ); + service.authorization_statuses(&requests).await + } + + /// Update a stored permission authorization status. `NotDetermined` + /// clears the stored value so the next product request prompts again. + #[instrument(skip_all, fields(runtime.method = "permissions.set_authorization_status"))] + pub(crate) async fn set_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + let service = PermissionsService::new( + self.platform.as_ref(), + self.platform.as_ref(), + &self.runtime_config.product_id, + ); + service.set_authorization_status(&request, status).await + } + + #[instrument(skip_all, fields(runtime.method = "permissions.chain_submit_authorization"))] + async fn chain_submit_authorization(&self) -> Result { + let service = PermissionsService::new( + self.platform.as_ref(), + self.platform.as_ref(), + &self.runtime_config.product_id, + ); + service + .check_or_prompt_remote(v01::RemotePermissionRequest { + permission: v01::RemotePermission::ChainSubmit, + }) + .await + .map_err(|err| format!("permission storage failed: {err:?}")) + } + + async fn require_chain_submit(&self, denied_error: E) -> Result<(), CallError> { + match self.chain_submit_authorization().await { + Ok(PermissionAuthorizationStatus::Authorized) => Ok(()), + Ok( + PermissionAuthorizationStatus::Denied + | PermissionAuthorizationStatus::NotDetermined, + ) => Err(CallError::Domain(denied_error)), + Err(reason) => Err(CallError::HostFailure { reason }), + } + } + + /// Shared tail of the four `sign_*` methods: submit the already-built SSO + /// message, require a `Sign` wallet response, and map its payload into the + /// caller's concrete response variant. `wrap` adapts a sign-payload domain + /// error into the method's error type. + async fn submit_sign_request( + &self, + cx: &CallContext, + session: &SessionInfo, + wrap: fn(v01::HostSignPayloadError) -> E, + action: &str, + message: crate::host_logic::sso::messages::RemoteMessage, + into_response: impl FnOnce(v01::HostSignPayloadResponse) -> R, + ) -> Result> { + let response = self + .submit_sso_remote_message(cx, session, action, message) + .await + .map_err(|reason| signing_unknown_call_error(wrap, reason))?; + let SsoRemoteResponse::Sign(response) = response else { + return Err(signing_unknown_call_error( + wrap, + UNEXPECTED_SSO_SIGNING_RESPONSE, + )); + }; + response + .payload + .map(|payload| { + into_response(v01::HostSignPayloadResponse { + signature: payload.signature, + signed_transaction: payload.signed_transaction, + }) + }) + .map_err(|reason| signing_unknown_call_error(wrap, reason)) + } + + fn validate_legacy_address_signer( + &self, + session: &SessionInfo, + signer: &str, + ) -> Result<(), v01::HostSignPayloadError> { + let public_key = self + .legacy_slot_zero_public_key(session) + .map_err(|reason| v01::HostSignPayloadError::Unknown { reason })?; + let expected = product_public_key_to_address(public_key); + if expected == signer { + Ok(()) + } else { + Err(v01::HostSignPayloadError::Unknown { + reason: "Account can't be derived from product account id".to_string(), + }) + } + } + + fn validate_legacy_public_key_signer( + &self, + session: &SessionInfo, + signer: [u8; 32], + ) -> Result<(), v01::HostCreateTransactionError> { + let public_key = self + .legacy_slot_zero_public_key(session) + .map_err(|reason| v01::HostCreateTransactionError::Unknown { reason })?; + if public_key == signer { + Ok(()) + } else { + Err(v01::HostCreateTransactionError::Unknown { + reason: "Account can't be derived from product account id".to_string(), + }) + } + } +} + +/// Adapter from `truapi_platform::ChainProvider` into the +/// [`RuntimeChainProvider`] surface the chain runtime expects. +/// Reuses the platform-supplied json-rpc connection and converts the +/// platform `GenericError` into a `RuntimeFailure::Unavailable`. +struct HostChainProvider { + platform: Arc, +} + +#[async_trait::async_trait] +impl RuntimeChainProvider for HostChainProvider { + #[instrument(skip_all, fields(runtime.method = "chain.provider.connect"))] + async fn connect( + &self, + genesis_hash: Vec, + ) -> Result, RuntimeFailure> { + self.platform + .connect(genesis_hash) + .await + .map(Arc::from) + .map_err(|_| RuntimeFailure::unavailable("remote_chain_connect")) + } +} + +fn runtime_failure_to_call_error(failure: RuntimeFailure) -> CallError { + CallError::HostFailure { + reason: failure.reason(), + } +} + +// --------------------------------------------------------------------------- +// System +// --------------------------------------------------------------------------- + +impl System for PlatformRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "system.feature_supported"))] + async fn feature_supported( + &self, + _cx: &CallContext, + request: HostFeatureSupportedRequest, + ) -> Result> { + let HostFeatureSupportedRequest::V1(inner) = request; + feature_supported(self.platform.as_ref(), inner) + .await + .map(HostFeatureSupportedResponse::V1) + .map_err(|err| CallError::Domain(HostFeatureSupportedError::V1(err))) + } + + #[instrument(skip_all, fields(runtime.method = "system.navigate_to"))] + async fn navigate_to( + &self, + _cx: &CallContext, + request: HostNavigateToRequest, + ) -> Result> { + let HostNavigateToRequest::V1(v01::HostNavigateToRequest { url }) = request; + let resolved = match parse_navigate(&url) { + NavigateDecision::Reject { reason } => { + return Err(CallError::Domain(HostNavigateToError::V1( + v01::HostNavigateToError::Unknown { reason }, + ))); + } + decision => match decision.canonical_url() { + Some(url) => url, + None => { + return Err(CallError::HostFailure { + reason: "navigate decision produced no canonical URL".to_string(), + }); + } + }, + }; + self.platform + .navigate_to(resolved) + .await + .map(|()| HostNavigateToResponse::V1) + .map_err(|err| CallError::Domain(HostNavigateToError::V1(err))) + } +} + +// --------------------------------------------------------------------------- +// Permissions +// --------------------------------------------------------------------------- + +impl Permissions for PlatformRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "permissions.request_device_permission"))] + async fn request_device_permission( + &self, + _cx: &CallContext, + request: HostDevicePermissionRequest, + ) -> Result> { + let HostDevicePermissionRequest::V1(inner) = request; + let service = PermissionsService::new( + self.platform.as_ref(), + self.platform.as_ref(), + &self.runtime_config.product_id, + ); + match service.check_or_prompt_device(inner).await { + Ok(decision) => Ok(HostDevicePermissionResponse::V1( + v01::HostDevicePermissionResponse { + granted: decision == PermissionAuthorizationStatus::Authorized, + }, + )), + Err(err) => Err(CallError::HostFailure { + reason: format!("permission storage failed: {err:?}"), + }), + } + } + + #[instrument(skip_all, fields(runtime.method = "permissions.request_remote_permission"))] + async fn request_remote_permission( + &self, + _cx: &CallContext, + request: RemotePermissionRequest, + ) -> Result> { + let RemotePermissionRequest::V1(inner) = request; + let service = PermissionsService::new( + self.platform.as_ref(), + self.platform.as_ref(), + &self.runtime_config.product_id, + ); + match service.check_or_prompt_remote(inner).await { + Ok(decision) => Ok(RemotePermissionResponse::V1( + v01::RemotePermissionResponse { + granted: decision == PermissionAuthorizationStatus::Authorized, + }, + )), + Err(err) => Err(CallError::HostFailure { + reason: format!("permission storage failed: {err:?}"), + }), + } + } +} + +// --------------------------------------------------------------------------- +// LocalStorage +// --------------------------------------------------------------------------- + +impl LocalStorage for PlatformRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "local_storage.read"))] + async fn read( + &self, + _cx: &CallContext, + request: HostLocalStorageReadRequest, + ) -> Result> { + let HostLocalStorageReadRequest::V1(v01::HostLocalStorageReadRequest { key }) = request; + self.platform + .read(key) + .await + .map(|value| { + HostLocalStorageReadResponse::V1(v01::HostLocalStorageReadResponse { value }) + }) + .map_err(|err| CallError::Domain(HostLocalStorageReadError::V1(err))) + } + + #[instrument(skip_all, fields(runtime.method = "local_storage.write"))] + async fn write( + &self, + _cx: &CallContext, + request: HostLocalStorageWriteRequest, + ) -> Result> { + let HostLocalStorageWriteRequest::V1(v01::HostLocalStorageWriteRequest { key, value }) = + request; + self.platform + .write(key, value) + .await + .map(|()| HostLocalStorageWriteResponse::V1) + .map_err(|err| CallError::Domain(HostLocalStorageWriteError::V1(err))) + } + + #[instrument(skip_all, fields(runtime.method = "local_storage.clear"))] + async fn clear( + &self, + _cx: &CallContext, + request: HostLocalStorageClearRequest, + ) -> Result> { + let HostLocalStorageClearRequest::V1(v01::HostLocalStorageClearRequest { key }) = request; + self.platform + .clear(key) + .await + .map(|()| HostLocalStorageClearResponse::V1) + .map_err(|err| CallError::Domain(HostLocalStorageClearError::V1(err))) + } +} + +// --------------------------------------------------------------------------- +// Account +// --------------------------------------------------------------------------- +// +// Account-management flows live in the Rust core itself, backed by the shared +// session state and, for alias/proof/login success paths, the SSO service. + +impl Account for PlatformRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "account.get_account"))] + async fn get_account( + &self, + _cx: &CallContext, + request: HostAccountGetRequest, + ) -> Result> { + let HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id }) = request; + let product_account_id = Self::normalize_product_account_id(product_account_id); + + if !is_product_identifier(&product_account_id.dot_ns_identifier) { + return Err(CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::DomainNotValid, + ))); + } + + let Some(session) = self.session_state.current() else { + return Err(CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::NotConnected, + ))); + }; + + let public_key = derive_product_public_key( + session.public_key, + &product_account_id.dot_ns_identifier, + product_account_id.derivation_index, + ) + .map_err(|err| { + CallError::Domain(HostAccountGetError::V1(v01::HostAccountGetError::Unknown { + reason: err.to_string(), + })) + })?; + + Ok(HostAccountGetResponse::V1(v01::HostAccountGetResponse { + account: v01::ProductAccount { + public_key: public_key.to_vec(), + }, + })) + } + + #[instrument(skip_all, fields(runtime.method = "account.get_account_alias"))] + async fn get_account_alias( + &self, + cx: &CallContext, + request: HostAccountGetAliasRequest, + ) -> Result> { + let HostAccountGetAliasRequest::V1(v01::HostAccountGetAliasRequest { product_account_id }) = + request; + let product_account_id = Self::normalize_product_account_id(product_account_id); + + let Some(session) = self.session_state.current() else { + return Err(CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::NotConnected, + ))); + }; + + if !is_product_identifier(&product_account_id.dot_ns_identifier) { + return Err(CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::DomainNotValid, + ))); + } + + let product_id = self.product_id(); + if product_account_id.dot_ns_identifier != product_id { + let confirmed = self + .platform + .confirm_user_action(UserConfirmationReview::AccountAlias(AccountAliasReview { + requesting_product_id: product_id.clone(), + target_product_id: product_account_id.dot_ns_identifier.clone(), + })) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("account alias confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::Rejected, + ))); + } + } + + let message_id = sso_message_id(cx, "account-alias"); + let message = alias_request_message(message_id.clone(), product_account_id, product_id); + let response = self + .submit_sso_remote_message_without_timeout(cx, &session, "account-alias", message) + .await + .map_err(|reason| { + CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::Unknown { reason }, + )) + })?; + let SsoRemoteResponse::RingVrfAlias(response) = response else { + return Err(CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::Unknown { + reason: "Unexpected SSO response for account alias request".to_string(), + }, + ))); + }; + response + .payload + .map(HostAccountGetAliasResponse::V1) + .map_err(|reason| { + CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::Unknown { reason }, + )) + }) + } + + #[instrument(skip_all, fields(runtime.method = "account.create_account_proof"))] + async fn create_account_proof( + &self, + _cx: &CallContext, + _request: HostAccountCreateProofRequest, + ) -> Result> { + Err(CallError::Unsupported) + } + + #[instrument(skip_all, fields(runtime.method = "account.get_legacy_accounts"))] + async fn get_legacy_accounts( + &self, + _cx: &CallContext, + _request: HostGetLegacyAccountsRequest, + ) -> Result> { + let Some(session) = self.session_state.current() else { + return Ok(HostGetLegacyAccountsResponse::V1( + v01::HostGetLegacyAccountsResponse { accounts: vec![] }, + )); + }; + + let product_id = self.product_id(); + if !is_product_identifier(&product_id) { + return Err(CallError::Domain(HostGetLegacyAccountsError::V1( + v01::HostAccountGetError::DomainNotValid, + ))); + } + + let public_key = + derive_product_public_key(session.public_key, &product_id, 0).map_err(|err| { + CallError::Domain(HostGetLegacyAccountsError::V1( + v01::HostAccountGetError::Unknown { + reason: err.to_string(), + }, + )) + })?; + + Ok(HostGetLegacyAccountsResponse::V1( + v01::HostGetLegacyAccountsResponse { + accounts: vec![v01::LegacyAccount { + public_key: public_key.to_vec(), + name: session.lite_username, + }], + }, + )) + } + + #[instrument(skip_all, fields(runtime.method = "account.get_user_id"))] + async fn get_user_id( + &self, + _cx: &CallContext, + _request: HostGetUserIdRequest, + ) -> Result> { + let Some(session) = self.session_state.current() else { + return Err(CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::NotConnected, + ))); + }; + + let primary_username = session + .full_username + .filter(|value| !value.is_empty()) + .or_else(|| session.lite_username.filter(|value| !value.is_empty())) + .ok_or_else(|| { + CallError::Domain(HostGetUserIdError::V1(v01::HostGetUserIdError::Unknown { + reason: "No primary username for this session".to_string(), + })) + })?; + + Ok(HostGetUserIdResponse::V1(v01::HostGetUserIdResponse { + primary_username, + })) + } + + #[instrument(skip_all, fields(runtime.method = "account.connection_status_subscribe"))] + async fn connection_status_subscribe( + &self, + _cx: &CallContext, + ) -> Subscription { + Subscription::new(self.session_state.subscribe()) + } + + #[instrument(skip_all, fields(runtime.method = "account.request_login", product = %self.runtime_config.product_id))] + async fn request_login( + &self, + _cx: &CallContext, + _request: HostRequestLoginRequest, + ) -> Result> { + self.request_login_flow().await + } +} + +/// Host-UI projection of an active session for `AuthState::Connected`. +fn connected_session_ui_info(session: &SessionInfo) -> SessionUiInfo { + SessionUiInfo { + public_key: session.public_key, + identity_account_id: session.identity_account_id, + lite_username: session.lite_username.clone(), + full_username: session.full_username.clone(), + } +} + +const UNEXPECTED_SSO_SIGNING_RESPONSE: &str = "Unexpected SSO response for signing request"; +const UNEXPECTED_SSO_TRANSACTION_RESPONSE: &str = "Unexpected SSO response for transaction request"; + +fn signing_unknown_call_error( + wrap: fn(v01::HostSignPayloadError) -> E, + reason: impl Into, +) -> CallError { + CallError::Domain(wrap(v01::HostSignPayloadError::Unknown { + reason: reason.into(), + })) +} + +fn transaction_unknown_call_error( + wrap: fn(v01::HostCreateTransactionError) -> E, + reason: impl Into, +) -> CallError { + CallError::Domain(wrap(v01::HostCreateTransactionError::Unknown { + reason: reason.into(), + })) +} + +impl Signing for PlatformRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "signing.sign_payload"))] + async fn sign_payload( + &self, + cx: &CallContext, + request: HostSignPayloadRequest, + ) -> Result> { + info!("sign_payload: requesting wallet signature"); + let HostSignPayloadRequest::V1(mut inner) = request; + inner.account = Self::normalize_product_account_id(inner.account); + if !self.is_product_account_valid_for_caller(&inner.account.dot_ns_identifier) { + return Err(CallError::Domain(HostSignPayloadError::V1( + v01::HostSignPayloadError::PermissionDenied, + ))); + } + self.require_chain_submit(HostSignPayloadError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + .await?; + let Some(session) = self.session_state.current() else { + return Err(CallError::Domain(HostSignPayloadError::V1( + v01::HostSignPayloadError::Rejected, + ))); + }; + let confirmed = self + .platform + .confirm_user_action(UserConfirmationReview::SignPayload( + SignPayloadReview::Product(inner.clone()), + )) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("sign payload confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostSignPayloadError::V1( + v01::HostSignPayloadError::Rejected, + ))); + } + let message_id = sso_message_id(cx, "sign-payload"); + let message = sign_payload_message(message_id, inner); + self.submit_sign_request( + cx, + &session, + HostSignPayloadError::V1, + "sign-payload", + message, + HostSignPayloadResponse::V1, + ) + .await + } + + #[instrument(skip_all, fields(runtime.method = "signing.sign_raw"))] + async fn sign_raw( + &self, + cx: &CallContext, + request: HostSignRawRequest, + ) -> Result> { + info!("sign_raw: requesting wallet signature"); + let HostSignRawRequest::V1(mut inner) = request; + inner.account = Self::normalize_product_account_id(inner.account); + if !self.is_product_account_valid_for_caller(&inner.account.dot_ns_identifier) { + return Err(CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied, + ))); + } + self.require_chain_submit(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + .await?; + let Some(session) = self.session_state.current() else { + return Err(CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::Rejected, + ))); + }; + let confirmed = self + .platform + .confirm_user_action(UserConfirmationReview::SignRaw(SignRawReview::Product( + inner.clone(), + ))) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("sign raw confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::Rejected, + ))); + } + let message_id = sso_message_id(cx, "sign-raw"); + let message = sign_raw_message(message_id, inner); + self.submit_sign_request( + cx, + &session, + HostSignRawError::V1, + "sign-raw", + message, + HostSignRawResponse::V1, + ) + .await + } + + #[instrument(skip_all, fields(runtime.method = "signing.create_transaction"))] + async fn create_transaction( + &self, + cx: &CallContext, + request: HostCreateTransactionRequest, + ) -> Result> { + info!("create_transaction: requesting wallet signature"); + let HostCreateTransactionRequest::V1(mut inner) = request; + inner.signer = Self::normalize_product_account_id(inner.signer); + if !self.is_product_account_valid_for_caller(&inner.signer.dot_ns_identifier) { + return Err(CallError::Domain(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::PermissionDenied, + ))); + } + self.require_chain_submit(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::PermissionDenied, + )) + .await?; + let Some(session) = self.session_state.current() else { + return Err(CallError::Domain(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::Rejected, + ))); + }; + let confirmed = self + .platform + .confirm_user_action(UserConfirmationReview::CreateTransaction( + CreateTransactionReview::Product(inner.clone()), + )) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("create transaction confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::Rejected, + ))); + } + let message_id = sso_message_id(cx, "create-transaction"); + let message = create_transaction_message(message_id, inner); + let response = self + .submit_sso_remote_message(cx, &session, "create-transaction", message) + .await + .map_err(|reason| { + transaction_unknown_call_error(HostCreateTransactionError::V1, reason) + })?; + let SsoRemoteResponse::CreateTransaction(response) = response else { + return Err(transaction_unknown_call_error( + HostCreateTransactionError::V1, + UNEXPECTED_SSO_TRANSACTION_RESPONSE, + )); + }; + response + .signed_transaction + .map(|transaction| { + HostCreateTransactionResponse::V1(v01::HostCreateTransactionResponse { + transaction, + }) + }) + .map_err(|reason| { + transaction_unknown_call_error(HostCreateTransactionError::V1, reason) + }) + } + + #[instrument(skip_all, fields(runtime.method = "signing.sign_payload_with_legacy_account"))] + async fn sign_payload_with_legacy_account( + &self, + cx: &CallContext, + request: HostSignPayloadWithLegacyAccountRequest, + ) -> Result< + HostSignPayloadWithLegacyAccountResponse, + CallError, + > { + let HostSignPayloadWithLegacyAccountRequest::V1(inner) = request; + let Some(session) = self.session_state.current() else { + return Err(CallError::Domain( + HostSignPayloadWithLegacyAccountError::V1(v01::HostSignPayloadError::Rejected), + )); + }; + self.validate_legacy_address_signer(&session, &inner.signer) + .map_err(|err| CallError::Domain(HostSignPayloadWithLegacyAccountError::V1(err)))?; + self.require_chain_submit(HostSignPayloadWithLegacyAccountError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + .await?; + let confirmed = self + .platform + .confirm_user_action(UserConfirmationReview::SignPayload( + SignPayloadReview::LegacyAccount(inner.clone()), + )) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("sign payload confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain( + HostSignPayloadWithLegacyAccountError::V1(v01::HostSignPayloadError::Rejected), + )); + } + let message_id = sso_message_id(cx, "legacy-sign-payload"); + let message = sign_payload_message( + message_id, + v01::HostSignPayloadRequest { + account: v01::ProductAccountId { + dot_ns_identifier: self.product_id(), + derivation_index: 0, + }, + payload: inner.payload, + }, + ); + self.submit_sign_request( + cx, + &session, + HostSignPayloadWithLegacyAccountError::V1, + "legacy-sign-payload", + message, + HostSignPayloadWithLegacyAccountResponse::V1, + ) + .await + } + + #[instrument(skip_all, fields(runtime.method = "signing.sign_raw_with_legacy_account"))] + async fn sign_raw_with_legacy_account( + &self, + cx: &CallContext, + request: HostSignRawWithLegacyAccountRequest, + ) -> Result> + { + let HostSignRawWithLegacyAccountRequest::V1(inner) = request; + let Some(session) = self.session_state.current() else { + return Err(CallError::Domain(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::Rejected, + ))); + }; + self.validate_legacy_address_signer(&session, &inner.signer) + .map_err(|err| CallError::Domain(HostSignRawWithLegacyAccountError::V1(err)))?; + self.require_chain_submit(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + .await?; + let confirmed = self + .platform + .confirm_user_action(UserConfirmationReview::SignRaw( + SignRawReview::LegacyAccount(inner.clone()), + )) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("sign raw confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::Rejected, + ))); + } + let message_id = sso_message_id(cx, "legacy-sign-raw"); + let message = sign_raw_message( + message_id, + v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: self.product_id(), + derivation_index: 0, + }, + payload: inner.payload, + }, + ); + self.submit_sign_request( + cx, + &session, + HostSignRawWithLegacyAccountError::V1, + "legacy-sign-raw", + message, + HostSignRawWithLegacyAccountResponse::V1, + ) + .await + } + + #[instrument(skip_all, fields(runtime.method = "signing.create_transaction_with_legacy_account"))] + async fn create_transaction_with_legacy_account( + &self, + cx: &CallContext, + request: HostCreateTransactionWithLegacyAccountRequest, + ) -> Result< + HostCreateTransactionWithLegacyAccountResponse, + CallError, + > { + let HostCreateTransactionWithLegacyAccountRequest::V1(inner) = request; + let Some(session) = self.session_state.current() else { + return Err(CallError::Domain( + HostCreateTransactionWithLegacyAccountError::V1( + v01::HostCreateTransactionError::Rejected, + ), + )); + }; + self.validate_legacy_public_key_signer(&session, inner.signer) + .map_err(|err| { + CallError::Domain(HostCreateTransactionWithLegacyAccountError::V1(err)) + })?; + self.require_chain_submit(HostCreateTransactionWithLegacyAccountError::V1( + v01::HostCreateTransactionError::PermissionDenied, + )) + .await?; + let confirmed = self + .platform + .confirm_user_action(UserConfirmationReview::CreateTransaction( + CreateTransactionReview::LegacyAccount(inner.clone()), + )) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("create transaction confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain( + HostCreateTransactionWithLegacyAccountError::V1( + v01::HostCreateTransactionError::Rejected, + ), + )); + } + let message_id = sso_message_id(cx, "legacy-create-transaction"); + let message = create_transaction_message( + message_id, + v01::ProductAccountTxPayload { + signer: v01::ProductAccountId { + dot_ns_identifier: self.product_id(), + derivation_index: 0, + }, + genesis_hash: inner.genesis_hash, + call_data: inner.call_data, + extensions: inner.extensions, + tx_ext_version: inner.tx_ext_version, + }, + ); + let response = self + .submit_sso_remote_message(cx, &session, "legacy-create-transaction", message) + .await + .map_err(|reason| { + transaction_unknown_call_error( + HostCreateTransactionWithLegacyAccountError::V1, + reason, + ) + })?; + let SsoRemoteResponse::CreateTransaction(response) = response else { + return Err(transaction_unknown_call_error( + HostCreateTransactionWithLegacyAccountError::V1, + UNEXPECTED_SSO_TRANSACTION_RESPONSE, + )); + }; + response + .signed_transaction + .map(|transaction| { + HostCreateTransactionWithLegacyAccountResponse::V1( + v01::HostCreateTransactionWithLegacyAccountResponse { transaction }, + ) + }) + .map_err(|reason| { + transaction_unknown_call_error( + HostCreateTransactionWithLegacyAccountError::V1, + reason, + ) + }) + } +} + +// --------------------------------------------------------------------------- +// Chain +// --------------------------------------------------------------------------- +// +// The chain surface is backed by `ChainRuntime`, which keeps one +// `chainHead_v1` connection per genesis hash on top of the platform-supplied +// `ChainProvider::connect`. Requests go through `request_value` and parse +// json-rpc responses into typed v01 results; follow notifications are +// translated into `RemoteChainHeadFollowItem` items on the subscription +// stream. + +impl Chain for PlatformRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "chain.follow_head_subscribe"))] + async fn follow_head_subscribe( + &self, + cx: &CallContext, + request: RemoteChainHeadFollowRequest, + ) -> Subscription { + let RemoteChainHeadFollowRequest::V1(inner) = request; + let follow_subscription_id = cx.request_id().to_string(); + let stream = self + .chain + .remote_chain_head_follow(follow_subscription_id, inner) + .map(RemoteChainHeadFollowItem::V1); + Subscription::new(Box::pin(stream)) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_head_header"))] + async fn get_head_header( + &self, + _cx: &CallContext, + request: RemoteChainHeadHeaderRequest, + ) -> Result> { + let RemoteChainHeadHeaderRequest::V1(inner) = request; + self.chain + .remote_chain_head_header(inner) + .await + .map(RemoteChainHeadHeaderResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_head_body"))] + async fn get_head_body( + &self, + _cx: &CallContext, + request: RemoteChainHeadBodyRequest, + ) -> Result> { + let RemoteChainHeadBodyRequest::V1(inner) = request; + self.chain + .remote_chain_head_body(inner) + .await + .map(RemoteChainHeadBodyResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_head_storage"))] + async fn get_head_storage( + &self, + _cx: &CallContext, + request: RemoteChainHeadStorageRequest, + ) -> Result> { + let RemoteChainHeadStorageRequest::V1(inner) = request; + self.chain + .remote_chain_head_storage(inner) + .await + .map(RemoteChainHeadStorageResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.call_head"))] + async fn call_head( + &self, + _cx: &CallContext, + request: RemoteChainHeadCallRequest, + ) -> Result> { + let RemoteChainHeadCallRequest::V1(inner) = request; + self.chain + .remote_chain_head_call(inner) + .await + .map(RemoteChainHeadCallResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.unpin_head"))] + async fn unpin_head( + &self, + _cx: &CallContext, + request: RemoteChainHeadUnpinRequest, + ) -> Result> { + let RemoteChainHeadUnpinRequest::V1(inner) = request; + self.chain + .remote_chain_head_unpin(inner) + .await + .map(|()| RemoteChainHeadUnpinResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.continue_head"))] + async fn continue_head( + &self, + _cx: &CallContext, + request: RemoteChainHeadContinueRequest, + ) -> Result> { + let RemoteChainHeadContinueRequest::V1(inner) = request; + self.chain + .remote_chain_head_continue(inner) + .await + .map(|()| RemoteChainHeadContinueResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.stop_head_operation"))] + async fn stop_head_operation( + &self, + _cx: &CallContext, + request: RemoteChainHeadStopOperationRequest, + ) -> Result> + { + let RemoteChainHeadStopOperationRequest::V1(inner) = request; + self.chain + .remote_chain_head_stop_operation(inner) + .await + .map(|()| RemoteChainHeadStopOperationResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_spec_genesis_hash"))] + async fn get_spec_genesis_hash( + &self, + _cx: &CallContext, + request: RemoteChainSpecGenesisHashRequest, + ) -> Result> + { + let RemoteChainSpecGenesisHashRequest::V1(inner) = request; + self.chain + .remote_chain_spec_genesis_hash(inner.genesis_hash) + .await + .map(RemoteChainSpecGenesisHashResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_spec_chain_name"))] + async fn get_spec_chain_name( + &self, + _cx: &CallContext, + request: RemoteChainSpecChainNameRequest, + ) -> Result> { + let RemoteChainSpecChainNameRequest::V1(inner) = request; + self.chain + .remote_chain_spec_chain_name(inner.genesis_hash) + .await + .map(RemoteChainSpecChainNameResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_spec_properties"))] + async fn get_spec_properties( + &self, + _cx: &CallContext, + request: RemoteChainSpecPropertiesRequest, + ) -> Result> { + let RemoteChainSpecPropertiesRequest::V1(inner) = request; + self.chain + .remote_chain_spec_properties(inner.genesis_hash) + .await + .map(RemoteChainSpecPropertiesResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.broadcast_transaction"))] + async fn broadcast_transaction( + &self, + _cx: &CallContext, + request: RemoteChainTransactionBroadcastRequest, + ) -> Result< + RemoteChainTransactionBroadcastResponse, + CallError, + > { + let RemoteChainTransactionBroadcastRequest::V1(inner) = request; + self.chain + .remote_chain_transaction_broadcast(inner) + .await + .map(RemoteChainTransactionBroadcastResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.stop_transaction"))] + async fn stop_transaction( + &self, + _cx: &CallContext, + request: RemoteChainTransactionStopRequest, + ) -> Result> + { + let RemoteChainTransactionStopRequest::V1(inner) = request; + self.chain + .remote_chain_transaction_stop(inner) + .await + .map(|()| RemoteChainTransactionStopResponse::V1) + .map_err(runtime_failure_to_call_error) + } +} + +// --------------------------------------------------------------------------- +// Deferred product surfaces. +// +// Payment and full account proof are explicitly out of current dotli parity, +// but products should still observe dotli's typed "not implemented" errors +// rather than a generic transport failure. +// Chat and CoinPayment remain outside this milestone and keep their generated +// trait defaults until another host/product needs real implementations. + +const PAYMENTS_NOT_IMPLEMENTED: &str = "Payments are not supported in dot.li"; + +impl Chat for PlatformRuntimeHost {} +impl CoinPayment for PlatformRuntimeHost {} +impl Payment for PlatformRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "payment.balance_subscribe"))] + async fn balance_subscribe( + &self, + _cx: &CallContext, + _request: HostPaymentBalanceSubscribeRequest, + ) -> Result< + Subscription, + CallError, + > { + Err(CallError::Domain(HostPaymentBalanceSubscribeError::V1( + v01::HostPaymentBalanceSubscribeError::PermissionDenied, + ))) + } + + #[instrument(skip_all, fields(runtime.method = "payment.request"))] + async fn request( + &self, + _cx: &CallContext, + _request: HostPaymentRequest, + ) -> Result> { + Err(CallError::Domain(HostPaymentError::V1( + v01::HostPaymentError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }, + ))) + } + + #[instrument(skip_all, fields(runtime.method = "payment.status_subscribe"))] + async fn status_subscribe( + &self, + _cx: &CallContext, + _request: HostPaymentStatusSubscribeRequest, + ) -> Result< + Subscription, + CallError, + > { + Err(CallError::Domain(HostPaymentStatusSubscribeError::V1( + v01::HostPaymentStatusSubscribeError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }, + ))) + } + + #[instrument(skip_all, fields(runtime.method = "payment.top_up"))] + async fn top_up( + &self, + _cx: &CallContext, + _request: HostPaymentTopUpRequest, + ) -> Result> { + Err(CallError::Domain(HostPaymentTopUpError::V1( + v01::HostPaymentTopUpError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }, + ))) + } +} + +impl ResourceAllocation for PlatformRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "resource_allocation.request"))] + async fn request( + &self, + cx: &CallContext, + request: HostRequestResourceAllocationRequest, + ) -> Result> + { + let HostRequestResourceAllocationRequest::V1(inner) = request; + let Some(session) = self.session_state.current() else { + return Err(CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { + reason: "No active session".to_string(), + }, + ))); + }; + + let confirmed = self + .platform + .confirm_user_action(UserConfirmationReview::ResourceAllocation(inner.clone())) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("resource allocation confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { + reason: "User rejected resource allocation".to_string(), + }, + ))); + } + let message_id = sso_message_id(cx, "resource-allocation"); + let message = resource_allocation_message( + message_id, + self.product_id(), + inner.resources, + OnExistingAllowancePolicy::Increase, + ); + let response = self + .submit_sso_remote_message(cx, &session, "resource-allocation", message) + .await + .map_err(|reason| { + CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { reason }, + )) + })?; + let SsoRemoteResponse::ResourceAllocation(response) = response else { + return Err(CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { + reason: "Unexpected SSO response for resource allocation request".to_string(), + }, + ))); + }; + response + .payload + .map(|outcomes| { + HostRequestResourceAllocationResponse::V1( + v01::HostRequestResourceAllocationResponse { + outcomes: outcomes + .into_iter() + .map(resource_allocation_outcome) + .collect(), + }, + ) + }) + .map_err(|reason| { + CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { reason }, + )) + }) + } +} + +fn resource_allocation_outcome(outcome: SsoAllocationOutcome) -> v01::AllocationOutcome { + match outcome { + SsoAllocationOutcome::Allocated(_) => v01::AllocationOutcome::Allocated, + SsoAllocationOutcome::Rejected => v01::AllocationOutcome::Rejected, + SsoAllocationOutcome::NotAvailable => v01::AllocationOutcome::NotAvailable, + } +} +// --------------------------------------------------------------------------- +// Entropy +// --------------------------------------------------------------------------- + +impl Entropy for PlatformRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "entropy.derive"))] + async fn derive( + &self, + _cx: &CallContext, + request: HostDeriveEntropyRequest, + ) -> Result> { + let HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { context }) = request; + let Some(session) = self.session_state.current() else { + return Err(CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { + reason: "Not connected".to_string(), + }, + ))); + }; + let Some(root_entropy_source) = session.root_entropy_source else { + return Err(CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { + reason: "Session secret missing".to_string(), + }, + ))); + }; + + let entropy = + derive_product_entropy_from_source(&root_entropy_source, &self.product_id(), &context) + .map_err(|err| { + CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { + reason: err.to_string(), + }, + )) + })?; + + Ok(HostDeriveEntropyResponse::V1( + v01::HostDeriveEntropyResponse { entropy }, + )) + } +} + +// --------------------------------------------------------------------------- +// Preimage +// --------------------------------------------------------------------------- + +impl Preimage for PlatformRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "preimage.lookup_subscribe"))] + async fn lookup_subscribe( + &self, + _cx: &CallContext, + request: RemotePreimageLookupSubscribeRequest, + ) -> Subscription { + let RemotePreimageLookupSubscribeRequest::V1(v01::RemotePreimageLookupSubscribeRequest { + key, + }) = request; + let stream = self + .platform + .lookup_preimage(key) + .filter_map(|item| async move { + item.ok().map(|value| { + RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { + value, + }) + }) + }); + Subscription::new(Box::pin(stream)) + } + + #[instrument(skip_all, fields(runtime.method = "preimage.submit"))] + async fn submit( + &self, + _cx: &CallContext, + request: RemotePreimageSubmitRequest, + ) -> Result> { + let RemotePreimageSubmitRequest::V1(value) = request; + let confirmed = self + .platform + .confirm_user_action(UserConfirmationReview::PreimageSubmit( + PreimageSubmitReview { + size: value.len() as u64, + }, + )) + .await + .map_err(|err| { + CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { reason: err.reason }, + )) + })?; + if !confirmed { + return Err(CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { + reason: "User rejected preimage submission".to_string(), + }, + ))); + } + self.platform + .submit_preimage(value) + .await + .map(RemotePreimageSubmitResponse::V1) + .map_err(|err| CallError::Domain(RemotePreimageSubmitError::V1(err))) + } +} + +// --------------------------------------------------------------------------- +// Theme +// --------------------------------------------------------------------------- + +impl Theme for PlatformRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "theme.subscribe"))] + async fn subscribe(&self, _cx: &CallContext) -> Subscription { + let stream = self.platform.subscribe_theme().filter_map(|item| async { + item.ok().map(|variant| { + HostThemeSubscribeItem::V1(v01::HostThemeSubscribeItem { + name: v01::ThemeName::Default, + variant, + }) + }) + }); + Subscription::new(Box::pin(stream)) + } +} + +#[cfg(debug_assertions)] +impl Testing for PlatformRuntimeHost {} + +// `Notifications` delegates to the platform so hosts can own scheduling and +// cancellation while the core preserves the typed TrUAPI wire shape. +impl Notifications for PlatformRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "notifications.send_push_notification"))] + async fn send_push_notification( + &self, + _cx: &CallContext, + request: HostPushNotificationRequest, + ) -> Result> { + let HostPushNotificationRequest::V1(inner) = request; + self.platform + .push_notification(inner) + .await + .map(HostPushNotificationResponse::V1) + .map_err(|err| { + CallError::Domain(HostPushNotificationError::V1( + v01::HostPushNotificationError::Unknown { reason: err.reason }, + )) + }) + } + + #[instrument(skip_all, fields(runtime.method = "notifications.cancel_push_notification"))] + async fn cancel_push_notification( + &self, + _cx: &CallContext, + request: HostPushNotificationCancelRequest, + ) -> Result> + { + let HostPushNotificationCancelRequest::V1(v01::HostPushNotificationCancelRequest { id }) = + request; + self.platform + .cancel_notification(id) + .await + .map(|()| HostPushNotificationCancelResponse::V1) + .map_err(|err| { + CallError::Domain(HostPushNotificationCancelError::V1(v01::GenericError { + reason: err.reason, + })) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_logic::sso::messages::{RemoteMessageData, RemoteMessageV1}; + use crate::test_support::*; + use std::sync::Mutex; + use truapi_platform::{AuthState, CoreStorageKey}; + + use super::sso_remote::SSO_PEER_DISCONNECT_REASON; + + fn wait_until(mut condition: impl FnMut() -> bool, message: &str) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while !condition() { + assert!(std::time::Instant::now() < deadline, "{message}"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + + #[test] + fn feature_supported_round_trips_through_runtime() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + }); + let response = futures::executor::block_on(host.feature_supported(&cx, request)).unwrap(); + let HostFeatureSupportedResponse::V1(inner) = response; + assert!(inner.supported); + } + + #[test] + fn navigate_to_uses_dotns_decision_and_then_platform() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostNavigateToRequest::V1(v01::HostNavigateToRequest { + url: "mytestapp.dot".to_string(), + }); + let response = futures::executor::block_on(host.navigate_to(&cx, request)).unwrap(); + assert_eq!(response, HostNavigateToResponse::V1); + } + + #[test] + fn navigate_to_rejects_empty_input_without_calling_platform() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostNavigateToRequest::V1(v01::HostNavigateToRequest { + url: "".to_string(), + }); + let err = futures::executor::block_on(host.navigate_to(&cx, request)).unwrap_err(); + match err { + CallError::Domain(HostNavigateToError::V1(v01::HostNavigateToError::Unknown { + .. + })) => {} + other => panic!("expected Unknown navigate error, got {other:?}"), + } + } + + #[test] + fn push_notification_delegates_payload_and_returns_host_id() { + let pushed_notifications = Arc::new(Mutex::new(Vec::new())); + let platform = Arc::new(StubPlatform { + notification_id: 42, + pushed_notifications: pushed_notifications.clone(), + ..Default::default() + }); + let host = PlatformRuntimeHost::new_compat(platform, test_spawner()); + let cx = CallContext::new(); + let request = HostPushNotificationRequest::V1(v01::HostPushNotificationRequest { + text: "Hello".to_string(), + deeplink: Some("https://example.invalid/launch".to_string()), + scheduled_at: Some(1_776_144_000_000), + }); + + let response = + futures::executor::block_on(host.send_push_notification(&cx, request)).unwrap(); + + assert_eq!( + response, + HostPushNotificationResponse::V1(v01::HostPushNotificationResponse { id: 42 }) + ); + assert_eq!( + pushed_notifications + .lock() + .expect("notification list mutex poisoned") + .as_slice(), + &[v01::HostPushNotificationRequest { + text: "Hello".to_string(), + deeplink: Some("https://example.invalid/launch".to_string()), + scheduled_at: Some(1_776_144_000_000), + }] + ); + } + + #[test] + fn cancel_notification_delegates_host_id() { + let cancelled_notifications = Arc::new(Mutex::new(Vec::new())); + let platform = Arc::new(StubPlatform { + cancelled_notifications: cancelled_notifications.clone(), + ..Default::default() + }); + let host = PlatformRuntimeHost::new_compat(platform, test_spawner()); + let cx = CallContext::new(); + let request = + HostPushNotificationCancelRequest::V1(v01::HostPushNotificationCancelRequest { + id: 42, + }); + + let response = + futures::executor::block_on(host.cancel_push_notification(&cx, request)).unwrap(); + + assert_eq!(response, HostPushNotificationCancelResponse::V1); + assert_eq!( + cancelled_notifications + .lock() + .expect("notification cancellation list mutex poisoned") + .as_slice(), + &[42] + ); + } + + #[test] + fn get_account_requires_session() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + }); + let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::NotConnected + )) + )); + } + + #[test] + fn get_account_rejects_invalid_product_identifier() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "example.com".to_string(), + derivation_index: 0, + }, + }); + let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::DomainNotValid + )) + )); + } + + #[test] + fn get_account_derives_dotli_product_key() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + }); + let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); + let HostAccountGetResponse::V1(inner) = response; + assert_eq!( + hex::encode(inner.account.public_key), + "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + ); + } + + #[test] + fn get_account_normalizes_product_identifier_before_deriving() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "MyApp.DOT".to_string(), + derivation_index: 0, + }, + }); + let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); + let HostAccountGetResponse::V1(inner) = response; + assert_eq!( + hex::encode(inner.account.public_key), + "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + ); + } + + #[test] + fn get_account_alias_requires_session() { + let host = + PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let cx = CallContext::new(); + let err = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("myapp.dot")), + ) + .unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::NotConnected + )) + )); + } + + #[test] + fn get_account_alias_rejects_invalid_product_identifier() { + let host = + PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let err = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("example.com")), + ) + .unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::DomainNotValid + )) + )); + } + + #[test] + fn get_account_alias_same_domain_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + rpc_responses: sso_success_responses( + &session, + "alias-1", + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-alias-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::RingVrfAliasResponse( + crate::host_logic::sso::messages::RingVrfAliasResponse { + responding_to: "alias-1".to_string(), + payload: Ok(v01::HostAccountGetAliasResponse { + context: [9; 32], + alias: vec![1, 2, 3], + }), + }, + ), + ), + }, + ), + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("alias-1".to_string()); + let response = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("myapp.dot")), + ) + .unwrap(); + let HostAccountGetAliasResponse::V1(inner) = response; + assert_eq!(inner.context, [9; 32]); + assert_eq!(inner.alias, vec![1, 2, 3]); + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::RingVrfAliasRequest(request), + ) = message.data + else { + panic!("expected ring VRF alias request"); + }; + assert_eq!(request.product_account_id.dot_ns_identifier, "myapp.dot"); + assert_eq!(request.product_id, "myapp.dot"); + } + + #[test] + fn get_account_alias_normalizes_remote_request_identifier() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + rpc_responses: sso_success_responses( + &session, + "alias-1", + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-alias-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::RingVrfAliasResponse( + crate::host_logic::sso::messages::RingVrfAliasResponse { + responding_to: "alias-1".to_string(), + payload: Ok(v01::HostAccountGetAliasResponse { + context: [9; 32], + alias: vec![1, 2, 3], + }), + }, + ), + ), + }, + ), + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("MyApp.DOT"), + test_spawner(), + ); + host.session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("alias-1".to_string()); + futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("MyApp.DOT")), + ) + .unwrap(); + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::RingVrfAliasRequest(request), + ) = message.data + else { + panic!("expected ring VRF alias request"); + }; + assert_eq!(request.product_account_id.dot_ns_identifier, "myapp.dot"); + assert_eq!(request.product_id, "myapp.dot"); + } + + #[test] + fn get_account_alias_cross_domain_rejects_when_user_declines() { + let host = + PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let err = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("other.dot")), + ) + .unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::Rejected + )) + )); + } + + #[test] + fn get_account_alias_cross_domain_maps_confirmation_failure_to_host_failure() { + let host = PlatformRuntimeHost::new( + Arc::new(StubPlatform { + account_alias_error: Some("modal failed"), + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let err = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("other.dot")), + ) + .unwrap_err(); + assert!( + matches!(err, CallError::HostFailure { reason } if reason.contains("modal failed")) + ); + } + + #[test] + fn get_account_alias_cross_domain_accepts_confirmation_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + account_alias_confirmed: true, + rpc_responses: sso_success_responses( + &session, + "alias-2", + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-alias-2".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::RingVrfAliasResponse( + crate::host_logic::sso::messages::RingVrfAliasResponse { + responding_to: "alias-2".to_string(), + payload: Ok(v01::HostAccountGetAliasResponse { + context: [8; 32], + alias: vec![4, 5, 6], + }), + }, + ), + ), + }, + ), + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("alias-2".to_string()); + let response = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("other.dot")), + ) + .unwrap(); + let HostAccountGetAliasResponse::V1(inner) = response; + assert_eq!(inner.context, [8; 32]); + assert_eq!(inner.alias, vec![4, 5, 6]); + let message = submitted_remote_message(&platform, &session); + assert!(matches!( + message.data, + crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::RingVrfAliasRequest(_) + ) + )); + } + + #[test] + fn get_legacy_accounts_returns_derived_slot_zero_when_connected() { + let host = PlatformRuntimeHost::new( + stub_platform(), + runtime_config("localhost:3000"), + test_spawner(), + ); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let response = futures::executor::block_on( + host.get_legacy_accounts(&cx, HostGetLegacyAccountsRequest::V1), + ) + .unwrap(); + let HostGetLegacyAccountsResponse::V1(inner) = response; + assert_eq!(inner.accounts.len(), 1); + assert_eq!(inner.accounts[0].name.as_deref(), Some("alice")); + assert_eq!( + hex::encode(&inner.accounts[0].public_key), + "1c822b488297fde8c60d9cbc5585839f70a69fb2c5c69daa66b6043c75184467" + ); + } + + #[test] + fn get_legacy_accounts_returns_empty_when_disconnected() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let response = futures::executor::block_on( + host.get_legacy_accounts(&cx, HostGetLegacyAccountsRequest::V1), + ) + .unwrap(); + let HostGetLegacyAccountsResponse::V1(inner) = response; + assert!(inner.accounts.is_empty()); + } + + #[test] + fn get_user_id_returns_primary_username() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let response = + futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)).unwrap(); + let HostGetUserIdResponse::V1(inner) = response; + assert_eq!(inner.primary_username, "Alice Smith"); + } + + #[test] + fn derive_entropy_matches_dotli_vector() { + let host = + PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { + context: b"product-key".to_vec(), + }); + let response = futures::executor::block_on(host.derive(&cx, request)).unwrap(); + let HostDeriveEntropyResponse::V1(inner) = response; + assert_eq!( + hex::encode(inner.entropy), + "ab1887248c9de3cf4b8c5a255782796d3d35a98c8eb2d7df61a410db8b14da36" + ); + } + + #[test] + fn derive_entropy_requires_session() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { + context: b"product-key".to_vec(), + }); + let err = futures::executor::block_on(host.derive(&cx, request)).unwrap_err(); + match err { + CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { reason }, + )) => assert_eq!(reason, "Not connected"), + other => panic!("expected Unknown entropy error, got {other:?}"), + } + } + + #[test] + fn derive_entropy_requires_secret() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let mut session = session_info(); + session.root_entropy_source = None; + host.session_state().set_session(session); + let cx = CallContext::new(); + let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { + context: b"product-key".to_vec(), + }); + let err = futures::executor::block_on(host.derive(&cx, request)).unwrap_err(); + match err { + CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { reason }, + )) => assert_eq!(reason, "Session secret missing"), + other => panic!("expected Unknown entropy error, got {other:?}"), + } + } + + #[test] + fn derive_entropy_rejects_empty_context_like_dotli_key() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = + HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { context: vec![] }); + let err = futures::executor::block_on(host.derive(&cx, request)).unwrap_err(); + match err { + CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { reason }, + )) => assert_eq!(reason, "\"key\" must be between 1 and 32 bytes, got 0"), + other => panic!("expected Unknown entropy error, got {other:?}"), + } + } + + #[test] + fn preimage_submit_confirms_and_delegates_to_platform() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); + let response = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap(); + assert_eq!(response, RemotePreimageSubmitResponse::V1(vec![1, 2, 3])); + } + + #[test] + fn preimage_lookup_subscribe_maps_platform_values() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = + RemotePreimageLookupSubscribeRequest::V1(v01::RemotePreimageLookupSubscribeRequest { + key: vec![0; 32], + }); + let mut subscription = futures::executor::block_on(host.lookup_subscribe(&cx, request)); + let item = futures::executor::block_on(subscription.next()).expect("preimage item"); + assert_eq!( + item, + RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { + value: Some(vec![9, 8, 7]) + }) + ); + } + + #[test] + fn theme_subscribe_maps_platform_values() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let mut subscription = futures::executor::block_on(Theme::subscribe(&host, &cx)); + let item = futures::executor::block_on(subscription.next()).expect("theme item"); + assert_eq!( + item, + HostThemeSubscribeItem::V1(v01::HostThemeSubscribeItem { + name: v01::ThemeName::Default, + variant: v01::ThemeVariant::Dark, + }) + ); + } + + #[test] + fn sign_raw_rejects_invalid_product_account() { + let host = + PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("other.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied + )) + )); + } + + #[test] + fn sign_raw_rejects_without_session_after_valid_account() { + let host = + PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignRawError::V1(v01::HostSignPayloadError::Rejected)) + )); + } + + #[test] + fn sign_raw_denies_when_chain_submit_denied() { + let host = PlatformRuntimeHost::new( + Arc::new(StubPlatform { + remote_permission_granted: false, + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied + )) + )); + } + + #[test] + fn sign_raw_rejects_when_user_declines_confirmation() { + let host = + PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignRawError::V1(v01::HostSignPayloadError::Rejected)) + )); + } + + #[test] + fn sign_raw_accepts_confirmation_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + rpc_responses: sso_success_responses( + &session, + "sign-raw-1", + sign_response_message("sign-raw-1", vec![7, 7], None), + ), + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("sign-raw-1".to_string()); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let response = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap(); + let HostSignRawResponse::V1(inner) = response; + assert_eq!(inner.signature, vec![7, 7]); + assert_eq!(inner.signed_transaction, None); + let message = submitted_remote_message(&platform, &session); + assert!(matches!( + &message.data, + crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::SignRequest(request) + ) if matches!( + request.as_ref(), + crate::host_logic::sso::messages::SigningRequest::Raw(_) + ) + )); + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + let methods = sent + .iter() + .map(|request| { + serde_json::from_str::(request).unwrap()["method"] + .as_str() + .unwrap() + .to_string() + }) + .collect::>(); + assert_eq!( + methods, + vec![ + "statement_subscribeStatement", + "statement_subscribeStatement", + "statement_submit", + "statement_unsubscribeStatement", + "statement_unsubscribeStatement", + ] + ); + let mut unsubscribe_ids = sent[3..] + .iter() + .map(|request| serde_json::from_str::(request).unwrap()) + .map(|request| request["params"][0].as_str().unwrap().to_string()) + .collect::>(); + unsubscribe_ids.sort(); + assert_eq!( + unsubscribe_ids, + vec!["own-sub-sign-raw-1", "peer-sub-sign-raw-1"] + ); + } + + #[test] + fn sign_raw_peer_disconnect_clears_session_store_and_broadcasts() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + rpc_responses: sso_peer_disconnect_responses(&session, "sign-raw-disconnect"), + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.session_state().set_session(session); + let mut statuses = host.session_state().subscribe(); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Connected + ) + ); + + let cx = CallContext::with_request_id("sign-raw-disconnect".to_string()); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + + assert!(matches!( + err, + CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::Unknown { reason } + )) if reason == SSO_PEER_DISCONNECT_REASON + )); + assert!(host.session_state().current().is_none()); + assert_eq!( + *platform + .session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 1 + ); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Disconnected + ) + ); + } + + #[test] + fn idle_peer_disconnect_monitor_clears_session_store_and_broadcasts() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + rpc_responses: sso_peer_disconnect_monitor_responses(&session), + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.session_state().set_session(session.clone()); + let mut statuses = host.session_state().subscribe(); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Connected + ) + ); + + host.start_sso_disconnect_monitor(&session); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let disconnected = loop { + if let Some(item) = statuses.next().now_or_never() { + break item.expect("status stream ended"); + } + assert!( + std::time::Instant::now() < deadline, + "peer disconnect monitor did not emit Disconnected" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + + assert!(host.session_state().current().is_none()); + assert_eq!( + *platform + .session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 1 + ); + assert_eq!( + disconnected, + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Disconnected + ) + ); + } + + #[test] + fn sign_payload_denies_when_chain_submit_denied() { + let host = PlatformRuntimeHost::new( + Arc::new(StubPlatform { + remote_permission_granted: false, + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostSignPayloadRequest::V1(v01::HostSignPayloadRequest { + account: account_id("myapp.dot", 0), + payload: sign_payload_data(), + }); + let err = futures::executor::block_on(host.sign_payload(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignPayloadError::V1( + v01::HostSignPayloadError::PermissionDenied + )) + )); + } + + #[test] + fn sign_payload_maps_confirmation_failure_to_host_failure() { + let host = PlatformRuntimeHost::new( + Arc::new(StubPlatform { + sign_payload_error: Some("modal failed"), + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostSignPayloadRequest::V1(v01::HostSignPayloadRequest { + account: account_id("myapp.dot", 0), + payload: sign_payload_data(), + }); + let err = futures::executor::block_on(host.sign_payload(&cx, request)).unwrap_err(); + assert!( + matches!(err, CallError::HostFailure { reason } if reason.contains("modal failed")) + ); + } + + #[test] + fn sign_payload_accepts_confirmation_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + sign_payload_confirmed: true, + rpc_responses: sso_success_responses( + &session, + "sign-payload-1", + sign_response_message("sign-payload-1", vec![8, 8], Some(vec![0xab, 0xcd])), + ), + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("sign-payload-1".to_string()); + let request = HostSignPayloadRequest::V1(v01::HostSignPayloadRequest { + account: account_id("myapp.dot", 0), + payload: sign_payload_data(), + }); + + let response = futures::executor::block_on(host.sign_payload(&cx, request)).unwrap(); + + let HostSignPayloadResponse::V1(inner) = response; + assert_eq!(inner.signature, vec![8, 8]); + assert_eq!(inner.signed_transaction, Some(vec![0xab, 0xcd])); + let message = submitted_remote_message(&platform, &session); + assert!(matches!( + &message.data, + crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::SignRequest(request) + ) if matches!( + request.as_ref(), + crate::host_logic::sso::messages::SigningRequest::Payload(_) + ) + )); + } + + #[test] + fn create_transaction_accepts_confirmation_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + create_transaction_confirmed: true, + rpc_responses: sso_success_responses( + &session, + "create-tx-1", + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-create-tx-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::CreateTransactionResponse( + crate::host_logic::sso::messages::CreateTransactionResponse { + responding_to: "create-tx-1".to_string(), + signed_transaction: Ok(vec![0xca, 0xfe]), + }, + ), + ), + }, + ), + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("create-tx-1".to_string()); + let request = HostCreateTransactionRequest::V1(product_tx_payload("myapp.dot")); + let response = futures::executor::block_on(host.create_transaction(&cx, request)).unwrap(); + let HostCreateTransactionResponse::V1(inner) = response; + assert_eq!(inner.transaction, vec![0xca, 0xfe]); + let message = submitted_remote_message(&platform, &session); + assert!(matches!( + message.data, + crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::CreateTransactionRequest(_) + ) + )); + } + + #[test] + fn legacy_sign_raw_rejects_signer_mismatch() { + let host = + PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = + HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { + signer: "5Ci5sCERp3MFEDpF2jVkQDJoBevpRosB7toYRqKWShewhdhq".to_string(), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw_with_legacy_account(&cx, request)) + .unwrap_err(); + match err { + CallError::Domain(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::Unknown { reason }, + )) => assert_eq!(reason, "Account can't be derived from product account id"), + other => panic!("expected legacy signer mismatch, got {other:?}"), + } + } + + #[test] + fn legacy_sign_raw_denies_when_chain_submit_denied() { + let host = PlatformRuntimeHost::new( + Arc::new(StubPlatform { + remote_permission_granted: false, + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.session_state().set_session(sso_session_info()); + let cx = CallContext::new(); + let request = + HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { + signer: "5CyFsdhwjXy7wWpDEM6isungQ3LfGnu9UXkt7paBQ6DYRxk1".to_string(), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw_with_legacy_account(&cx, request)) + .unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::PermissionDenied + )) + )); + } + + #[test] + fn legacy_sign_raw_accepts_derived_ss58_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + rpc_responses: sso_success_responses( + &session, + "legacy-sign-raw-1", + sign_response_message("legacy-sign-raw-1", vec![9, 9], None), + ), + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("legacy-sign-raw-1".to_string()); + let request = + HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { + signer: "5CyFsdhwjXy7wWpDEM6isungQ3LfGnu9UXkt7paBQ6DYRxk1".to_string(), + payload: raw_payload(), + }); + let response = + futures::executor::block_on(host.sign_raw_with_legacy_account(&cx, request)).unwrap(); + let HostSignRawWithLegacyAccountResponse::V1(inner) = response; + assert_eq!(inner.signature, vec![9, 9]); + assert_eq!(inner.signed_transaction, None); + let message = submitted_remote_message(&platform, &session); + assert!(matches!( + &message.data, + crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::SignRequest(request) + ) if matches!( + request.as_ref(), + crate::host_logic::sso::messages::SigningRequest::Raw(_) + ) + )); + } + + #[test] + fn legacy_create_transaction_rejects_raw_key_mismatch() { + let host = + PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = + HostCreateTransactionWithLegacyAccountRequest::V1(v01::LegacyAccountTxPayload { + signer: [0; 32], + genesis_hash: [1; 32], + call_data: vec![0], + extensions: vec![], + tx_ext_version: 0, + }); + let err = + futures::executor::block_on(host.create_transaction_with_legacy_account(&cx, request)) + .unwrap_err(); + match err { + CallError::Domain(HostCreateTransactionWithLegacyAccountError::V1( + v01::HostCreateTransactionError::Unknown { reason }, + )) => assert_eq!(reason, "Account can't be derived from product account id"), + other => panic!("expected legacy signer mismatch, got {other:?}"), + } + } + + #[test] + fn create_transaction_rejects_invalid_product_account() { + let host = + PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostCreateTransactionRequest::V1(product_tx_payload("other.dot")); + let err = futures::executor::block_on(host.create_transaction(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::PermissionDenied + )) + )); + } + + #[test] + fn resource_allocation_rejects_without_session() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let err = futures::executor::block_on(ResourceAllocation::request( + &host, + &cx, + resource_allocation_request(), + )) + .unwrap_err(); + match err { + CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { reason }, + )) => assert_eq!(reason, "No active session"), + other => panic!("expected no-session resource allocation error, got {other:?}"), + } + } + + #[test] + fn resource_allocation_rejects_when_user_declines() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let err = futures::executor::block_on(ResourceAllocation::request( + &host, + &cx, + resource_allocation_request(), + )) + .unwrap_err(); + match err { + CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { reason }, + )) => assert_eq!(reason, "User rejected resource allocation"), + other => panic!("expected user-rejected resource allocation error, got {other:?}"), + } + } + + #[test] + fn resource_allocation_maps_confirmation_failure_to_host_failure() { + let host = PlatformRuntimeHost::new_compat( + Arc::new(StubPlatform { + resource_allocation_error: Some("modal failed"), + ..Default::default() + }), + test_spawner(), + ); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let err = futures::executor::block_on(ResourceAllocation::request( + &host, + &cx, + resource_allocation_request(), + )) + .unwrap_err(); + assert!( + matches!(err, CallError::HostFailure { reason } if reason.contains("modal failed")) + ); + } + + #[test] + fn resource_allocation_accepts_confirmation_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + rpc_responses: sso_success_responses( + &session, + "alloc-1", + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-alloc-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::ResourceAllocationResponse( + crate::host_logic::sso::messages::ResourceAllocationResponse { + responding_to: "alloc-1".to_string(), + payload: Ok(vec![ + crate::host_logic::sso::messages::SsoAllocationOutcome::Allocated( + crate::host_logic::sso::messages::SsoAllocatedResource::StatementStoreAllowance { + slot_account_key: vec![1], + }, + ), + crate::host_logic::sso::messages::SsoAllocationOutcome::Rejected, + crate::host_logic::sso::messages::SsoAllocationOutcome::NotAvailable, + ]), + }, + ), + ), + }, + ), + ..Default::default() + }); + let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("alloc-1".to_string()); + let response = futures::executor::block_on(ResourceAllocation::request( + &host, + &cx, + resource_allocation_request(), + )) + .unwrap(); + let HostRequestResourceAllocationResponse::V1(inner) = response; + assert_eq!( + inner.outcomes, + vec![ + v01::AllocationOutcome::Allocated, + v01::AllocationOutcome::Rejected, + v01::AllocationOutcome::NotAvailable, + ] + ); + let message = submitted_remote_message(&platform, &session); + assert!(matches!( + message.data, + crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::ResourceAllocationRequest(_) + ) + )); + } + + #[test] + fn session_store_sync_restores_valid_blob_from_tick() { + let stored = sso_session_info(); + let platform = Arc::new(StubPlatform { + session_blob: Some(crate::host_logic::session::encode_persisted_session( + &stored, + )), + ..Default::default() + }); + let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); + + host.start_session_store_sync(test_spawner()); + wait_until( + || host.session_state().current() == Some(stored.clone()), + "session store sync did not restore valid blob", + ); + + assert_eq!(host.session_state().current(), Some(stored.clone())); + assert_eq!( + *platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"), + vec![AuthState::Connected(connected_session_ui_info(&stored))] + ); + } + + #[test] + fn session_store_sync_replaces_valid_blob_and_broadcasts_connected() { + let mut replacement = sso_session_info(); + replacement.public_key = [0x44; 32]; + let host = PlatformRuntimeHost::new_compat( + Arc::new(StubPlatform { + session_blob: Some(crate::host_logic::session::encode_persisted_session( + &replacement, + )), + ..Default::default() + }), + test_spawner(), + ); + host.session_state().set_session(sso_session_info()); + let mut statuses = host.session_state().subscribe(); + let _ = futures::executor::block_on(statuses.next()); + + host.start_session_store_sync(test_spawner()); + + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Connected + ) + ); + assert_eq!(host.session_state().current(), Some(replacement)); + } + + #[test] + fn session_store_sync_clears_invalid_blob() { + let platform = Arc::new(StubPlatform { + session_blob: Some(vec![0xff]), + ..Default::default() + }); + let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.session_state().set_session(sso_session_info()); + + host.start_session_store_sync(test_spawner()); + wait_until( + || host.session_state().current().is_none(), + "session store sync did not clear invalid blob", + ); + + assert!(host.session_state().current().is_none()); + // `set_session` bypasses the auth state cell, so the cell never left + // `Disconnected` and clearing the invalid blob emits nothing. + assert!( + platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .is_empty() + ); + } + + #[test] + fn session_store_sync_clears_unreadable_blob() { + let session_clears = Arc::new(Mutex::new(0)); + let host = PlatformRuntimeHost::new_compat( + Arc::new(StubPlatform { + session_error: Some("storage unavailable"), + session_clears: session_clears.clone(), + ..Default::default() + }), + test_spawner(), + ); + host.session_state().set_session(sso_session_info()); + + host.start_session_store_sync(test_spawner()); + wait_until( + || *session_clears.lock().unwrap() == 1, + "session store sync did not clear unreadable blob", + ); + + assert!(host.session_state().current().is_none()); + assert_eq!(*session_clears.lock().unwrap(), 1); + } + + /// A persistently failing read clears the backing store once for the + /// initial sync tick. Further clears require explicit host notifications. + #[test] + fn session_store_sync_clears_once_on_initial_persistent_read_error() { + let session_clears = Arc::new(Mutex::new(0)); + let host = PlatformRuntimeHost::new_compat( + Arc::new(StubPlatform { + session_error: Some("storage unavailable"), + session_clears: session_clears.clone(), + ..Default::default() + }), + test_spawner(), + ); + host.session_state().set_session(sso_session_info()); + + host.start_session_store_sync(test_spawner()); + + wait_until( + || *session_clears.lock().unwrap() == 1, + "clear_stored_session was never called", + ); + assert_eq!(*session_clears.lock().unwrap(), 1); + assert!(host.session_state().current().is_none()); + } + + #[test] + fn disconnect_submits_disconnected_message_best_effort() { + let platform = Arc::new(StubPlatform::default()); + let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); + let session = sso_session_info(); + host.session_state().set_session(session.clone()); + + futures::executor::block_on(host.disconnect()); + + assert!(host.session_state().current().is_none()); + assert_eq!( + *platform + .session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 1 + ); + let message = submitted_remote_message(&platform, &session); + assert_eq!(message.message_id, "truapi:sso:disconnect"); + assert!(matches!( + message.data, + RemoteMessageData::V1(RemoteMessageV1::Disconnected) + )); + } + + #[test] + fn disconnect_clears_session_store_and_broadcasts_disconnected() { + let platform = Arc::new(StubPlatform::default()); + let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.session_state().set_session(sso_session_info()); + platform + .local_storage + .lock() + .expect("local storage mutex poisoned") + .insert( + core_storage_test_key(CoreStorageKey::PairingDeviceIdentity), + vec![1, 2, 3], + ); + let mut statuses = host.session_state().subscribe(); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Connected + ) + ); + + futures::executor::block_on(host.disconnect()); + + assert!(host.session_state().current().is_none()); + assert_eq!( + *platform + .session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 1 + ); + assert!( + !platform + .local_storage + .lock() + .expect("local storage mutex poisoned") + .contains_key(&core_storage_test_key( + CoreStorageKey::PairingDeviceIdentity + )), + "logout must rotate the pairing device identity so stale statement-store responses cannot be replayed on the next login" + ); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Disconnected + ) + ); + // `set_session` bypasses the auth state cell, so the cell never left + // `Disconnected` and the logout emits nothing new. + assert!( + platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .is_empty() + ); + } + + #[test] + fn disconnect_emits_disconnected_auth_state_after_store_sync_connected() { + let stored = sso_session_info(); + let platform = Arc::new(StubPlatform { + session_blob: Some(crate::host_logic::session::encode_persisted_session( + &stored, + )), + ..Default::default() + }); + let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.start_session_store_sync(test_spawner()); + wait_until( + || { + platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .len() + == 1 + }, + "session store sync did not emit connected auth state", + ); + + futures::executor::block_on(host.disconnect()); + + assert_eq!( + *platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"), + vec![ + AuthState::Connected(connected_session_ui_info(&stored)), + AuthState::Disconnected, + ] + ); + } + + #[test] + fn disconnect_tolerates_repeated_logout_when_already_disconnected() { + let platform = Arc::new(StubPlatform::default()); + let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); + + futures::executor::block_on(host.disconnect()); + futures::executor::block_on(host.disconnect()); + + assert!(host.session_state().current().is_none()); + assert_eq!( + *platform + .session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 2 + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } + + #[test] + fn disconnect_notifies_pending_sso_waiters() { + let platform = Arc::new(StubPlatform::default()); + let host = PlatformRuntimeHost::new_compat(platform, test_spawner()); + let (_waiter_id, disconnect) = host.session_disconnects.subscribe(); + + futures::executor::block_on(host.disconnect()); + + assert_eq!( + futures::executor::block_on(disconnect).unwrap(), + SSO_LOCAL_DISCONNECT_REASON + ); + } + + #[test] + fn permissions_grants_and_caches() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostDevicePermissionRequest::V1(v01::HostDevicePermissionRequest::Camera); + let response = + futures::executor::block_on(host.request_device_permission(&cx, request)).unwrap(); + let HostDevicePermissionResponse::V1(inner) = response; + assert!(inner.granted); + } + + #[test] + fn feature_supported_encodes_response_to_known_bytes() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + }); + let response = futures::executor::block_on(host.feature_supported(&cx, request)).unwrap(); + // [V1 variant=0][supported=1] + assert_eq!(response.encode(), vec![0x00, 0x01]); + } +} diff --git a/rust/crates/truapi-server/src/runtime/auth_state.rs b/rust/crates/truapi-server/src/runtime/auth_state.rs new file mode 100644 index 00000000..1b0b8541 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/auth_state.rs @@ -0,0 +1,168 @@ +//! Core-owned auth/session UI state machine. Every [`AuthState`] emission to +//! the host funnels through [`AuthStateMachine`], so transitions stay ordered +//! and a stale session-store tick can never tear down an in-flight pairing. + +use std::sync::{Arc, Mutex}; + +use futures::channel::oneshot; +use truapi_platform::{AuthPresenter, AuthState, Platform, SessionUiInfo}; + +/// Serialized auth-state machine bound to the platform's `auth_state_changed` +/// sink. Each transition mutates under the lock, releases it, then emits the +/// new state (when it actually changed), so `auth_state_changed` handlers may +/// safely re-enter the runtime (e.g. a host cancelling the login it just +/// observed). The cancel channel for an in-flight login lives inside the +/// `Pairing` state, making its registration atomic with the transition. +pub(crate) struct AuthStateMachine { + platform: Arc, + inner: Arc>, +} + +impl Clone for AuthStateMachine { + fn clone(&self) -> Self { + Self { + platform: self.platform.clone(), + inner: self.inner.clone(), + } + } +} + +#[derive(Default)] +struct AuthStateInner { + state: AuthState, + /// Increments on every `pairing_started`; lets an abandoned flow's reset + /// guard distinguish its own `Pairing` from a newer flow's. + pairing_epoch: u64, + /// Resolves the in-flight login's cancel receiver. Present exactly while + /// the state is `Pairing`. + cancel_tx: Option>, +} + +impl AuthStateMachine { + /// Create an auth state machine that reports transitions to `platform`. + pub(super) fn new(platform: Arc) -> Self { + Self { + platform, + inner: Arc::new(Mutex::new(AuthStateInner::default())), + } + } + + /// Enter `Pairing`. Returns the cancel receiver and the pairing epoch, or + /// `None` when a pairing is already in flight (single-flight guard). + pub(super) fn pairing_started(&self, deeplink: String) -> Option<(oneshot::Receiver<()>, u64)> { + let (cancel_tx, cancel_rx) = oneshot::channel(); + let epoch = self.transition(|inner| { + if matches!(inner.state, AuthState::Pairing { .. }) { + return None; + } + inner.state = AuthState::Pairing { deeplink }; + inner.pairing_epoch = inner.pairing_epoch.wrapping_add(1); + inner.cancel_tx = Some(cancel_tx); + Some(inner.pairing_epoch) + })?; + Some((cancel_rx, epoch)) + } + + /// `Pairing` -> `LoginFailed`: the in-flight login reported a failure. + pub(super) fn login_failed(&self, reason: String) { + self.transition(|inner| { + if !matches!(inner.state, AuthState::Pairing { .. }) { + return None; + } + inner.cancel_tx = None; + inner.state = AuthState::LoginFailed { reason }; + Some(()) + }); + } + + /// `Disconnected`/`LoginFailed` -> `LoginFailed`: a login failed before + /// it reached `Pairing` (device identity or bootstrap errors). A no-op + /// while `Pairing`, so a concurrent second login attempt failing early + /// cannot tear down the first one's presentation. + pub(super) fn login_failed_before_pairing(&self, reason: String) { + self.transition(|inner| { + if matches!( + inner.state, + AuthState::Pairing { .. } | AuthState::Connected(_) + ) { + return None; + } + inner.state = AuthState::LoginFailed { reason }; + Some(()) + }); + } + + /// `Pairing`/`LoginFailed` -> `Disconnected` (host cancelled or + /// dismissed). Wakes the in-flight login, which resolves as `Rejected`. + pub(super) fn login_cancelled(&self) { + self.transition(|inner| { + if !matches!( + inner.state, + AuthState::Pairing { .. } | AuthState::LoginFailed { .. } + ) { + return None; + } + if let Some(cancel_tx) = inner.cancel_tx.take() { + let _ = cancel_tx.send(()); + } + inner.state = AuthState::Disconnected; + Some(()) + }); + } + + /// Any state -> `Connected`. A login in flight is cancelled: another + /// runtime won the race, and the waking flow resolves as + /// `AlreadyConnected`. Emits only when the connected info changed. + pub(super) fn connected(&self, info: &SessionUiInfo) { + self.transition(|inner| { + if let Some(cancel_tx) = inner.cancel_tx.take() { + let _ = cancel_tx.send(()); + } + if matches!(&inner.state, AuthState::Connected(current) if current == info) { + return None; + } + inner.state = AuthState::Connected(info.clone()); + Some(()) + }); + } + + /// Session store reports no session. A no-op while `Pairing`: the login + /// flow owns its own terminal transition, and a boot-time store tick must + /// not tear down the pairing UI. + pub(super) fn store_disconnected(&self) { + self.transition(|inner| { + if matches!( + inner.state, + AuthState::Pairing { .. } | AuthState::Disconnected + ) { + return None; + } + inner.state = AuthState::Disconnected; + Some(()) + }); + } + + /// Reset a `Pairing` left behind by a dropped login future, but only when + /// it still belongs to `epoch` (a newer flow's pairing is left alone). + pub(super) fn reset_abandoned_pairing(&self, epoch: u64) { + self.transition(|inner| { + if !matches!(inner.state, AuthState::Pairing { .. }) || inner.pairing_epoch != epoch { + return None; + } + inner.cancel_tx = None; + inner.state = AuthState::Disconnected; + Some(()) + }); + } + + /// Run `apply` under the lock; when it changed the state (returned + /// `Some`), emit the new state to the host after releasing the lock. + fn transition(&self, apply: impl FnOnce(&mut AuthStateInner) -> Option) -> Option { + let mut inner = self.inner.lock().expect("auth state mutex poisoned"); + let applied = apply(&mut inner)?; + let state = inner.state.clone(); + drop(inner); + AuthPresenter::auth_state_changed(self.platform.as_ref(), state); + Some(applied) + } +} diff --git a/rust/crates/truapi-server/src/runtime/identity.rs b/rust/crates/truapi-server/src/runtime/identity.rs new file mode 100644 index 00000000..1b3af084 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/identity.rs @@ -0,0 +1,339 @@ +//! People-chain identity lookup used to resolve usernames for a paired +//! session. + +use std::sync::atomic::{AtomicU64, Ordering}; + +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; + +use super::PlatformRuntimeHost; +use crate::chain_runtime::ChainRuntime; +use crate::host_logic::identity::{ + PeopleIdentity, decode_people_identity, resources_consumers_storage_key, +}; +use crate::host_logic::session::SessionInfo; + +use futures::stream::BoxStream; +use futures::{FutureExt, StreamExt, pin_mut}; +use tracing::{debug, instrument, warn}; +use truapi::v01; +use truapi::v01::{ + OperationStartedResult, RemoteChainHeadFollowItem as V01RemoteChainHeadFollowItem, + StorageQueryType, +}; + +impl PlatformRuntimeHost { + /// Resolve usernames for `session` against this runtime's people chain. + #[instrument(skip_all, fields(runtime.method = "session.identity.resolve"))] + pub(super) async fn resolve_session_identity(&self, session: SessionInfo) -> SessionInfo { + resolve_session_identity_with_chain( + &self.chain, + self.runtime_config.people_chain_genesis_hash, + session, + ) + .await + } +} + +static IDENTITY_LOOKUP_COUNTER: AtomicU64 = AtomicU64::new(1); + +/// Fill in missing usernames by querying the people chain; returns the +/// session unchanged when it already carries a username or no people chain +/// is configured. +#[instrument(skip_all, fields(runtime.method = "session.identity.resolve_with_chain"))] +pub(super) async fn resolve_session_identity_with_chain( + chain: &ChainRuntime, + people_chain_genesis_hash: [u8; 32], + mut session: SessionInfo, +) -> SessionInfo { + if session_has_username(&session) || people_chain_genesis_hash == [0; 32] { + return session; + } + + let preferred_account = session.identity_account_id.unwrap_or(session.public_key); + if !lookup_and_apply( + chain, + people_chain_genesis_hash, + preferred_account, + &mut session, + "identity", + ) + .await + && preferred_account != session.public_key + { + let public_key = session.public_key; + lookup_and_apply( + chain, + people_chain_genesis_hash, + public_key, + &mut session, + "root identity", + ) + .await; + } + + session +} + +/// Look up `account`'s people-chain identity and apply any usernames to +/// `session`; returns whether a username record was found and applied. +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 + } + } +} + +fn non_empty(value: &Option) -> bool { + value.as_ref().is_some_and(|value| !value.is_empty()) +} + +fn session_has_username(session: &SessionInfo) -> bool { + non_empty(&session.full_username) || non_empty(&session.lite_username) +} + +fn apply_people_identity(session: &mut SessionInfo, identity: PeopleIdentity) { + if non_empty(&identity.full_username) { + session.full_username = identity.full_username; + } + if non_empty(&identity.lite_username) { + session.lite_username = identity.lite_username; + } +} + +#[instrument(skip_all, fields(runtime.method = "session.identity.lookup"))] +async fn lookup_people_identity( + chain: &ChainRuntime, + people_chain_genesis_hash: [u8; 32], + account_id: [u8; 32], +) -> Result, String> { + let genesis_hash = people_chain_genesis_hash.to_vec(); + let key = resources_consumers_storage_key(&account_id); + let follow_id = format!( + "truapi:identity:{}:{}", + IDENTITY_LOOKUP_COUNTER.fetch_add(1, Ordering::Relaxed), + hex::encode(account_id), + ); + let mut follow = chain.remote_chain_head_follow( + follow_id.clone(), + v01::RemoteChainHeadFollowRequest { + genesis_hash: genesis_hash.clone(), + with_runtime: false, + }, + ); + + let hash = wait_for_identity_follow_hash(&mut follow).await?; + let response = chain + .remote_chain_head_storage(v01::RemoteChainHeadStorageRequest { + genesis_hash, + follow_subscription_id: follow_id, + hash, + items: vec![v01::StorageQueryItem { + key: key.clone(), + query_type: StorageQueryType::Value, + }], + child_trie: None, + }) + .await + .map_err(|failure| failure.reason())?; + + let operation_id = match response.operation { + OperationStartedResult::Started { operation_id } => operation_id, + OperationStartedResult::LimitReached => { + return Err("People-chain storage lookup limit reached".to_string()); + } + }; + let Some(value) = wait_for_identity_storage_value(&mut follow, &operation_id, &key).await? + else { + return Ok(None); + }; + decode_people_identity(&value).map(Some) +} + +#[instrument(skip_all, fields(runtime.method = "session.identity.wait_follow_hash"))] +async fn wait_for_identity_follow_hash( + follow: &mut BoxStream<'static, V01RemoteChainHeadFollowItem>, +) -> Result, String> { + let timeout = futures_timer::Delay::new(Duration::from_secs(10)).fuse(); + pin_mut!(timeout); + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(V01RemoteChainHeadFollowItem::Initialized { finalized_block_hashes, .. }) => { + let fallback = finalized_block_hashes.last().cloned(); + return wait_for_identity_best_hash(follow, fallback).await; + } + Some(V01RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { + return Ok(best_block_hash); + } + Some(V01RemoteChainHeadFollowItem::Stop) | None => { + return Err("People-chain follow stopped before initialization".to_string()); + } + _ => {} + }, + () = timeout => return Err("People-chain follow initialization timed out".to_string()), + } + } +} + +async fn wait_for_identity_best_hash( + follow: &mut BoxStream<'static, V01RemoteChainHeadFollowItem>, + fallback: Option>, +) -> Result, String> { + let timeout = futures_timer::Delay::new(Duration::from_secs(2)).fuse(); + pin_mut!(timeout); + let mut candidate = fallback; + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(V01RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { + return Ok(best_block_hash); + } + Some(V01RemoteChainHeadFollowItem::NewBlock { block_hash, .. }) => { + candidate = Some(block_hash); + } + Some(V01RemoteChainHeadFollowItem::Stop) | None => { + return candidate.ok_or_else(|| { + "People-chain follow stopped before best block".to_string() + }); + } + _ => {} + }, + () = timeout => { + return candidate.ok_or_else(|| { + "People-chain follow best block timed out".to_string() + }); + }, + } + } +} + +#[instrument(skip_all, fields(runtime.method = "session.identity.wait_storage_value"))] +async fn wait_for_identity_storage_value( + follow: &mut BoxStream<'static, V01RemoteChainHeadFollowItem>, + operation_id: &str, + key: &[u8], +) -> Result>, String> { + let timeout = futures_timer::Delay::new(Duration::from_secs(10)).fuse(); + pin_mut!(timeout); + let mut value = None; + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(V01RemoteChainHeadFollowItem::OperationStorageItems { operation_id: item_operation_id, items }) + if item_operation_id == operation_id => + { + for item in items { + if item.key == key { + value = item.value; + } + } + } + Some(V01RemoteChainHeadFollowItem::OperationStorageDone { operation_id: item_operation_id }) + if item_operation_id == operation_id => + { + return Ok(value); + } + Some(V01RemoteChainHeadFollowItem::OperationInaccessible { operation_id: item_operation_id }) + if item_operation_id == operation_id => + { + return Ok(None); + } + Some(V01RemoteChainHeadFollowItem::OperationError { operation_id: item_operation_id, error }) + if item_operation_id == operation_id => + { + return Err(error); + } + Some(V01RemoteChainHeadFollowItem::Stop) | None => { + return Err("People-chain follow stopped during storage lookup".to_string()); + } + _ => {} + }, + () = timeout => return Err("People-chain storage lookup timed out".to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::stream; + + #[test] + fn identity_follow_prefers_best_block_after_initialization() { + let mut follow = stream::iter(vec![ + V01RemoteChainHeadFollowItem::Initialized { + finalized_block_hashes: vec![vec![0x01]], + finalized_block_runtime: None, + }, + V01RemoteChainHeadFollowItem::BestBlockChanged { + best_block_hash: vec![0x02], + }, + ]) + .boxed(); + + let hash = futures::executor::block_on(wait_for_identity_follow_hash(&mut follow)) + .expect("best hash should resolve"); + + assert_eq!(hash, vec![0x02]); + } + + #[test] + fn identity_follow_uses_new_block_before_stale_finalized_fallback() { + let mut follow = stream::iter(vec![ + V01RemoteChainHeadFollowItem::Initialized { + finalized_block_hashes: vec![vec![0x01]], + finalized_block_runtime: None, + }, + V01RemoteChainHeadFollowItem::NewBlock { + block_hash: vec![0x03], + parent_block_hash: vec![0x01], + new_runtime: None, + }, + V01RemoteChainHeadFollowItem::Stop, + ]) + .boxed(); + + let hash = futures::executor::block_on(wait_for_identity_follow_hash(&mut follow)) + .expect("new block hash should resolve"); + + assert_eq!(hash, vec![0x03]); + } +} diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs new file mode 100644 index 00000000..c25c2e9a --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -0,0 +1,787 @@ +//! SSO pairing (login): presents the pairing deeplink, watches the bootstrap +//! topic on the statement store (live subscription plus periodic snapshot +//! queries), and decrypts the wallet's V2 handshake response into a session. + +#[cfg(test)] +use std::sync::{Arc, Mutex}; + +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; + +use super::auth_state::AuthStateMachine; +use super::statement_store_rpc::{self, StatementStoreRpc}; +use super::{PlatformRuntimeHost, connected_session_ui_info}; +use crate::host_logic::session::{SessionInfo, encode_persisted_session}; +use crate::host_logic::sso::pairing::{ + EncryptedHandshakeResponseV2, PairingBootstrap, PairingDeviceIdentity, + VersionedHandshakeResponse, create_pairing_bootstrap_from_identity, decode_app_handshake_data, + decrypt_v2_handshake_response, establish_sso_session_info, generate_pairing_device_identity, +}; +use crate::host_logic::statement_store::{ + decode_verified_statement_data, parse_new_statements_result, +}; +use crate::subscription::Spawner; + +use futures::channel::{mpsc, oneshot}; +use futures::{FutureExt, StreamExt, pin_mut}; +use parity_scale_codec::Encode; +use serde_json::Value; +use subxt_rpcs::RpcClient; +use subxt_rpcs::client::RpcSubscription; +use tracing::{debug, info, instrument}; +use truapi::CallError; +use truapi::v01; +use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; +use truapi_platform::{CoreStorage, CoreStorageKey}; + +#[cfg(not(test))] +const PAIRING_QUERY_INTERVAL: Duration = Duration::from_secs(2); +#[cfg(test)] +const PAIRING_QUERY_INTERVAL: Duration = Duration::from_millis(1); +#[cfg(not(test))] +const PAIRING_QUERY_TIMEOUT_TICKS: u8 = 15; +#[cfg(test)] +const PAIRING_QUERY_TIMEOUT_TICKS: u8 = 10; + +/// Terminal outcome of [`PlatformRuntimeHost::run_pairing_flow`]. +enum PairingFlowOutcome { + /// The login was cancelled (host `cancel_login`, `disconnect`, or a + /// cross-tab session win). + Cancelled, + /// Wallet handshake completed; the session is resolved and persisted. + Success(Box), +} + +/// Resets a `Pairing` state left behind by a dropped login future (e.g. the +/// transport dropping in-flight calls on connection close). A no-op once the +/// flow reached any terminal transition or a newer pairing took over. +struct AbandonedPairingGuard { + auth_state: AuthStateMachine, + epoch: u64, +} + +impl Drop for AbandonedPairingGuard { + fn drop(&mut self) { + self.auth_state.reset_abandoned_pairing(self.epoch); + } +} + +impl PlatformRuntimeHost { + /// `request_login` pairing flow: emits `AuthState::Pairing` for the host + /// to present, then races host cancellation against the wallet handshake + /// arriving on the statement store; on success resolves identity and + /// persists the new session. + pub(super) async fn request_login_flow( + &self, + ) -> Result> { + if let Some(session) = self.session_state.current() { + debug!("request_login: already connected, returning early"); + self.auth_state + .connected(&connected_session_ui_info(&session)); + return Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::AlreadyConnected, + )); + } + + let pairing_identity = create_fresh_pairing_device_identity(self.platform.as_ref()) + .await + .map_err(|reason| self.fail_before_pairing(reason))?; + let bootstrap = + create_pairing_bootstrap_from_identity(&self.runtime_config, pairing_identity) + .map_err(|err| self.fail_before_pairing(err.to_string()))?; + + let Some((cancel_rx, pairing_epoch)) = + self.auth_state.pairing_started(bootstrap.deeplink.clone()) + else { + return Err(CallError::Domain(HostRequestLoginError::V1( + v01::HostRequestLoginError::Unknown { + reason: "login already in progress".to_string(), + }, + ))); + }; + info!("presenting pairing QR, waiting for wallet handshake"); + let _reset_guard = AbandonedPairingGuard { + auth_state: self.auth_state.clone(), + epoch: pairing_epoch, + }; + + match self.run_pairing_flow(&bootstrap, cancel_rx).await { + Ok(PairingFlowOutcome::Cancelled) => { + // `cancel_login` (or the cross-tab `connected` transition) + // already moved the auth state; only the call result is left + // to map. A session appearing mid-flow means another runtime + // won the login. + if self.session_state.current().is_some() { + info!("login cancelled by a cross-runtime session win"); + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::AlreadyConnected, + )) + } else { + info!("login cancelled before handshake, login rejected"); + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::Rejected, + )) + } + } + Ok(PairingFlowOutcome::Success(session)) => { + let session = *session; + self.auth_state + .connected(&connected_session_ui_info(&session)); + self.session_state.set_session(session.clone()); + self.start_sso_disconnect_monitor(&session); + info!("login succeeded, SSO session established"); + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::Success, + )) + } + Err(reason) => { + self.auth_state.login_failed(reason.clone()); + Err(CallError::HostFailure { reason }) + } + } + } + + /// Emit `LoginFailed` for an error raised before the pairing was entered + /// and map it onto the `request_login` error shape. + fn fail_before_pairing(&self, reason: String) -> CallError { + self.auth_state.login_failed_before_pairing(reason.clone()); + CallError::Domain(HostRequestLoginError::V1( + v01::HostRequestLoginError::Unknown { reason }, + )) + } + + /// Everything between the `Pairing` emission and a terminal outcome. + /// Every error returned here maps to `AuthState::LoginFailed` at the + /// single exit in [`Self::request_login_flow`]. + async fn run_pairing_flow( + &self, + bootstrap: &PairingBootstrap, + cancel_rx: oneshot::Receiver<()>, + ) -> Result { + let mut cancel = cancel_rx.fuse(); + let statement_store = StatementStoreRpc::new( + self.platform.clone(), + self.runtime_config.people_chain_genesis_hash, + self.spawner.clone(), + ); + let statement_store_connect = statement_store.client("pairing statement-store").fuse(); + pin_mut!(statement_store_connect); + + let rpc_client = futures::select! { + _ = cancel => return Ok(PairingFlowOutcome::Cancelled), + connect_result = statement_store_connect => connect_result?, + }; + let subscribe_client = rpc_client.clone(); + let live_topics = [bootstrap.topic]; + let live_subscription = + statement_store_rpc::subscribe_match_all(&subscribe_client, &live_topics).fuse(); + pin_mut!(live_subscription); + let live_subscription = futures::select! { + _ = cancel => return Ok(PairingFlowOutcome::Cancelled), + subscribe_result = live_subscription => subscribe_result + .map_err(|err| format!("pairing statement-store subscribe failed: {err}"))?, + }; + debug!("subscribed to pairing topic, polling statement store"); + let pairing_response = wait_for_v2_pairing_success( + rpc_client, + live_subscription, + bootstrap.topic, + bootstrap.encryption_secret_key, + self.spawner.clone(), + ) + .fuse(); + pin_mut!(pairing_response); + + let response = futures::select! { + _ = cancel => return Ok(PairingFlowOutcome::Cancelled), + response_result = pairing_response => response_result?, + }; + let sso = establish_sso_session_info( + bootstrap, + response.peer_statement_account_id, + response.success.sso_enc_pub_key, + )?; + let session = SessionInfo { + public_key: response.success.root_account_id, + sso: Some(sso), + root_entropy_source: Some(response.success.root_entropy_source), + identity_account_id: Some(response.success.identity_account_id), + lite_username: None, + full_username: None, + }; + let session = self.resolve_session_identity(session).await; + self.platform + .write_core_storage( + CoreStorageKey::AuthSession, + encode_persisted_session(&session), + ) + .await + .map_err(|err| format!("session persist failed: {err:?}"))?; + Ok(PairingFlowOutcome::Success(Box::new(session))) + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing_device.create_fresh"))] +async fn create_fresh_pairing_device_identity( + storage: &(impl CoreStorage + ?Sized), +) -> Result { + let identity = generate_pairing_device_identity() + .map_err(|err| format!("pairing identity failed: {err}"))?; + storage + .write_core_storage(CoreStorageKey::PairingDeviceIdentity, identity.encode()) + .await + .map_err(|err| format!("pairing device identity write failed: {err:?}"))?; + Ok(identity) +} + +struct PairingSuccess { + peer_statement_account_id: [u8; 32], + success: crate::host_logic::sso::pairing::HandshakeSuccessV2, +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing.wait_success"))] +async fn wait_for_v2_pairing_success( + rpc_client: RpcClient, + mut live_subscription: RpcSubscription, + topic: [u8; 32], + core_encryption_secret_key: [u8; 32], + spawner: Spawner, +) -> Result { + let (query_tx, mut query_rx) = mpsc::unbounded(); + let mut query_active = false; + let poll = futures_timer::Delay::new(PAIRING_QUERY_INTERVAL).fuse(); + pin_mut!(poll); + loop { + futures::select! { + item = live_subscription.next().fuse() => { + let Some(item) = item else { + return Err("pairing statement-store live subscription ended".to_string()); + }; + let value = item.map_err(|err| format!("pairing statement-store live error: {err}"))?; + if let Some(success) = handle_v2_pairing_result(&value, core_encryption_secret_key)? { + return Ok(success); + } + } + query = query_rx.next().fuse() => { + query_active = false; + if let Some(query) = query + && let Some(success) = query? { + return Ok(success); + } + } + _ = poll => { + if !query_active { + query_active = true; + let rpc_client = rpc_client.clone(); + let query_tx = query_tx.clone(); + let fut = async move { + let result = run_pairing_snapshot_query( + rpc_client, + topic, + core_encryption_secret_key, + ).await; + let _ = query_tx.unbounded_send(result); + }; + // `RpcClient` is transport-only here; spawning lets live + // notifications continue to be consumed while a snapshot + // query is waiting for backlog completion or timeout. + (spawner)(fut.boxed()); + } + poll.set(futures_timer::Delay::new(PAIRING_QUERY_INTERVAL).fuse()); + } + } + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing.snapshot_query"))] +async fn run_pairing_snapshot_query( + rpc_client: RpcClient, + topic: [u8; 32], + core_encryption_secret_key: [u8; 32], +) -> Result, String> { + let topics = [topic]; + let mut subscription = statement_store_rpc::subscribe_match_all(&rpc_client, &topics) + .await + .map_err(|err| format!("pairing statement-store query failed: {err}"))?; + for _ in 0..PAIRING_QUERY_TIMEOUT_TICKS { + let timeout = futures_timer::Delay::new(PAIRING_QUERY_INTERVAL).fuse(); + pin_mut!(timeout); + futures::select! { + item = subscription.next().fuse() => { + let Some(item) = item else { + return Ok(None); + }; + let value = item.map_err(|err| format!("pairing statement-store query item failed: {err}"))?; + if let Some(success) = handle_v2_pairing_result(&value, core_encryption_secret_key)? { + return Ok(Some(success)); + } + let page = parse_new_statements_result("query".to_string(), &value) + .map_err(|err| err.to_string())?; + if page.remaining == Some(0) { + return Ok(None); + } + } + _ = timeout => {} + } + } + Ok(None) +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing.handle_result"))] +fn handle_v2_pairing_result( + value: &Value, + core_encryption_secret_key: [u8; 32], +) -> Result, String> { + let page = + parse_new_statements_result("pairing".to_string(), value).map_err(|err| err.to_string())?; + for statement in page.statements { + if let Some(success) = decode_v2_pairing_statement(&statement, core_encryption_secret_key)? + { + return Ok(Some(success)); + } + } + + Ok(None) +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing.decode_statement"))] +fn decode_v2_pairing_statement( + statement: &[u8], + core_encryption_secret_key: [u8; 32], +) -> Result, String> { + let verified = + decode_verified_statement_data(statement, None).map_err(|err| err.to_string())?; + let VersionedHandshakeResponse::V2 { + encrypted_message, + public_key, + } = decode_app_handshake_data(&verified.data)?; + match decrypt_v2_handshake_response(core_encryption_secret_key, public_key, &encrypted_message)? + { + EncryptedHandshakeResponseV2::Pending(_) => Ok(None), + EncryptedHandshakeResponseV2::Failed(reason) => Err(reason), + EncryptedHandshakeResponseV2::Success(success) => Ok(Some(PairingSuccess { + peer_statement_account_id: verified.signer, + success: *success, + })), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{ + StubPlatform, core_storage_test_key, pairing_device_from_deeplink, peer_statement_keypair, + runtime_config, session_info, signed_test_statement, stub_platform, test_spawner, + }; + use p256::elliptic_curve::sec1::ToEncodedPoint; + use truapi::CallContext; + use truapi::api::Account; + use truapi::versioned::account::{ + HostAccountConnectionStatusSubscribeItem, HostRequestLoginRequest, + }; + use truapi_platform::{AuthState, CoreStorageKey}; + + /// Cancel the login as soon as the host observes the `Pairing` state, + /// mimicking a user dismissing the pairing UI immediately. + fn cancel_on_pairing(platform: &StubPlatform, host: &Arc) { + let host = host.clone(); + *platform + .on_auth_state + .lock() + .expect("auth state hook mutex poisoned") = Some(Arc::new(move |state| { + if matches!(state, AuthState::Pairing { .. }) { + host.cancel_login(); + } + })); + } + + #[test] + fn request_login_presents_pairing_and_rejects_when_cancelled() { + let platform = stub_platform(); + let host = Arc::new(PlatformRuntimeHost::new_compat( + platform.clone(), + test_spawner(), + )); + cancel_on_pairing(&platform, &host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + let auth_states = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"); + assert_eq!(auth_states.len(), 2, "states: {auth_states:?}"); + match &auth_states[0] { + AuthState::Pairing { deeplink } => { + assert!(deeplink.starts_with("polkadotapp://pair?handshake=")); + } + other => panic!("expected pairing state first, got {other:?}"), + } + assert_eq!(auth_states[1], AuthState::Disconnected); + + let sent_rpc = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + if let Some(sent) = sent_rpc.first() { + let request: serde_json::Value = serde_json::from_str(sent).unwrap(); + assert_eq!(request["method"], "statement_subscribeStatement"); + assert_eq!( + request["params"][0]["matchAll"][0].as_str().unwrap().len(), + 66 + ); + } + } + + #[test] + fn request_login_rotates_pairing_device_identity_between_attempts() { + let platform = stub_platform(); + let host = Arc::new(PlatformRuntimeHost::new_compat( + platform.clone(), + test_spawner(), + )); + cancel_on_pairing(&platform, &host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + + let first = futures::executor::block_on(host.request_login(&cx, request.clone())).unwrap(); + let second = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + first, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + assert_eq!( + second, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + let deeplinks: Vec = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .iter() + .filter_map(|state| match state { + AuthState::Pairing { deeplink } => Some(deeplink.clone()), + _ => None, + }) + .collect(); + assert_eq!(deeplinks.len(), 2); + assert_ne!( + pairing_device_from_deeplink(&deeplinks[0]), + pairing_device_from_deeplink(&deeplinks[1]) + ); + assert!( + platform + .local_storage + .lock() + .expect("local storage mutex poisoned") + .contains_key(&core_storage_test_key( + CoreStorageKey::PairingDeviceIdentity + )) + ); + } + + #[test] + fn request_login_waits_for_pairing_statement() { + let wallet_ephemeral_secret = p256::SecretKey::from_slice(&[2; 32]).unwrap(); + let wallet_ephemeral_public = wallet_ephemeral_secret.public_key().to_encoded_point(false); + let mut wallet_ephemeral_public_bytes = [0u8; 65]; + wallet_ephemeral_public_bytes.copy_from_slice(wallet_ephemeral_public.as_bytes()); + let handshake = crate::host_logic::sso::pairing::VersionedHandshakeResponse::V2 { + encrypted_message: vec![0xde, 0xad], + public_key: wallet_ephemeral_public_bytes, + }; + let statement = signed_test_statement(handshake.encode()); + let notification = format!( + r#"{{"jsonrpc":"2.0","method":"statement_subscribeStatement","params":{{"subscription":"remote-sub","result":{{"event":"newStatements","data":{{"statements":["0x{}"],"remaining":0}}}}}}}}"#, + hex::encode(statement) + ); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":"remote-sub"}"#.to_string(), + notification, + ], + ..Default::default() + }); + let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let err = futures::executor::block_on(host.request_login(&cx, request)).unwrap_err(); + + match err { + CallError::HostFailure { reason } => { + assert_eq!(reason, "encrypted SSO handshake answer is too short"); + } + other => panic!("expected handshake decrypt failure, got {other:?}"), + } + let sent_rpc = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + let methods = sent_rpc + .iter() + .map(|request| serde_json::from_str::(request).unwrap()) + .map(|request| request["method"].as_str().unwrap().to_string()) + .collect::>(); + assert_eq!( + methods.first().map(String::as_str), + Some("statement_subscribeStatement") + ); + assert!( + methods + .iter() + .any(|method| method == "statement_unsubscribeStatement"), + "pairing subscription should be cleaned up" + ); + let unsubscribe: serde_json::Value = serde_json::from_str(&sent_rpc[1]).unwrap(); + assert_eq!(unsubscribe["params"][0], "remote-sub"); + } + + #[test] + fn request_login_accepts_valid_pairing_statement_and_persists_session() { + let session_writes = Arc::new(Mutex::new(Vec::new())); + let platform = Arc::new(StubPlatform { + pairing_success_response: true, + session_writes: session_writes.clone(), + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let mut statuses = host.session_state().subscribe(); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Disconnected + ) + ); + + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Success) + ); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Connected + ) + ); + + let session = host + .session_state() + .current() + .expect("paired session should be active"); + assert_eq!(session.public_key, session_info().public_key); + assert_eq!(session.root_entropy_source, Some([0x66; 32])); + assert_eq!( + session.sso.as_ref().unwrap().identity_account_id, + peer_statement_keypair().1 + ); + + let writes = session_writes + .lock() + .expect("session write list mutex poisoned"); + assert_eq!(writes.len(), 1); + assert_eq!( + crate::host_logic::session::decode_persisted_session(&writes[0]).unwrap(), + session + ); + + let auth_states = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"); + assert_eq!(auth_states.len(), 2, "states: {auth_states:?}"); + assert!(matches!(&auth_states[0], AuthState::Pairing { .. })); + assert_eq!( + auth_states[1], + AuthState::Connected(connected_session_ui_info(&session)) + ); + drop(auth_states); + + let methods = platform + .sent_rpc + .lock() + .expect("rpc list mutex poisoned") + .iter() + .map(|request| serde_json::from_str::(request).unwrap()) + .map(|request| request["method"].as_str().unwrap().to_string()) + .collect::>(); + assert_eq!( + methods.first().map(String::as_str), + Some("statement_subscribeStatement") + ); + assert!( + methods + .iter() + .any(|method| method == "statement_unsubscribeStatement"), + "pairing subscription should be cleaned up" + ); + } + + /// The pairing success must also be reachable through the core's own 2s + /// snapshot queries: the live subscription stays silent and the wallet + /// statement is delivered only on a query subscription page. + #[test] + fn request_login_accepts_pairing_statement_from_snapshot_query_page() { + let platform = Arc::new(StubPlatform { + pairing_success_via_query: true, + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Success) + ); + assert_eq!( + host.session_state() + .current() + .map(|session| session.public_key), + Some(session_info().public_key) + ); + + let methods = platform + .sent_rpc + .lock() + .expect("rpc list mutex poisoned") + .iter() + .map(|request| serde_json::from_str::(request).unwrap()) + .map(|request| request["method"].as_str().unwrap().to_string()) + .collect::>(); + assert!( + methods + .iter() + .filter(|method| method.as_str() == "statement_subscribeStatement") + .count() + >= 2, + "core should issue snapshot queries while pairing: {methods:?}" + ); + assert!( + methods + .iter() + .any(|method| method == "statement_unsubscribeStatement"), + "drained query subscription should be cleaned up: {methods:?}" + ); + } + + #[test] + fn request_login_emits_login_failed_for_pre_pairing_errors() { + let platform = Arc::new(StubPlatform { + local_storage_error: Some("identity storage unavailable"), + ..Default::default() + }); + let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let err = futures::executor::block_on(host.request_login(&cx, request)).unwrap_err(); + + assert!(matches!(err, CallError::Domain(_))); + let auth_states = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"); + assert_eq!(auth_states.len(), 1, "states: {auth_states:?}"); + assert!(matches!(&auth_states[0], AuthState::LoginFailed { reason } + if reason.contains("identity storage unavailable"))); + } + + #[test] + fn request_login_does_not_restore_persisted_session_before_pairing() { + let stored = session_info(); + let platform = Arc::new(StubPlatform { + session_blob: Some(crate::host_logic::session::encode_persisted_session( + &stored, + )), + ..Default::default() + }); + let host = Arc::new(PlatformRuntimeHost::new_compat( + platform.clone(), + test_spawner(), + )); + cancel_on_pairing(&platform, &host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + assert!(host.session_state().current().is_none()); + } + + #[test] + fn request_login_ignores_corrupt_persisted_session_before_pairing() { + let session_clears = Arc::new(Mutex::new(0)); + let platform = Arc::new(StubPlatform { + session_blob: Some(vec![0xff]), + session_clears: session_clears.clone(), + ..Default::default() + }); + let host = Arc::new(PlatformRuntimeHost::new_compat( + platform.clone(), + test_spawner(), + )); + cancel_on_pairing(&platform, &host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + assert!(host.session_state().current().is_none()); + assert_eq!(*session_clears.lock().unwrap(), 0); + } + + #[test] + fn request_login_ignores_session_store_failure_before_pairing() { + let platform = Arc::new(StubPlatform { + session_error: Some("storage failed"), + ..Default::default() + }); + let host = Arc::new(PlatformRuntimeHost::new_compat( + platform.clone(), + test_spawner(), + )); + cancel_on_pairing(&platform, &host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + assert!(host.session_state().current().is_none()); + } + + #[test] + fn request_login_returns_already_connected_when_session_exists() { + let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::AlreadyConnected) + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/sso_remote.rs b/rust/crates/truapi-server/src/runtime/sso_remote.rs new file mode 100644 index 00000000..68dc3d68 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/sso_remote.rs @@ -0,0 +1,509 @@ +//! SSO remote messaging over the people-chain statement store: submits an +//! encrypted request statement to the paired wallet and waits for the +//! matching response, honoring timeouts and local/peer disconnect signals. + +use core::mem; +use std::sync::Mutex; + +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; + +use super::PlatformRuntimeHost; +use super::statement_store_rpc; +use crate::host_logic::session::{SessionInfo, SsoSessionInfo}; +use crate::host_logic::sso::messages::{ + RemoteMessage, RemoteMessageData, RemoteMessageV1, SsoRemoteResponse, SsoSessionStatement, + build_outgoing_request_statement, decode_sso_session_statement, +}; +use crate::host_logic::statement_store::{current_unix_secs, parse_new_statements_result}; + +use futures::channel::oneshot; +use futures::future::BoxFuture; +use futures::stream::BoxStream; +use futures::{FutureExt, StreamExt, pin_mut}; +use serde_json::Value; +use subxt_rpcs::RpcClient; +use subxt_rpcs::client::RpcSubscription; +use tracing::{debug, instrument, warn}; +use truapi::CallContext; + +const DEFAULT_SSO_STATEMENT_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60; +const DEFAULT_SSO_RESPONSE_TIMEOUT: Duration = Duration::from_secs(180); +/// Disconnect reason reported when the local session logs out mid-request. +pub(super) const SSO_LOCAL_DISCONNECT_REASON: &str = "SSO session disconnected"; +/// Disconnect reason reported when the paired wallet announces a disconnect. +pub(super) const SSO_PEER_DISCONNECT_REASON: &str = "SSO peer disconnected"; + +/// Registry of oneshot waiters resolved when the SSO session disconnects. +#[derive(Default)] +pub(super) struct SessionDisconnects { + inner: Mutex, +} + +#[derive(Default)] +struct SessionDisconnectsInner { + next_id: u64, + waiters: Vec<(u64, oneshot::Sender)>, +} + +impl SessionDisconnects { + /// Register a waiter; returns its id and the disconnect-reason receiver. + pub(super) fn subscribe(&self) -> (u64, oneshot::Receiver) { + let (tx, rx) = oneshot::channel(); + let mut inner = self + .inner + .lock() + .expect("session disconnect mutex poisoned"); + inner.next_id = inner.next_id.wrapping_add(1); + let id = inner.next_id; + inner.waiters.push((id, tx)); + (id, rx) + } + + fn unsubscribe(&self, id: u64) { + self.inner + .lock() + .expect("session disconnect mutex poisoned") + .waiters + .retain(|(waiter_id, _)| *waiter_id != id); + } + + /// Resolve every pending waiter with `reason`. + pub(super) fn notify(&self, reason: &'static str) { + let waiters = { + let mut inner = self + .inner + .lock() + .expect("session disconnect mutex poisoned"); + mem::take(&mut inner.waiters) + }; + for (_, waiter) in waiters { + let _ = waiter.send(reason.to_string()); + } + } +} + +impl PlatformRuntimeHost { + /// Best-effort `Disconnected` notification to the SSO peer. + #[instrument(skip_all, fields(runtime.method = "sso.disconnect.submit"))] + pub(super) async fn submit_sso_disconnected( + &self, + session: &SessionInfo, + ) -> Result<(), String> { + let sso = session + .sso + .as_ref() + .ok_or_else(|| "No SSO session state".to_string())?; + let message_id = "truapi:sso:disconnect".to_string(); + let message = RemoteMessage { + message_id: message_id.clone(), + data: RemoteMessageData::V1(RemoteMessageV1::Disconnected), + }; + let statement = build_outgoing_request_statement( + sso, + message_id.clone(), + vec![message], + fresh_statement_expiry(), + )?; + self.statement_store_rpc() + .submit_fire_and_forget(statement, "SSO statement-store") + .await + .map_err(|err| format!("SSO statement submit failed: {err}"))?; + Ok(()) + } + + /// Submit an SSO remote message and wait for the wallet response with + /// the default timeout. + #[instrument(skip_all, fields(runtime.method = "sso.remote_message.submit", action = action))] + pub(super) async fn submit_sso_remote_message( + &self, + cx: &CallContext, + session: &SessionInfo, + action: &str, + message: RemoteMessage, + ) -> Result { + self.submit_sso_remote_message_with_timeout( + cx, + session, + action, + message, + Some(DEFAULT_SSO_RESPONSE_TIMEOUT), + ) + .await + } + + /// Submit an SSO remote message and wait for the wallet response without + /// a deadline (used for flows that block on user interaction). + #[instrument(skip_all, fields(runtime.method = "sso.remote_message.submit_without_timeout", action = action))] + pub(super) async fn submit_sso_remote_message_without_timeout( + &self, + cx: &CallContext, + session: &SessionInfo, + action: &str, + message: RemoteMessage, + ) -> Result { + self.submit_sso_remote_message_with_timeout(cx, session, action, message, None) + .await + } + + #[instrument(skip_all, fields(runtime.method = "sso.remote_message.submit_with_timeout", action = action))] + async fn submit_sso_remote_message_with_timeout( + &self, + cx: &CallContext, + session: &SessionInfo, + action: &str, + message: RemoteMessage, + timeout: Option, + ) -> Result { + let sso = session + .sso + .as_ref() + .ok_or_else(|| "No SSO session state".to_string())?; + let message_id = sso_message_id(cx, action); + let statement = build_outgoing_request_statement( + sso, + message_id.clone(), + vec![message], + fresh_statement_expiry(), + )?; + let rpc_client = self + .statement_store_rpc() + .client("SSO statement-store") + .await?; + let own_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_own) + .await + .map_err(|err| format!("SSO own statement-store subscribe failed: {err}"))?; + let peer_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_peer) + .await + .map_err(|err| format!("SSO peer statement-store subscribe failed: {err}"))?; + let submit_client = rpc_client.clone(); + let submit = async move { statement_store_rpc::submit(&submit_client, statement).await } + .map(|result| result.map_err(|err| format!("SSO statement submit failed: {err}"))) + .boxed(); + debug!(action, %message_id, "submitted SSO remote message, awaiting response"); + let (disconnect_waiter_id, disconnect) = self.session_disconnects.subscribe(); + let result = wait_for_sso_remote_response( + statement_subscription_stream(own_subscription, "own"), + statement_subscription_stream(peer_subscription, "peer"), + submit, + SsoRemoteResponseWait { + session: sso, + statement_request_id: &message_id, + remote_message_id: &message_id, + timeout, + disconnect: Some(disconnect), + }, + ) + .await; + self.session_disconnects.unsubscribe(disconnect_waiter_id); + match &result { + Ok(_) => debug!(action, %message_id, "SSO remote response received"), + Err(reason) => warn!(action, %message_id, %reason, "SSO remote message failed"), + } + if matches!(&result, Err(reason) if reason == SSO_PEER_DISCONNECT_REASON) { + self.session_disconnects.notify(SSO_PEER_DISCONNECT_REASON); + self.clear_disconnected_session().await; + } + result + } +} + +struct SsoRemoteResponseWait<'a> { + session: &'a SsoSessionInfo, + statement_request_id: &'a str, + remote_message_id: &'a str, + timeout: Option, + disconnect: Option>, +} + +type StatementPageStream = BoxStream<'static, Result>; +type StatementSubmitFuture = BoxFuture<'static, Result<(), String>>; + +#[instrument(skip_all, fields(runtime.method = "sso.remote_response.wait"))] +async fn wait_for_sso_remote_response( + own_statements: StatementPageStream, + peer_statements: StatementPageStream, + submit: StatementSubmitFuture, + wait: SsoRemoteResponseWait<'_>, +) -> Result { + let timeout_reason = wait.timeout.map(|timeout| { + format!( + "SSO response timed out after {} for {}", + format_timeout_duration(timeout), + wait.remote_message_id + ) + }); + let response = wait_for_sso_remote_response_inner( + own_statements, + peer_statements, + submit, + wait.session, + wait.statement_request_id, + wait.remote_message_id, + ) + .fuse(); + let timeout = async move { + match (wait.timeout, timeout_reason) { + (Some(timeout), Some(reason)) => { + futures_timer::Delay::new(timeout).await; + reason + } + _ => futures::future::pending::().await, + } + } + .fuse(); + let disconnect = async move { + match wait.disconnect { + Some(rx) => rx + .await + .unwrap_or_else(|_| SSO_LOCAL_DISCONNECT_REASON.to_string()), + None => futures::future::pending::().await, + } + } + .fuse(); + pin_mut!(response, timeout, disconnect); + futures::select! { + result = response => result, + reason = timeout => Err(reason), + reason = disconnect => Err(reason), + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.remote_response.wait_inner"))] +async fn wait_for_sso_remote_response_inner( + own_statements: StatementPageStream, + peer_statements: StatementPageStream, + submit: StatementSubmitFuture, + session: &SsoSessionInfo, + statement_request_id: &str, + remote_message_id: &str, +) -> Result { + let mut own_statements = own_statements.fuse(); + let mut peer_statements = peer_statements.fuse(); + let mut submit = submit.fuse(); + let mut own_done = false; + let mut peer_done = false; + let mut request_accepted = false; + let mut pending_remote_response = None; + + loop { + if own_done && peer_done { + return Err(format!( + "SSO response stream ended before response for {}", + remote_message_id + )); + } + futures::select! { + item = own_statements.next() => { + match item { + Some(Ok(value)) => { + if let Some(response) = handle_sso_remote_statement_page( + session, + &value, + statement_request_id, + remote_message_id, + &mut request_accepted, + &mut pending_remote_response, + )? { + return Ok(response); + } + } + Some(Err(reason)) => return Err(reason), + None => own_done = true, + } + } + item = peer_statements.next() => { + match item { + Some(Ok(value)) => { + if let Some(response) = handle_sso_remote_statement_page( + session, + &value, + statement_request_id, + remote_message_id, + &mut request_accepted, + &mut pending_remote_response, + )? { + return Ok(response); + } + } + Some(Err(reason)) => return Err(reason), + None => peer_done = true, + } + } + submit_result = submit => { + submit_result?; + } + } + } +} + +fn handle_sso_remote_statement_page( + session: &SsoSessionInfo, + value: &Value, + statement_request_id: &str, + remote_message_id: &str, + request_accepted: &mut bool, + pending_remote_response: &mut Option, +) -> Result, String> { + let page = parse_new_statements_result("sso-remote".to_string(), value) + .map_err(|err| err.to_string())?; + for statement in page.statements { + match decode_sso_session_statement( + session, + &statement, + statement_request_id, + remote_message_id, + )? { + Some(SsoSessionStatement::RequestAccepted) => { + *request_accepted = true; + if let Some(response) = pending_remote_response.take() { + return Ok(Some(response)); + } + } + Some(SsoSessionStatement::RemoteResponse(response)) => { + if *request_accepted { + return Ok(Some(response)); + } + *pending_remote_response = Some(response); + } + Some(SsoSessionStatement::Disconnected) => { + return Err(SSO_PEER_DISCONNECT_REASON.to_string()); + } + None => {} + } + } + Ok(None) +} + +async fn subscribe_statement_topic( + rpc_client: &RpcClient, + topic: [u8; 32], +) -> Result, subxt_rpcs::Error> { + statement_store_rpc::subscribe_match_all(rpc_client, &[topic]).await +} + +fn statement_subscription_stream( + subscription: RpcSubscription, + label: &'static str, +) -> StatementPageStream { + subscription + .map(move |item| item.map_err(|err| format!("SSO {label} subscription failed: {err}"))) + .boxed() +} + +fn format_timeout_duration(duration: Duration) -> String { + if duration.subsec_millis() == 0 { + format!("{}s", duration.as_secs()) + } else { + format!("{}ms", duration.as_millis()) + } +} + +/// Stable message id for an SSO request: the wire request id when present, +/// otherwise a fixed per-action fallback. +pub(super) fn sso_message_id(cx: &CallContext, action: &str) -> String { + if cx.request_id().is_empty() { + format!("truapi:sso:{action}") + } else { + cx.request_id().to_string() + } +} + +fn fresh_statement_expiry() -> u64 { + let timestamp = current_unix_secs().saturating_add(DEFAULT_SSO_STATEMENT_EXPIRY_SECS); + timestamp << 32 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::sso_session_info; + use futures::stream; + + #[test] + fn sso_remote_response_waiter_times_out() { + let session = sso_session_info(); + let err = futures::executor::block_on(wait_for_sso_remote_response( + stream::pending().boxed(), + stream::pending().boxed(), + futures::future::pending().boxed(), + SsoRemoteResponseWait { + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + timeout: Some(Duration::from_millis(1)), + disconnect: None, + }, + )) + .unwrap_err(); + + assert_eq!(err, "SSO response timed out after 1ms for request-1"); + } + + #[test] + fn sso_remote_response_waiter_reports_submit_rejections() { + let session = sso_session_info(); + let err = futures::executor::block_on(wait_for_sso_remote_response( + stream::pending().boxed(), + stream::pending().boxed(), + futures::future::ready(Err("SSO statement submit failed: no allowance".to_string())) + .boxed(), + SsoRemoteResponseWait { + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + timeout: Some(Duration::from_secs(60)), + disconnect: None, + }, + )) + .unwrap_err(); + + assert_eq!(err, "SSO statement submit failed: no allowance"); + } + + #[test] + fn sso_remote_response_waiter_stops_on_local_disconnect_signal() { + let session = sso_session_info(); + let (tx, rx) = oneshot::channel(); + tx.send(SSO_LOCAL_DISCONNECT_REASON.to_string()).unwrap(); + let err = futures::executor::block_on(wait_for_sso_remote_response( + stream::pending().boxed(), + stream::pending().boxed(), + futures::future::pending().boxed(), + SsoRemoteResponseWait { + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + timeout: Some(Duration::from_secs(60)), + disconnect: Some(rx), + }, + )) + .unwrap_err(); + + assert_eq!(err, SSO_LOCAL_DISCONNECT_REASON); + } + + #[test] + fn sso_remote_response_waiter_without_timeout_stops_on_local_disconnect_signal() { + let session = sso_session_info(); + let (tx, rx) = oneshot::channel(); + tx.send(SSO_LOCAL_DISCONNECT_REASON.to_string()).unwrap(); + let err = futures::executor::block_on(wait_for_sso_remote_response( + stream::pending().boxed(), + stream::pending().boxed(), + futures::future::pending().boxed(), + SsoRemoteResponseWait { + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + timeout: None, + disconnect: Some(rx), + }, + )) + .unwrap_err(); + + assert_eq!(err, SSO_LOCAL_DISCONNECT_REASON); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs new file mode 100644 index 00000000..c4833559 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -0,0 +1,660 @@ +//! `StatementStore` surface: session-key statement proofs plus submit and +//! subscribe flows over the people-chain statement store. + +use core::pin::Pin; +use core::task::{Context, Poll}; + +use super::PlatformRuntimeHost; +use super::statement_store_rpc::{self, StatementStoreRpc}; +use crate::host_logic::statement_store::{ + MAX_MATCH_ALL_TOPICS, MAX_MATCH_ANY_TOPICS, TopicFilterKind, decode_signed_statement, + parse_new_statements_result, sign_statement_fields, signed_statement_to_scale, + statement_fields_from_v01, statement_proof_to_v01, +}; + +use serde_json::Value; +use subxt_rpcs::client::RpcSubscription; +use tracing::instrument; +use truapi::api::StatementStore; +use truapi::v01; +use truapi::versioned::statement_store::{ + RemoteStatementStoreCreateProofAuthorizedError, + RemoteStatementStoreCreateProofAuthorizedRequest, + RemoteStatementStoreCreateProofAuthorizedResponse, RemoteStatementStoreCreateProofError, + RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, + RemoteStatementStoreSubmitError, RemoteStatementStoreSubmitRequest, + RemoteStatementStoreSubscribeError, RemoteStatementStoreSubscribeItem, + RemoteStatementStoreSubscribeRequest, +}; +use truapi::{CallContext, CallError, Subscription}; + +impl StatementStore for PlatformRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "statement_store.subscribe"))] + async fn subscribe( + &self, + _cx: &CallContext, + request: RemoteStatementStoreSubscribeRequest, + ) -> Result< + Subscription, + CallError, + > { + let (kind, topics) = match statement_store_topic_filter(request) { + Ok(value) => value, + Err(reason) => { + return Err(CallError::Domain(RemoteStatementStoreSubscribeError::V1( + v01::GenericError { reason }, + ))); + } + }; + let statement_store = self.statement_store_rpc(); + let rpc_client = statement_store + .client("statement-store") + .await + .map_err(|reason| { + CallError::Domain(RemoteStatementStoreSubscribeError::V1(v01::GenericError { + reason, + })) + })?; + let subscription = statement_store_rpc::subscribe(&rpc_client, kind, &topics) + .await + .map_err(|err| { + CallError::Domain(RemoteStatementStoreSubscribeError::V1(v01::GenericError { + reason: format!("statement-store subscribe failed: {err}"), + })) + })?; + let Some(remote_subscription_id) = subscription.subscription_id().map(ToString::to_string) + else { + return Err(CallError::Domain(RemoteStatementStoreSubscribeError::V1( + v01::GenericError { + reason: "statement-store subscribe returned no subscription id".to_string(), + }, + ))); + }; + let stream = statement_store_subscription_stream(subscription, remote_subscription_id); + Ok(Subscription::new(Box::pin(stream))) + } + + #[instrument(skip_all, fields(runtime.method = "statement_store.create_proof"))] + async fn create_proof( + &self, + _cx: &CallContext, + request: RemoteStatementStoreCreateProofRequest, + ) -> Result< + RemoteStatementStoreCreateProofResponse, + CallError, + > { + let RemoteStatementStoreCreateProofRequest::V1(mut inner) = request; + inner.product_account_id = Self::normalize_product_account_id(inner.product_account_id); + if !self.is_product_account_valid_for_caller(&inner.product_account_id.dot_ns_identifier) { + return Err(CallError::Domain(RemoteStatementStoreCreateProofError::V1( + v01::RemoteStatementStoreCreateProofError::UnknownAccount, + ))); + } + let proof = self + .create_statement_proof(inner.statement) + .map_err(statement_proof_error)?; + Ok(RemoteStatementStoreCreateProofResponse::V1( + v01::RemoteStatementStoreCreateProofResponse { proof }, + )) + } + + #[instrument(skip_all, fields(runtime.method = "statement_store.create_proof_authorized"))] + async fn create_proof_authorized( + &self, + _cx: &CallContext, + request: RemoteStatementStoreCreateProofAuthorizedRequest, + ) -> Result< + RemoteStatementStoreCreateProofAuthorizedResponse, + CallError, + > { + let RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement) = request; + let proof = self + .create_statement_proof(statement) + .map_err(statement_proof_authorized_error)?; + Ok(RemoteStatementStoreCreateProofAuthorizedResponse::V1( + v01::RemoteStatementStoreCreateProofResponse { proof }, + )) + } + + #[instrument(skip_all, fields(runtime.method = "statement_store.submit"))] + async fn submit( + &self, + _cx: &CallContext, + request: RemoteStatementStoreSubmitRequest, + ) -> Result<(), CallError> { + let RemoteStatementStoreSubmitRequest::V1(statement) = request; + let statement = signed_statement_to_scale(statement).map_err(|reason| { + CallError::Domain(RemoteStatementStoreSubmitError::V1(v01::GenericError { + reason, + })) + })?; + self.statement_store_rpc() + .submit(statement, "statement-store") + .await + .map_err(|reason| { + CallError::Domain(RemoteStatementStoreSubmitError::V1(v01::GenericError { + reason: format!("statement-store submit failed: {reason}"), + })) + }) + } +} + +fn statement_store_topic_filter( + request: RemoteStatementStoreSubscribeRequest, +) -> Result<(TopicFilterKind, Vec<[u8; 32]>), String> { + match request { + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAll(topics), + ) => { + if topics.len() > MAX_MATCH_ALL_TOPICS { + return Err(format!( + "MatchAll has {} topics, maximum is {}", + topics.len(), + MAX_MATCH_ALL_TOPICS + )); + } + Ok((TopicFilterKind::MatchAll, topics)) + } + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(topics), + ) => { + if topics.len() > MAX_MATCH_ANY_TOPICS { + return Err(format!( + "MatchAny has {} topics, maximum is {}", + topics.len(), + MAX_MATCH_ANY_TOPICS + )); + } + Ok((TopicFilterKind::MatchAny, topics)) + } + } +} + +#[instrument(skip_all, fields(runtime.method = "statement_store.subscription_stream"))] +fn statement_store_subscription_stream( + subscription: RpcSubscription, + remote_subscription_id: String, +) -> impl futures::Stream + Send { + StatementStoreSubscriptionStream { + subscription, + remote_subscription_id, + is_complete: false, + } +} + +struct StatementStoreSubscriptionStream { + subscription: RpcSubscription, + remote_subscription_id: String, + is_complete: bool, +} + +impl futures::Stream for StatementStoreSubscriptionStream { + type Item = RemoteStatementStoreSubscribeItem; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let state = self.get_mut(); + loop { + let value = match Pin::new(&mut state.subscription).poll_next(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Some(Ok(value))) => value, + Poll::Ready(Some(Err(_))) | Poll::Ready(None) => { + return Poll::Ready(None); + } + }; + let page = + match parse_new_statements_result(state.remote_subscription_id.clone(), &value) { + Ok(page) => page, + Err(_) => continue, + }; + + let was_complete = state.is_complete; + let is_complete = was_complete || page.remaining == Some(0); + state.is_complete = is_complete; + let statements = page + .statements + .into_iter() + .filter_map(|statement| decode_signed_statement(&statement).ok()) + .collect::>(); + if statements.is_empty() { + if is_complete && !was_complete { + return Poll::Ready(Some(RemoteStatementStoreSubscribeItem::V1( + v01::RemoteStatementStoreSubscribeItem { + statements, + is_complete, + }, + ))); + } + continue; + } + + return Poll::Ready(Some(RemoteStatementStoreSubscribeItem::V1( + v01::RemoteStatementStoreSubscribeItem { + statements, + is_complete, + }, + ))); + } + } +} + +impl PlatformRuntimeHost { + /// `StatementStoreRpc` bound to this runtime's people chain. + pub(super) fn statement_store_rpc(&self) -> StatementStoreRpc { + StatementStoreRpc::new( + self.platform.clone(), + self.runtime_config.people_chain_genesis_hash, + self.spawner.clone(), + ) + } + + fn create_statement_proof( + &self, + statement: v01::Statement, + ) -> Result { + let session = self + .session_state + .current() + .ok_or(StatementProofFailure::NoSession)?; + let sso = session + .sso + .as_ref() + .ok_or(StatementProofFailure::NoSession)?; + let fields = statement_fields_from_v01(statement) + .map_err(StatementProofFailure::InvalidStatement)?; + let signed = sign_statement_fields(sso.ss_secret, sso.ss_public_key, fields) + .map_err(StatementProofFailure::UnableToSign)?; + signed + .into_iter() + .find_map(|field| match field { + crate::host_logic::statement_store::StatementField::Proof(proof) => { + Some(statement_proof_to_v01(proof)) + } + _ => None, + }) + .ok_or_else(|| StatementProofFailure::UnableToSign("missing proof".to_string())) + } +} + +enum StatementProofFailure { + NoSession, + InvalidStatement(String), + UnableToSign(String), +} + +fn statement_proof_v01_error( + failure: StatementProofFailure, +) -> v01::RemoteStatementStoreCreateProofError { + match failure { + StatementProofFailure::NoSession => v01::RemoteStatementStoreCreateProofError::UnableToSign, + StatementProofFailure::UnableToSign(_reason) => { + v01::RemoteStatementStoreCreateProofError::UnableToSign + } + StatementProofFailure::InvalidStatement(reason) => { + v01::RemoteStatementStoreCreateProofError::Unknown { reason } + } + } +} + +fn statement_proof_error( + failure: StatementProofFailure, +) -> CallError { + CallError::Domain(RemoteStatementStoreCreateProofError::V1( + statement_proof_v01_error(failure), + )) +} + +fn statement_proof_authorized_error( + failure: StatementProofFailure, +) -> CallError { + CallError::Domain(RemoteStatementStoreCreateProofAuthorizedError::V1( + statement_proof_v01_error(failure), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{ + StubPlatform, account_id, new_statements_frame, runtime_config, signed_statement, + sso_session_info, statement, stub_platform, subscribe_ack_frame, test_spawner, + }; + use futures::StreamExt; + use parity_scale_codec::Encode; + use std::sync::Arc; + + #[test] + fn statement_store_create_proof_signs_with_session_key() { + let host = + PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let session = sso_session_info(); + let expected_signer = session.sso.as_ref().unwrap().ss_public_key; + host.session_state().set_session(session); + let cx = CallContext::new(); + let request = RemoteStatementStoreCreateProofRequest::V1( + v01::RemoteStatementStoreCreateProofRequest { + product_account_id: account_id("myapp.dot", 0), + statement: statement(), + }, + ); + + let response = + futures::executor::block_on(StatementStore::create_proof(&host, &cx, request)).unwrap(); + + let RemoteStatementStoreCreateProofResponse::V1(inner) = response; + let v01::StatementProof::Sr25519 { signer, signature } = inner.proof else { + panic!("expected sr25519 statement proof"); + }; + assert_eq!(signer, expected_signer); + assert_ne!(signature, [0; 64]); + } + + #[test] + fn statement_store_create_proof_rejects_wrong_product_account() { + let host = + PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.session_state().set_session(sso_session_info()); + let cx = CallContext::new(); + let request = RemoteStatementStoreCreateProofRequest::V1( + v01::RemoteStatementStoreCreateProofRequest { + product_account_id: account_id("other.dot", 0), + statement: statement(), + }, + ); + + let err = futures::executor::block_on(StatementStore::create_proof(&host, &cx, request)) + .unwrap_err(); + + assert!(matches!( + err, + CallError::Domain(RemoteStatementStoreCreateProofError::V1( + v01::RemoteStatementStoreCreateProofError::UnknownAccount + )) + )); + } + + #[test] + fn statement_store_create_proof_authorized_signs_with_session_key() { + let host = + PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let session = sso_session_info(); + let expected_signer = session.sso.as_ref().unwrap().ss_public_key; + host.session_state().set_session(session); + let cx = CallContext::new(); + let request = RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement()); + + let response = futures::executor::block_on(StatementStore::create_proof_authorized( + &host, &cx, request, + )) + .unwrap(); + + let RemoteStatementStoreCreateProofAuthorizedResponse::V1(inner) = response; + let v01::StatementProof::Sr25519 { signer, .. } = inner.proof else { + panic!("expected sr25519 statement proof"); + }; + assert_eq!(signer, expected_signer); + } + + #[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()], + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("submit-1".to_string()); + let request = RemoteStatementStoreSubmitRequest::V1(signed_statement([7; 32])); + + futures::executor::block_on(StatementStore::submit(&host, &cx, request)).unwrap(); + + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + assert_eq!(sent.len(), 1); + let request: serde_json::Value = serde_json::from_str(&sent[0]).unwrap(); + assert_eq!(request["method"], "statement_submit"); + let statement_hex = request["params"][0].as_str().unwrap(); + let statement = + hex::decode(statement_hex.strip_prefix("0x").unwrap_or(statement_hex)).unwrap(); + assert_eq!( + crate::host_logic::statement_store::decode_signed_statement(&statement).unwrap(), + signed_statement([7; 32]) + ); + } + + #[test] + fn statement_store_subscribe_maps_signed_pages() { + let signed = crate::host_logic::statement_store::signed_statement_to_scale( + signed_statement([7; 32]), + ) + .unwrap(); + let unsigned = vec![crate::host_logic::statement_store::StatementField::Data( + vec![1], + )] + .encode(); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + subscribe_ack_frame("truapi:1", "remote-sub"), + new_statements_frame("remote-sub", vec![unsigned, signed]), + ], + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("sub-1".to_string()); + let mut subscription = futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + ), + )) + .unwrap(); + + let item = futures::executor::block_on(subscription.next()).expect("statement page"); + + let RemoteStatementStoreSubscribeItem::V1(inner) = item; + assert!(inner.is_complete); + assert_eq!(inner.statements, vec![signed_statement([7; 32])]); + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + let request: serde_json::Value = serde_json::from_str(&sent[0]).unwrap(); + assert_eq!(request["method"], "statement_subscribeStatement"); + assert_eq!( + request["params"][0]["matchAny"][0], + "0x0707070707070707070707070707070707070707070707070707070707070707" + ); + } + + /// Pages that arrive before the subscribe ack are buffered by remote + /// subscription id and replayed once the ack confirms the subscription. + #[test] + fn statement_store_subscribe_buffers_pages_before_subscribe_ack() { + let rogue = crate::host_logic::statement_store::signed_statement_to_scale( + signed_statement([9; 32]), + ) + .unwrap(); + let signed = crate::host_logic::statement_store::signed_statement_to_scale( + signed_statement([7; 32]), + ) + .unwrap(); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + new_statements_frame("remote-sub-pre", vec![rogue]), + subscribe_ack_frame("truapi:1", "remote-sub-pre"), + new_statements_frame("remote-sub-pre", vec![signed]), + ], + ..Default::default() + }); + let host = PlatformRuntimeHost::new(platform, runtime_config("myapp.dot"), test_spawner()); + let cx = CallContext::with_request_id("sub-pre".to_string()); + let mut subscription = futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + ), + )) + .unwrap(); + + let item = futures::executor::block_on(subscription.next()).expect("statement page"); + + assert_eq!( + item, + RemoteStatementStoreSubscribeItem::V1(v01::RemoteStatementStoreSubscribeItem { + statements: vec![signed_statement([9; 32])], + is_complete: true, + }) + ); + } + + #[test] + fn statement_store_subscribe_unsubscribes_remote_subscription_on_drop() { + let signed = crate::host_logic::statement_store::signed_statement_to_scale( + signed_statement([7; 32]), + ) + .unwrap(); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + subscribe_ack_frame("truapi:1", "remote-sub-drop"), + new_statements_frame("remote-sub-drop", vec![signed]), + ], + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("sub-drop".to_string()); + let mut subscription = futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + ), + )) + .unwrap(); + + let _ = futures::executor::block_on(subscription.next()).expect("statement page"); + drop(subscription); + + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + assert_eq!(sent.len(), 2); + let unsubscribe: serde_json::Value = serde_json::from_str(&sent[1]).unwrap(); + assert_eq!(unsubscribe["method"], "statement_unsubscribeStatement"); + assert_eq!(unsubscribe["params"][0], "remote-sub-drop"); + } + + #[test] + fn statement_store_subscribe_emits_empty_completion_page_after_filtering() { + let unsigned = vec![crate::host_logic::statement_store::StatementField::Data( + vec![1], + )] + .encode(); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + subscribe_ack_frame("truapi:1", "remote-sub-empty"), + new_statements_frame("remote-sub-empty", vec![unsigned]), + ], + ..Default::default() + }); + let host = PlatformRuntimeHost::new(platform, runtime_config("myapp.dot"), test_spawner()); + let cx = CallContext::with_request_id("sub-empty-complete".to_string()); + let mut subscription = futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + ), + )) + .unwrap(); + + let item = futures::executor::block_on(subscription.next()).expect("completion page"); + + let RemoteStatementStoreSubscribeItem::V1(inner) = item; + assert!(inner.is_complete); + assert!(inner.statements.is_empty()); + } + + #[test] + fn statement_store_subscribe_rejects_topic_limit_violations() { + let platform = stub_platform(); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("sub-too-many".to_string()); + let topics = vec![[7; 32]; MAX_MATCH_ANY_TOPICS + 1]; + + let err = match futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(topics), + ), + )) { + Ok(_) => panic!("topic limit violation should fail subscription start"), + Err(err) => err, + }; + + let CallError::Domain(RemoteStatementStoreSubscribeError::V1(reason)) = err else { + panic!("expected statement-store subscribe domain error"); + }; + assert_eq!( + reason.reason, + format!( + "MatchAny has {} topics, maximum is {}", + MAX_MATCH_ANY_TOPICS + 1, + MAX_MATCH_ANY_TOPICS + ) + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } + + #[test] + fn statement_store_subscribe_reports_chain_connect_failure() { + let platform = Arc::new(StubPlatform { + chain_connect_error: Some("chain unavailable"), + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("sub-connect-fail".to_string()); + + let err = match futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + ), + )) { + Ok(_) => panic!("chain connect failure should fail subscription start"), + Err(err) => err, + }; + + let CallError::Domain(RemoteStatementStoreSubscribeError::V1(reason)) = err else { + panic!("expected statement-store subscribe domain error"); + }; + assert!( + reason + .reason + .contains("statement-store connect failed: GenericError"), + "unexpected reason: {}", + reason.reason + ); + assert!( + reason.reason.contains("chain unavailable"), + "unexpected reason: {}", + reason.reason + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs new file mode 100644 index 00000000..508ae8a4 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs @@ -0,0 +1,139 @@ +//! Runtime helper for People-chain statement-store JSON-RPC. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use subxt_rpcs::RpcClient; +use subxt_rpcs::client::{RpcSubscription, rpc_params}; +use truapi_platform::{JsonRpcConnection, Platform}; + +use crate::host_logic::statement_store::{ + SUBMIT_STATEMENT_METHOD, SUBSCRIBE_STATEMENT_METHOD, TopicFilterKind, + UNSUBSCRIBE_STATEMENT_METHOD, hex_topic, +}; +use crate::host_rpc_client::HostRpcClient; +use crate::subscription::Spawner; + +/// People-chain statement-store RPC client factory. +#[derive(Clone)] +pub(super) struct StatementStoreRpc { + platform: Arc, + people_chain_genesis_hash: [u8; 32], + spawner: Spawner, +} + +impl StatementStoreRpc { + /// Build a helper backed by the platform-owned chain provider. + pub(super) fn new( + platform: Arc, + people_chain_genesis_hash: [u8; 32], + spawner: Spawner, + ) -> Self { + Self { + platform, + people_chain_genesis_hash, + spawner, + } + } + + /// Open a statement-store RPC client over the host-provided People-chain + /// connection. + pub(super) async fn client(&self, label: &'static str) -> Result { + let connection = self.connect(label).await?; + Ok(RpcClient::new(HostRpcClient::new( + connection, + self.spawner.clone(), + ))) + } + + /// Submit a SCALE-encoded statement and wait for the JSON-RPC ack. + pub(super) async fn submit( + &self, + statement: Vec, + label: &'static str, + ) -> Result<(), String> { + let rpc_client = self.client(label).await?; + submit(&rpc_client, statement).await + } + + /// Submit a SCALE-encoded statement without waiting for the JSON-RPC ack. + pub(super) async fn submit_fire_and_forget( + &self, + statement: Vec, + label: &'static str, + ) -> Result<(), String> { + let connection = self.connect(label).await?; + HostRpcClient::new(connection, self.spawner.clone()) + .send_fire_and_forget(SUBMIT_STATEMENT_METHOD, statement_submit_params(statement)) + .map_err(rpc_error_message) + } + + async fn connect(&self, label: &'static str) -> Result, String> { + self.platform + .connect(self.people_chain_genesis_hash.to_vec()) + .await + .map(Arc::from) + .map_err(|err| format!("{label} connect failed: {err:?}")) + } +} + +/// Subscribe to statements matching the requested topic filter. +pub(super) async fn subscribe( + rpc_client: &RpcClient, + kind: TopicFilterKind, + topics: &[[u8; 32]], +) -> Result, subxt_rpcs::Error> { + rpc_client + .subscribe::( + SUBSCRIBE_STATEMENT_METHOD, + rpc_params![filter(kind, topics)], + UNSUBSCRIBE_STATEMENT_METHOD, + ) + .await +} + +/// Subscribe to statements matching every topic. +pub(super) async fn subscribe_match_all( + rpc_client: &RpcClient, + topics: &[[u8; 32]], +) -> Result, subxt_rpcs::Error> { + subscribe(rpc_client, TopicFilterKind::MatchAll, topics).await +} + +/// Submit a SCALE-encoded statement and wait for the JSON-RPC ack. +pub(super) async fn submit(rpc_client: &RpcClient, statement: Vec) -> Result<(), String> { + rpc_client + .request::( + SUBMIT_STATEMENT_METHOD, + rpc_params![statement_hex(&statement)], + ) + .await + .map(|_| ()) + .map_err(rpc_error_message) +} + +/// Statement-store topic filter encoded as JSON-RPC params. +pub(super) fn filter(kind: TopicFilterKind, topics: &[[u8; 32]]) -> Value { + let topics = topics.iter().map(hex_topic).collect::>(); + match kind { + TopicFilterKind::MatchAll => json!({ "matchAll": topics }), + TopicFilterKind::MatchAny => json!({ "matchAny": topics }), + } +} + +/// Human-readable JSON-RPC error message, preserving user error text when +/// provided by the remote endpoint. +pub(super) fn rpc_error_message(error: subxt_rpcs::Error) -> String { + match error { + subxt_rpcs::Error::User(error) => error.message, + other => other.to_string(), + } +} + +fn statement_submit_params(statement: Vec) -> Option> { + rpc_params![statement_hex(&statement)].build() +} + +fn statement_hex(statement: &[u8]) -> String { + format!("0x{}", hex::encode(statement)) +} diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs new file mode 100644 index 00000000..794eed56 --- /dev/null +++ b/rust/crates/truapi-server/src/test_support.rs @@ -0,0 +1,1075 @@ +//! Shared fixtures for the runtime test modules: a stub platform, a +//! recording json-rpc connection, and SSO statement/frame builders. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; + +use crate::subscription::Spawner; +#[cfg(not(target_arch = "wasm32"))] +use crate::subscription::thread_per_subscription_spawner; + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use futures::stream::{self, BoxStream}; +use hkdf::Hkdf; +use p256::PublicKey as P256PublicKey; +use p256::SecretKey as P256SecretKey; +use p256::ecdh::diffie_hellman; +use p256::elliptic_curve::sec1::ToEncodedPoint; +use parity_scale_codec::{Decode, Encode}; +use schnorrkel::{ExpansionMode, MiniSecretKey}; +use sha2::Sha256; +use truapi::v01; +use truapi::versioned::account::HostAccountGetAliasRequest; +use truapi::versioned::resource_allocation::HostRequestResourceAllocationRequest; +use truapi_platform::{ + AuthPresenter, AuthState, ChainProvider, CoreStorage as PlatformCoreStorage, CoreStorageKey, + Features as PlatformFeatures, HostInfo, JsonRpcConnection, Navigation as PlatformNavigation, + Notifications as PlatformNotifications, Permissions as PlatformPermissions, PlatformInfo, + PreimageHost, ProductStorage as PlatformProductStorage, RuntimeConfig, ThemeHost, + UserConfirmation, UserConfirmationReview, +}; + +/// Test spawner that matches the current target. +pub(crate) fn test_spawner() -> Spawner { + #[cfg(not(target_arch = "wasm32"))] + { + thread_per_subscription_spawner() + } + #[cfg(target_arch = "wasm32")] + { + immediate_spawner() + } +} + +#[allow(dead_code)] +/// Synchronous spawner for tests that should complete work immediately. +pub(crate) fn immediate_spawner() -> Spawner { + Arc::new(futures::executor::block_on) +} + +/// Test hook invoked after each recorded auth state. +pub(crate) type AuthStateHook = Arc; + +/// Minimal Platform impl that only answers `feature_supported`. Every +/// other callback returns a unit value or empty stream, so the runtime +/// can exercise its delegation paths without pulling in a real backend. +pub(crate) struct StubPlatform { + pub(crate) remote_permission_granted: bool, + pub(crate) account_alias_confirmed: bool, + pub(crate) account_alias_error: Option<&'static str>, + pub(crate) sign_payload_confirmed: bool, + pub(crate) sign_payload_error: Option<&'static str>, + pub(crate) sign_raw_confirmed: bool, + pub(crate) sign_raw_error: Option<&'static str>, + pub(crate) create_transaction_confirmed: bool, + pub(crate) create_transaction_error: Option<&'static str>, + pub(crate) resource_allocation_confirmed: bool, + pub(crate) resource_allocation_error: Option<&'static str>, + pub(crate) session_blob: Option>, + pub(crate) session_error: Option<&'static str>, + pub(crate) session_clears: Arc>, + pub(crate) session_writes: Arc>>>, + /// Every `auth_state_changed` emission in order. + pub(crate) auth_states: Arc>>, + /// Invoked after each recorded auth state, outside any stub lock, so a + /// test can react to a transition (e.g. cancel the login it observes). + pub(crate) on_auth_state: Arc>>, + /// Set when a `chain_connect_pending` connect future is dropped, which is + /// how a dropped login flow manifests on the stub. + pub(crate) pending_connect_dropped: Arc, + pub(crate) pairing_success_response: bool, + /// Deliver the pairing success statement only through a snapshot + /// query page; the live subscription stays silent. + pub(crate) pairing_success_via_query: bool, + pub(crate) notification_id: v01::NotificationId, + pub(crate) pushed_notifications: Arc>>, + pub(crate) cancelled_notifications: Arc>>, + pub(crate) sent_rpc: Arc>>, + pub(crate) rpc_responses: Vec, + pub(crate) chain_connect_error: Option<&'static str>, + pub(crate) chain_connect_pending: bool, + pub(crate) local_storage: Arc>>>, + /// When set, product/core storage reads fail with this reason. + pub(crate) local_storage_error: Option<&'static str>, +} + +impl Default for StubPlatform { + fn default() -> Self { + Self { + remote_permission_granted: true, + account_alias_confirmed: false, + account_alias_error: None, + sign_payload_confirmed: false, + sign_payload_error: None, + sign_raw_confirmed: false, + sign_raw_error: None, + create_transaction_confirmed: false, + create_transaction_error: None, + resource_allocation_confirmed: false, + resource_allocation_error: None, + session_blob: None, + session_error: None, + session_clears: Arc::new(Mutex::new(0)), + session_writes: Arc::new(Mutex::new(Vec::new())), + auth_states: Arc::new(Mutex::new(Vec::new())), + on_auth_state: Arc::new(Mutex::new(None)), + pending_connect_dropped: Arc::new(AtomicBool::new(false)), + pairing_success_response: false, + pairing_success_via_query: false, + notification_id: 0, + pushed_notifications: Arc::new(Mutex::new(Vec::new())), + cancelled_notifications: Arc::new(Mutex::new(Vec::new())), + sent_rpc: Arc::new(Mutex::new(Vec::new())), + rpc_responses: Vec::new(), + chain_connect_error: None, + chain_connect_pending: false, + local_storage: Arc::new(Mutex::new(std::collections::HashMap::new())), + local_storage_error: None, + } + } +} + +struct DropFlagGuard(Arc); + +impl Drop for DropFlagGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +/// First `Pairing` deeplink recorded on `auth_states`, if any. +pub(crate) fn first_pairing_deeplink(auth_states: &Mutex>) -> Option { + auth_states + .lock() + .expect("auth state list mutex poisoned") + .iter() + .find_map(|state| match state { + AuthState::Pairing { deeplink } => Some(deeplink.clone()), + _ => None, + }) +} + +/// Default stub platform wrapped in an `Arc`. +pub(crate) fn stub_platform() -> Arc { + Arc::new(StubPlatform::default()) +} + +/// Runtime configuration used by platform-backed runtime tests. +pub(crate) fn runtime_config(product_id: &str) -> RuntimeConfig { + RuntimeConfig { + product_id: product_id.to_string(), + host_info: HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://example.invalid/dotli.png".to_string()), + version: None, + }, + platform_info: PlatformInfo::default(), + people_chain_genesis_hash: [0; 32], + pairing_deeplink_scheme: "polkadotapp".to_string(), + } +} + +/// Basic connected session fixture without SSO channel material. +pub(crate) fn session_info() -> crate::host_logic::session::SessionInfo { + crate::host_logic::session::SessionInfo { + public_key: [ + 0x80, 0x05, 0x28, 0xc9, 0x55, 0x87, 0x3e, 0x4c, 0x78, 0xb7, 0xdf, 0x24, 0xf7, 0x1d, + 0xb8, 0xf5, 0x81, 0xaa, 0x99, 0xe3, 0x49, 0x3b, 0xf4, 0x96, 0xed, 0xf1, 0x51, 0xab, + 0xc1, 0xd7, 0x20, 0x23, + ], + sso: None, + root_entropy_source: Some([ + 0x15, 0xcb, 0x94, 0x34, 0x84, 0x0b, 0x56, 0xbe, 0x1f, 0xdd, 0x91, 0xc4, 0x6a, 0x13, + 0xf5, 0x20, 0xf4, 0x91, 0x61, 0x2e, 0xa5, 0xd6, 0x06, 0x92, 0x0d, 0x91, 0x38, 0xe8, + 0xbd, 0xd6, 0x3c, 0xb0, + ]), + identity_account_id: Some([ + 0x80, 0x05, 0x28, 0xc9, 0x55, 0x87, 0x3e, 0x4c, 0x78, 0xb7, 0xdf, 0x24, 0xf7, 0x1d, + 0xb8, 0xf5, 0x81, 0xaa, 0x99, 0xe3, 0x49, 0x3b, 0xf4, 0x96, 0xed, 0xf1, 0x51, 0xab, + 0xc1, 0xd7, 0x20, 0x23, + ]), + lite_username: Some("alice".to_string()), + full_username: Some("Alice Smith".to_string()), + } +} + +/// Connected session fixture with deterministic SSO channel material. +pub(crate) fn sso_session_info() -> crate::host_logic::session::SessionInfo { + let mut session = session_info(); + let mini_secret = MiniSecretKey::from_bytes(&[7; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + let (_, peer_public_key) = peer_statement_keypair(); + let core_secret = P256SecretKey::from_slice(&[1; 32]).unwrap(); + let peer_secret = P256SecretKey::from_slice(&[2; 32]).unwrap(); + session.sso = Some(crate::host_logic::session::SsoSessionInfo { + ss_secret: keypair.secret.to_bytes(), + ss_public_key: keypair.public.to_bytes(), + enc_secret: core_secret.to_bytes().into(), + peer_enc_pubkey: peer_secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(), + identity_account_id: peer_public_key, + session_id_own: [4; 32], + session_id_peer: [5; 32], + request_channel: [6; 32], + response_channel: [7; 32], + peer_request_channel: [8; 32], + }); + session.root_entropy_source = Some(keypair.secret.to_bytes()[..32].try_into().unwrap()); + session +} + +/// Deterministic peer statement-store signing keypair. +pub(crate) fn peer_statement_keypair() -> ([u8; 64], [u8; 32]) { + let mini_secret = MiniSecretKey::from_bytes(&[9; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + (keypair.secret.to_bytes(), keypair.public.to_bytes()) +} + +/// SCALE-encoded statement signed by the deterministic peer keypair. +pub(crate) fn signed_test_statement(data: Vec) -> Vec { + let (secret, public) = peer_statement_keypair(); + crate::host_logic::statement_store::sign_statement_fields( + secret, + public, + vec![crate::host_logic::statement_store::StatementField::Data( + data, + )], + ) + .unwrap() + .encode() +} + +/// Last submitted SSO remote message decoded from the stub RPC log. +pub(crate) fn submitted_remote_message( + platform: &Arc, + session: &crate::host_logic::session::SessionInfo, +) -> crate::host_logic::sso::messages::RemoteMessage { + let submit = wait_for_statement_submit(&platform.sent_rpc); + let value: serde_json::Value = serde_json::from_str(&submit).unwrap(); + let statement_hex = value["params"][0].as_str().unwrap(); + let statement = hex::decode(statement_hex.strip_prefix("0x").unwrap_or(statement_hex)).unwrap(); + let encrypted = crate::host_logic::statement_store::decode_statement_data(&statement) + .expect("statement data should decode"); + let data = crate::host_logic::sso::pairing::decrypt_session_statement_data( + session.sso.as_ref().unwrap(), + &encrypted, + ) + .expect("statement data should decrypt"); + let crate::host_logic::sso::pairing::SsoStatementData::Request { data, .. } = data else { + panic!("expected request statement data"); + }; + crate::host_logic::sso::messages::RemoteMessage::decode(&mut data[0].as_slice()) + .expect("remote message should decode") +} + +fn wait_for_statement_submit(sent: &Arc>>) -> String { + for _ in 0..100 { + if let Some(request) = sent + .lock() + .expect("rpc list mutex poisoned") + .iter() + .rev() + .find(|request| request.contains("\"statement_submit\"")) + .cloned() + { + return request; + } + #[cfg(not(target_arch = "wasm32"))] + std::thread::sleep(Duration::from_millis(1)); + #[cfg(target_arch = "wasm32")] + futures::executor::block_on(futures_timer::Delay::new(Duration::from_millis(1))); + } + panic!("statement_submit request should be sent"); +} + +/// JSON-RPC response sequence for a successful SSO request/response exchange. +pub(crate) fn sso_success_responses( + session: &crate::host_logic::session::SessionInfo, + message_id: &str, + response: crate::host_logic::sso::messages::RemoteMessage, +) -> Vec { + let own_subscription_id = format!("own-sub-{message_id}"); + let peer_subscription_id = format!("peer-sub-{message_id}"); + vec![ + subscribe_ack_frame("truapi:1", &own_subscription_id), + subscribe_ack_frame("truapi:2", &peer_subscription_id), + statement_submit_ack_frame("truapi:3"), + new_statements_frame( + &own_subscription_id, + vec![sso_statement( + session, + crate::host_logic::sso::pairing::SsoStatementData::Response { + request_id: message_id.to_string(), + response_code: 0, + }, + 1, + )], + ), + new_statements_frame( + &peer_subscription_id, + vec![sso_statement( + session, + crate::host_logic::sso::pairing::SsoStatementData::Request { + request_id: format!("wallet-response-{message_id}"), + data: vec![response.encode()], + }, + 2, + )], + ), + ] +} + +/// JSON-RPC response sequence where the SSO peer sends `Disconnected`. +pub(crate) fn sso_peer_disconnect_responses( + session: &crate::host_logic::session::SessionInfo, + message_id: &str, +) -> Vec { + let own_subscription_id = format!("own-sub-{message_id}"); + let peer_subscription_id = format!("peer-sub-{message_id}"); + vec![ + subscribe_ack_frame("truapi:1", &own_subscription_id), + subscribe_ack_frame("truapi:2", &peer_subscription_id), + statement_submit_ack_frame("truapi:3"), + new_statements_frame( + &own_subscription_id, + vec![sso_statement( + session, + crate::host_logic::sso::pairing::SsoStatementData::Response { + request_id: message_id.to_string(), + response_code: 0, + }, + 1, + )], + ), + new_statements_frame( + &peer_subscription_id, + vec![sso_statement( + session, + crate::host_logic::sso::pairing::SsoStatementData::Request { + request_id: format!("wallet-disconnect-{message_id}"), + data: vec![ + crate::host_logic::sso::messages::RemoteMessage { + message_id: format!("wallet-disconnect-{message_id}"), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::Disconnected, + ), + } + .encode(), + ], + }, + 2, + )], + ), + ] +} + +/// JSON-RPC response sequence for the background peer-disconnect monitor. +pub(crate) fn sso_peer_disconnect_monitor_responses( + session: &crate::host_logic::session::SessionInfo, +) -> Vec { + let subscription_id = "peer-disconnect-monitor-sub"; + vec![ + subscribe_ack_frame("truapi:1", subscription_id), + new_statements_frame( + subscription_id, + vec![sso_statement( + session, + crate::host_logic::sso::pairing::SsoStatementData::Request { + request_id: "wallet-disconnect-monitor".to_string(), + data: vec![ + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-disconnect-monitor".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::Disconnected, + ), + } + .encode(), + ], + }, + 1, + )], + ), + ] +} + +/// JSON-RPC subscription acknowledgement frame. +pub(crate) fn subscribe_ack_frame(request_id: &str, subscription_id: &str) -> String { + serde_json::json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": subscription_id, + }) + .to_string() +} + +fn statement_submit_ack_frame(request_id: &str) -> String { + serde_json::json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": "0xok", + }) + .to_string() +} + +/// JSON-RPC `newStatements` notification carrying SCALE statements. +pub(crate) fn new_statements_frame(subscription_id: &str, statements: Vec>) -> String { + let statements = statements + .into_iter() + .map(|statement| format!("0x{}", hex::encode(statement))) + .collect::>(); + serde_json::json!({ + "jsonrpc": "2.0", + "method": "statement_subscribeStatement", + "params": { + "subscription": subscription_id, + "result": { + "event": "newStatements", + "data": { + "statements": statements, + "remaining": 0, + }, + }, + }, + }) + .to_string() +} + +fn sso_statement( + session: &crate::host_logic::session::SessionInfo, + data: crate::host_logic::sso::pairing::SsoStatementData, + nonce_seed: u8, +) -> Vec { + let mut nonce = [0; crate::host_logic::sso::pairing::AES_GCM_NONCE_LEN]; + nonce[0] = nonce_seed; + let encrypted = crate::host_logic::sso::pairing::encrypt_session_statement_data_with_nonce( + session.sso.as_ref().unwrap(), + &data, + nonce, + ) + .unwrap(); + signed_test_statement(encrypted) +} + +fn core_encryption_public_key_from_deeplink(deeplink: &str) -> [u8; 65] { + pairing_device_from_deeplink(deeplink).1 +} + +/// Pairing device statement and encryption keys encoded in a deeplink. +pub(crate) fn pairing_device_from_deeplink(deeplink: &str) -> ([u8; 32], [u8; 65]) { + let encoded = deeplink + .split("handshake=") + .nth(1) + .expect("pairing deeplink should include handshake"); + let handshake = hex::decode(encoded).expect("handshake should be hex"); + let decoded = crate::host_logic::sso::pairing::VersionedHandshakeProposal::decode( + &mut handshake.as_slice(), + ) + .expect("handshake should decode"); + let crate::host_logic::sso::pairing::VersionedHandshakeProposal::V2(proposal) = decoded; + ( + proposal.device.statement_account_id, + proposal.device.encryption_public_key, + ) +} + +fn wallet_handshake_statement(deeplink: &str) -> Vec { + let core_public_key = + P256PublicKey::from_sec1_bytes(&core_encryption_public_key_from_deeplink(deeplink)) + .expect("core encryption public key should decode"); + let wallet_ephemeral_secret = P256SecretKey::from_slice(&[3; 32]).unwrap(); + let wallet_ephemeral_public = wallet_ephemeral_secret.public_key().to_encoded_point(false); + let mut wallet_ephemeral_public_bytes = [0u8; 65]; + wallet_ephemeral_public_bytes.copy_from_slice(wallet_ephemeral_public.as_bytes()); + let wallet_persistent_public: [u8; 65] = P256SecretKey::from_slice(&[2; 32]) + .unwrap() + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(); + let answer = crate::host_logic::sso::pairing::EncryptedHandshakeResponseV2::Success(Box::new( + crate::host_logic::sso::pairing::HandshakeSuccessV2 { + identity_account_id: peer_statement_keypair().1, + root_account_id: session_info().public_key, + identity_chat_private_key: [0x77; 32], + sso_enc_pub_key: wallet_persistent_public, + device_enc_pub_key: wallet_persistent_public, + root_entropy_source: [0x66; 32], + }, + )); + let shared_secret = diffie_hellman( + wallet_ephemeral_secret.to_nonzero_scalar(), + core_public_key.as_affine(), + ); + let hkdf = Hkdf::::new(None, shared_secret.raw_secret_bytes()); + let mut aes_key = [0u8; 32]; + hkdf.expand(&[], &mut aes_key).unwrap(); + let nonce = [0x44; crate::host_logic::sso::pairing::AES_GCM_NONCE_LEN]; + let cipher = Aes256Gcm::new_from_slice(&aes_key).unwrap(); + let mut encrypted_message = nonce.to_vec(); + encrypted_message.extend( + cipher + .encrypt(Nonce::from_slice(&nonce), answer.encode().as_slice()) + .unwrap(), + ); + let handshake = crate::host_logic::sso::pairing::VersionedHandshakeResponse::V2 { + encrypted_message, + public_key: wallet_ephemeral_public_bytes, + }; + + signed_test_statement(handshake.encode()) +} + +/// SSO signing response message for the given request id. +pub(crate) fn sign_response_message( + message_id: &str, + signature: Vec, + signed_transaction: Option>, +) -> crate::host_logic::sso::messages::RemoteMessage { + crate::host_logic::sso::messages::RemoteMessage { + message_id: format!("wallet-{message_id}"), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::SignResponse( + crate::host_logic::sso::messages::SigningResponse { + responding_to: message_id.to_string(), + payload: Ok( + crate::host_logic::sso::messages::SigningPayloadResponseData { + signature, + signed_transaction, + }, + ), + }, + ), + ), + } +} + +/// Product account id fixture for `identifier` and derivation slot. +pub(crate) fn account_id(identifier: &str, derivation_index: u32) -> v01::ProductAccountId { + v01::ProductAccountId { + dot_ns_identifier: identifier.to_string(), + derivation_index, + } +} + +/// Account-alias request fixture for a product identifier. +pub(crate) fn account_alias_request(identifier: &str) -> HostAccountGetAliasRequest { + HostAccountGetAliasRequest::V1(v01::HostAccountGetAliasRequest { + product_account_id: account_id(identifier, 0), + }) +} + +/// Raw signing payload fixture. +pub(crate) fn raw_payload() -> v01::RawPayload { + v01::RawPayload::Bytes { + bytes: b"hello".to_vec(), + } +} + +/// Structured signing payload fixture. +pub(crate) fn sign_payload_data() -> v01::HostSignPayloadData { + v01::HostSignPayloadData { + block_hash: vec![0; 32], + block_number: vec![0; 4], + era: vec![0], + genesis_hash: vec![1; 32], + method: vec![0], + nonce: vec![0], + spec_version: vec![0], + tip: vec![0], + transaction_version: vec![0], + signed_extensions: vec![], + version: 4, + asset_id: None, + metadata_hash: None, + mode: None, + with_signed_transaction: None, + } +} + +/// Product transaction payload fixture for `identifier`. +pub(crate) fn product_tx_payload(identifier: &str) -> v01::ProductAccountTxPayload { + v01::ProductAccountTxPayload { + signer: account_id(identifier, 0), + genesis_hash: [1; 32], + call_data: vec![0], + extensions: vec![], + tx_ext_version: 0, + } +} + +/// Resource-allocation request fixture containing all supported resource kinds. +pub(crate) fn resource_allocation_request() -> HostRequestResourceAllocationRequest { + HostRequestResourceAllocationRequest::V1(v01::HostRequestResourceAllocationRequest { + resources: vec![ + v01::AllocatableResource::StatementStoreAllowance, + v01::AllocatableResource::AutoSigning, + ], + }) +} + +/// Unsigned statement fixture with channel, topics, expiry, and data. +pub(crate) fn statement() -> v01::Statement { + v01::Statement { + proof: None, + decryption_key: None, + expiry: Some(99), + channel: Some([1; 32]), + topics: vec![[2; 32], [3; 32]], + data: Some(vec![4, 5, 6]), + } +} + +/// Signed statement fixture scoped to `topic`. +pub(crate) fn signed_statement(topic: [u8; 32]) -> v01::SignedStatement { + v01::SignedStatement { + proof: v01::StatementProof::Sr25519 { + signature: [9; 64], + signer: [8; 32], + }, + decryption_key: None, + expiry: Some(99), + channel: Some([1; 32]), + topics: vec![topic], + data: Some(vec![4, 5, 6]), + } +} + +#[truapi_platform::async_trait] +impl PlatformProductStorage for StubPlatform { + async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { + if let Some(reason) = self.local_storage_error { + return Err(v01::HostLocalStorageReadError::Unknown { + reason: reason.to_string(), + }); + } + Ok(self + .local_storage + .lock() + .expect("local storage mutex poisoned") + .get(&key) + .cloned()) + } + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + if let Some(reason) = self.local_storage_error { + return Err(v01::HostLocalStorageReadError::Unknown { + reason: reason.to_string(), + }); + } + self.local_storage + .lock() + .expect("local storage mutex poisoned") + .insert(key, value); + Ok(()) + } + async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { + if let Some(reason) = self.local_storage_error { + return Err(v01::HostLocalStorageReadError::Unknown { + reason: reason.to_string(), + }); + } + self.local_storage + .lock() + .expect("local storage mutex poisoned") + .remove(&key); + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl PlatformCoreStorage for StubPlatform { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + if let CoreStorageKey::AuthSession = key { + if let Some(reason) = self.session_error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + return Ok(self.session_blob.clone()); + } + if let Some(reason) = self.local_storage_error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + Ok(self + .local_storage + .lock() + .expect("local storage mutex poisoned") + .get(&core_storage_test_key(key)) + .cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + if let CoreStorageKey::AuthSession = key { + self.session_writes + .lock() + .expect("session write list mutex poisoned") + .push(value); + return Ok(()); + } + if let Some(reason) = self.local_storage_error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + self.local_storage + .lock() + .expect("local storage mutex poisoned") + .insert(core_storage_test_key(key), value); + Ok(()) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), v01::GenericError> { + if let CoreStorageKey::AuthSession = key { + *self + .session_clears + .lock() + .expect("session clear counter mutex poisoned") += 1; + return Ok(()); + } + if let Some(reason) = self.local_storage_error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + self.local_storage + .lock() + .expect("local storage mutex poisoned") + .remove(&core_storage_test_key(key)); + Ok(()) + } +} + +/// Stable string key used by the stub core-storage map. +pub(crate) fn core_storage_test_key(key: CoreStorageKey) -> String { + format!("core:{}", hex::encode(key.encode())) +} + +#[truapi_platform::async_trait] +impl PlatformNavigation for StubPlatform { + async fn navigate_to(&self, _url: String) -> Result<(), v01::HostNavigateToError> { + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl PlatformNotifications for StubPlatform { + async fn push_notification( + &self, + notification: v01::HostPushNotificationRequest, + ) -> Result { + self.pushed_notifications + .lock() + .expect("notification list mutex poisoned") + .push(notification); + Ok(v01::HostPushNotificationResponse { + id: self.notification_id, + }) + } + + async fn cancel_notification(&self, id: u32) -> Result<(), v01::GenericError> { + self.cancelled_notifications + .lock() + .expect("notification cancellation list mutex poisoned") + .push(id); + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl PlatformPermissions for StubPlatform { + async fn device_permission( + &self, + _request: v01::HostDevicePermissionRequest, + ) -> Result { + Ok(v01::HostDevicePermissionResponse { granted: true }) + } + + async fn remote_permission( + &self, + _request: v01::RemotePermissionRequest, + ) -> Result { + Ok(v01::RemotePermissionResponse { + granted: self.remote_permission_granted, + }) + } +} + +#[truapi_platform::async_trait] +impl PlatformFeatures for StubPlatform { + async fn feature_supported( + &self, + _request: v01::HostFeatureSupportedRequest, + ) -> Result { + Ok(v01::HostFeatureSupportedResponse { supported: true }) + } +} + +struct RecordingConnection { + sent: Arc>>, + responses: Vec, + auth_states: Arc>>, + pairing_success_response: bool, + pairing_success_via_query: bool, +} + +async fn wait_for_statement_subscribe_id(sent: Arc>>, index: usize) -> String { + for _ in 0..100 { + let ids = sent + .lock() + .expect("rpc list mutex poisoned") + .iter() + .filter_map(|request| { + let value: serde_json::Value = serde_json::from_str(request).ok()?; + (value.get("method")?.as_str()? == "statement_subscribeStatement") + .then(|| value.get("id")?.as_str().map(ToString::to_string))? + }) + .collect::>(); + if let Some(id) = ids.get(index) { + return id.clone(); + } + futures_timer::Delay::new(Duration::from_millis(1)).await; + } + panic!("statement_subscribeStatement request {index} was not issued"); +} + +impl JsonRpcConnection for RecordingConnection { + fn send(&self, request: String) { + self.sent + .lock() + .expect("rpc list mutex poisoned") + .push(request); + } + fn responses(&self) -> BoxStream<'static, String> { + if self.pairing_success_via_query { + let auth_states = self.auth_states.clone(); + let sent = self.sent.clone(); + return Box::pin(stream::unfold(0, move |state| { + let auth_states = auth_states.clone(); + let sent = sent.clone(); + async move { + match state { + 0 => { + let id = wait_for_statement_subscribe_id(sent.clone(), 0).await; + Some((subscribe_ack_frame(&id, "pairing-sub"), 1)) + } + 1 => { + let query_id = wait_for_statement_subscribe_id(sent.clone(), 1).await; + Some((subscribe_ack_frame(&query_id, "query-sub"), 2)) + } + 2 => { + for _ in 0..100 { + if let Some(deeplink) = first_pairing_deeplink(&auth_states) { + return Some(( + new_statements_frame( + "query-sub", + vec![wallet_handshake_statement(&deeplink)], + ), + 3, + )); + } + futures_timer::Delay::new(Duration::from_millis(1)).await; + } + panic!("pairing deeplink was not presented"); + } + _ => futures::future::pending().await, + } + } + })); + } + if self.pairing_success_response { + let auth_states = self.auth_states.clone(); + let sent = self.sent.clone(); + return Box::pin(stream::unfold(0, move |state| { + let auth_states = auth_states.clone(); + let sent = sent.clone(); + async move { + match state { + 0 => { + let id = wait_for_statement_subscribe_id(sent.clone(), 0).await; + Some((subscribe_ack_frame(&id, "pairing-sub"), 1)) + } + 1 => { + for _ in 0..100 { + if let Some(deeplink) = first_pairing_deeplink(&auth_states) { + return Some(( + new_statements_frame( + "pairing-sub", + vec![wallet_handshake_statement(&deeplink)], + ), + 2, + )); + } + futures_timer::Delay::new(Duration::from_millis(1)).await; + } + panic!("pairing deeplink was not presented"); + } + _ => futures::future::pending().await, + } + } + })); + } + if self.responses.is_empty() { + Box::pin(futures::stream::pending()) + } else { + let responses = self.responses.clone(); + let sent = self.sent.clone(); + Box::pin(stream::unfold(0, move |index| { + let responses = responses.clone(); + let sent = sent.clone(); + async move { + let Some(response) = responses.get(index).cloned() else { + return futures::future::pending().await; + }; + wait_for_matching_request_id(sent, &response).await; + Some((response, index + 1)) + } + })) + } + } + + fn close(&self) {} +} + +async fn wait_for_matching_request_id(sent: Arc>>, response: &str) { + let Some(id) = json_rpc_id(response) else { + return; + }; + for _ in 0..100 { + if sent + .lock() + .expect("rpc list mutex poisoned") + .iter() + .any(|request| json_rpc_id(request).as_deref() == Some(id.as_str())) + { + return; + } + futures_timer::Delay::new(Duration::from_millis(1)).await; + } + panic!("request {id} was not issued before scripted response"); +} + +fn json_rpc_id(frame: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(frame).ok()?; + match value.get("id")? { + serde_json::Value::String(value) => Some(value.clone()), + serde_json::Value::Number(value) => Some(value.to_string()), + _ => None, + } +} + +#[truapi_platform::async_trait] +impl ChainProvider for StubPlatform { + async fn connect( + &self, + _genesis_hash: Vec, + ) -> Result, v01::GenericError> { + if let Some(reason) = self.chain_connect_error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + if self.chain_connect_pending { + let _guard = DropFlagGuard(self.pending_connect_dropped.clone()); + futures::future::pending::<()>().await; + } + Ok(Box::new(RecordingConnection { + sent: self.sent_rpc.clone(), + responses: self.rpc_responses.clone(), + auth_states: self.auth_states.clone(), + pairing_success_response: self.pairing_success_response, + pairing_success_via_query: self.pairing_success_via_query, + })) + } +} + +impl AuthPresenter for StubPlatform { + fn auth_state_changed(&self, state: AuthState) { + self.auth_states + .lock() + .expect("auth state list mutex poisoned") + .push(state.clone()); + let hook = self + .on_auth_state + .lock() + .expect("auth state hook mutex poisoned") + .clone(); + if let Some(hook) = hook { + hook(&state); + } + } +} + +#[truapi_platform::async_trait] +impl UserConfirmation for StubPlatform { + async fn confirm_user_action( + &self, + review: UserConfirmationReview, + ) -> Result { + let (error, confirmed) = match review { + UserConfirmationReview::SignPayload(_) => { + (self.sign_payload_error, self.sign_payload_confirmed) + } + UserConfirmationReview::SignRaw(_) => (self.sign_raw_error, self.sign_raw_confirmed), + UserConfirmationReview::CreateTransaction(_) => ( + self.create_transaction_error, + self.create_transaction_confirmed, + ), + UserConfirmationReview::AccountAlias(_) => { + (self.account_alias_error, self.account_alias_confirmed) + } + UserConfirmationReview::ResourceAllocation(_) => ( + self.resource_allocation_error, + self.resource_allocation_confirmed, + ), + UserConfirmationReview::PreimageSubmit(_) => (None, true), + }; + if let Some(reason) = error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + Ok(confirmed) + } +} + +impl ThemeHost for StubPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + Box::pin(stream::once(async { Ok(v01::ThemeVariant::Dark) })) + } +} + +#[truapi_platform::async_trait] +impl PreimageHost for StubPlatform { + async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + Ok(value) + } + fn lookup_preimage( + &self, + _key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + Box::pin(stream::once(async { Ok(Some(vec![9, 8, 7])) })) + } +} diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs new file mode 100644 index 00000000..dae2920a --- /dev/null +++ b/rust/crates/truapi-server/src/wasm.rs @@ -0,0 +1,1158 @@ +//! wasm-bindgen surface. Exposes [`WasmHostCore`] to JavaScript hosts so +//! they can wire the TrUAPI core into a browser or worker shell. +//! +//! The browser side hands a `callbacks` object (a `JsBridge`) to the +//! constructor. The bridge implements every host-side capability the +//! [`truapi_platform::Platform`] trait set requires. Internally the bridge +//! is wrapped in a [`SendWrapper`] so it satisfies the `Send` bound the +//! platform trait set imposes; sound on wasm32 because the runtime is +//! single-threaded. + +use core::cell::Cell; +use core::future::Future; +use core::pin::Pin; +use core::task::{Context, Poll}; +use std::rc::Rc; +use std::sync::Arc; +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 send_wrapper::SendWrapper; +use truapi::v01; +use truapi_platform::{ + AuthPresenter, AuthState, ChainProvider, CoreStorage, CoreStorageKey, Features, HostInfo, + JsonRpcConnection, Navigation, Notifications, Permissions, PlatformInfo, PreimageHost, + ProductStorage, RuntimeConfig, RuntimeConfigValidationError, SessionUiInfo, ThemeHost, + UserConfirmation, UserConfirmationReview, +}; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; + +use crate::subscription::Spawner; +use crate::{FrameSink, HostCore, PermissionAuthorizationRequest, PermissionAuthorizationStatus}; + +/// 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: Option, + 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: Option, + submit_preimage: Option, + lookup_preimage: Option, + subscribe_theme: Option, + auth_state_changed: Option, + /// Optional. Hosts that own JSON-RPC connections provide this; otherwise + /// chain calls fail with an "unavailable" reason. + chain_connect: Option, + emit_frame: Function, + 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_optional_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_optional_function(callbacks, "confirmUserAction")?, + submit_preimage: get_optional_function(callbacks, "submitPreimage")?, + lookup_preimage: get_optional_function(callbacks, "lookupPreimage")?, + subscribe_theme: get_optional_function(callbacks, "subscribeTheme")?, + auth_state_changed: get_optional_function(callbacks, "authStateChanged")?, + chain_connect: get_optional_function(callbacks, "chainConnect")?, + emit_frame: get_function(callbacks, "emitFrame")?, + dispose: get_optional_function(callbacks, "dispose")?.unwrap_or_else(noop_function), + }) + } +} + +struct WasmFrameSink { + bridge: SendWrapper>, +} + +impl FrameSink for WasmFrameSink { + fn emit_frame(&self, frame: Vec) { + let frame = Uint8Array::from(frame.as_slice()); + if let Err(err) = self.bridge.emit_frame.call1(&JsValue::NULL, &frame) { + web_sys::console::error_1(&err); + } + } +} + +struct WasmPlatform { + bridge: SendWrapper>, +} + +impl WasmPlatform { + fn new(bridge: Arc) -> Self { + Self { + bridge: SendWrapper::new(bridge), + } + } +} + +#[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> { + let Some(fn_) = self.bridge.cancel_notification.as_ref() else { + return Ok(()); + }; + invoke_u32_unit(fn_, 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( + &self, + genesis_hash: Vec, + ) -> Result, v01::GenericError> { + let chain_connect = match self.bridge.chain_connect.clone() { + Some(f) => f, + None => { + return Err(generic( + "chainConnect callback not provided by host".to_string(), + )); + } + }; + let chain_connect = SendWrapper::new(chain_connect); + SendWrapper::new(async move { + let (response_tx, response_rx) = mpsc::unbounded::(); + let on_response = Closure::wrap(Box::new(move |json: JsValue| { + // The host must hand back JSON-RPC frames as strings. Drop (and + // log) non-string values rather than forwarding an empty frame + // that would desync request/response correlation. + match json.as_string() { + Some(s) => { + let _ = response_tx.unbounded_send(s); + } + None => web_sys::console::error_1(&JsValue::from_str( + "chainConnect onResponse expected a JSON string; dropping non-string value", + )), + } + }) as Box); + + let genesis_arg = JsValue::from_str(&format!("0x{}", hex::encode(&genesis_hash))); + let returned = chain_connect + .call2( + &JsValue::NULL, + &genesis_arg, + on_response.as_ref().unchecked_ref(), + ) + .map_err(|err| generic(js_to_string(err)))?; + let resolved = await_optional_promise(returned).await.map_err(generic)?; + if resolved.is_null() || resolved.is_undefined() { + return Err(generic("chainConnect returned no connection".into())); + } + let send_fn = Reflect::get(&resolved, &JsValue::from_str("send")) + .map_err(|_| generic("chainConnect must return { send, close }".into()))? + .dyn_into::() + .map_err(|_| generic("chainConnect.send must be a function".into()))?; + let close_fn = Reflect::get(&resolved, &JsValue::from_str("close")) + .map_err(|_| generic("chainConnect.close must be a function".into()))? + .dyn_into::() + .map_err(|_| generic("chainConnect.close must be a function".into()))?; + + Ok(Box::new(JsCallbackJsonRpcConnection { + send_fn: SendWrapper::new(send_fn), + close_fn: SendWrapper::new(close_fn), + closed: AtomicBool::new(false), + _on_response: SendWrapper::new(on_response), + response_rx: std::sync::Mutex::new(Some(response_rx)), + }) as Box) + }) + .await + } +} + +impl AuthPresenter for WasmPlatform { + fn auth_state_changed(&self, state: AuthState) { + let Some(fn_) = self.bridge.auth_state_changed.as_ref() else { + return; + }; + if let Err(err) = fn_.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 { + let Some(fn_) = self.bridge.confirm_user_action.as_ref() else { + return Ok(false); + }; + invoke_bool(fn_, review.encode()).await.map_err(generic) + } +} + +impl ThemeHost for WasmPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + let Some(fn_) = self.bridge.subscribe_theme.as_ref() else { + return stream::once(async { Ok(v01::ThemeVariant::Dark) }).boxed(); + }; + invoke_js_subscription(fn_, None, parse_theme_item).boxed() + } +} + +#[truapi_platform::async_trait] +impl PreimageHost for WasmPlatform { + async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + let Some(fn_) = self.bridge.submit_preimage.as_ref() else { + return Err(v01::PreimageSubmitError::Unknown { + reason: "submitPreimage callback not provided by host".to_string(), + }); + }; + invoke_bytes_return(fn_, value) + .await + .map_err(|reason| v01::PreimageSubmitError::Unknown { reason }) + } + + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + let Some(fn_) = self.bridge.lookup_preimage.as_ref() else { + return stream::once(async { Ok(None) }).boxed(); + }; + invoke_js_subscription(fn_, Some(key), parse_preimage_lookup_item).boxed() + } +} + +// Account, signing, and statement-store flows live in the Rust +// core itself. Their `truapi::api::*` trait defaults return `Unsupported` +// until those in-core implementations land. The JS bridge only carries +// callbacks for the platform capabilities the core cannot satisfy alone. + +struct JsSubscriptionStream { + rx: mpsc::UnboundedReceiver, + _send_item: SendWrapper>, + dispose: Option>, +} + +impl Stream for JsSubscriptionStream { + type Item = T; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.rx).poll_next(cx) + } +} + +impl Drop for JsSubscriptionStream { + fn drop(&mut self) { + if let Some(dispose) = self.dispose.take() { + let _ = dispose.call0(&JsValue::NULL); + } + } +} + +fn invoke_js_subscription( + fn_: &Function, + payload: Option>, + parse_item: fn(JsValue) -> Result, +) -> BoxStream<'static, Result> +where + T: Send + 'static, +{ + let (tx, rx) = mpsc::unbounded::>(); + let send_item = Closure::wrap(Box::new(move |value: JsValue| { + let item = parse_item(value).map_err(generic); + let _ = tx.unbounded_send(item); + }) as Box); + + let call_result = match payload { + Some(payload) => { + let arg = Uint8Array::from(payload.as_slice()); + fn_.call2(&JsValue::NULL, &arg, send_item.as_ref().unchecked_ref()) + } + None => fn_.call1(&JsValue::NULL, send_item.as_ref().unchecked_ref()), + }; + + let dispose = match call_result { + Ok(value) if value.is_null() || value.is_undefined() => None, + Ok(value) => match value.dyn_into::() { + Ok(dispose) => Some(SendWrapper::new(dispose)), + Err(_) => { + return stream::once(async { + Err(generic( + "subscription callback must return a dispose function, null, or undefined" + .to_string(), + )) + }) + .boxed(); + } + }, + Err(err) => return stream::once(async { Err(generic(js_to_string(err))) }).boxed(), + }; + + Box::pin(JsSubscriptionStream { + rx, + _send_item: SendWrapper::new(send_item), + dispose, + }) +} + +struct JsCallbackJsonRpcConnection { + send_fn: SendWrapper, + close_fn: SendWrapper, + closed: AtomicBool, + /// Closure must outlive the connection so JS keeps a live ref to the + /// response sink. Dropped together with the rest of the struct. + _on_response: SendWrapper>, + response_rx: std::sync::Mutex>>, +} + +impl JsonRpcConnection for JsCallbackJsonRpcConnection { + fn send(&self, request: String) { + let arg = JsValue::from_str(&request); + if let Err(err) = self.send_fn.call1(&JsValue::NULL, &arg) { + web_sys::console::error_1(&err); + } + } + + /// Single-take: the response receiver is handed out exactly once. A second + /// call yields an empty stream (and logs), since the channel has one + /// consumer. + fn responses(&self) -> BoxStream<'static, String> { + let mut guard = self.response_rx.lock().unwrap(); + match guard.take() { + Some(rx) => rx.boxed(), + None => { + web_sys::console::error_1(&JsValue::from_str( + "JsCallbackJsonRpcConnection::responses() called more than once", + )); + futures::stream::empty().boxed() + } + } + } + + fn close(&self) { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + let _ = self.close_fn.call0(&JsValue::NULL); + } +} + +impl Drop for JsCallbackJsonRpcConnection { + fn drop(&mut self) { + self.close(); + } +} + +fn generic(reason: String) -> v01::GenericError { + v01::GenericError { reason } +} + +/// Await the JS callback's return value if it's a Promise; pass other +/// values through unchanged. Every host callback resolves through this so +/// the JS side is free to be sync or async. +async fn await_optional_promise(returned: JsValue) -> Result { + if returned.is_instance_of::() { + let promise = returned.unchecked_into::(); + wasm_bindgen_futures::JsFuture::from(promise) + .await + .map_err(js_to_string) + } else { + Ok(returned) + } +} + +fn invoke_navigate_to( + bridge: &JsBridge, + url: &str, +) -> impl Future> + Send { + let fn_ = bridge.navigate_to.clone(); + let url = url.to_string(); + SendWrapper::new(async move { + let arg = JsValue::from_str(&url); + let returned = fn_.call1(&JsValue::NULL, &arg).map_err(js_to_string)?; + await_optional_promise(returned).await.map(|_| ()) + }) +} + +fn invoke_bool( + fn_: &Function, + payload: 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 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 + // denial / unsupported and hide the host bug). + resolved + .as_bool() + .ok_or_else(|| "callback must resolve to a boolean".to_string()) + }) +} + +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, +) -> 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 resolved = await_optional_promise(returned).await?; + resolved + .dyn_into::() + .map(|array| array.to_vec()) + .map_err(|_| "callback must resolve to Uint8Array".to_string()) + }) +} + +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, +) -> impl Future>, String>> + Send { + let fn_ = bridge.core_storage_read.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 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(|_| ()) + }) +} + +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 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 arg = JsValue::from_str(&key); + let returned = fn_.call1(&JsValue::NULL, &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 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 arg = JsValue::from_str(&key); + let returned = fn_.call1(&JsValue::NULL, &arg).map_err(js_to_string)?; + await_optional_promise(returned).await.map(|_| ()) + }) +} + +fn js_to_string(value: JsValue) -> String { + value + .as_string() + .or_else(|| { + value + .dyn_ref::() + .map(|err| err.message().into()) + }) + .unwrap_or_else(|| format!("{value:?}")) +} + +fn get_function(callbacks: &JsValue, name: &str) -> Result { + let value = Reflect::get(callbacks, &JsValue::from_str(name))?; + value + .dyn_into::() + .map_err(|_| JsValue::from_str(&format!("callbacks.{name} must be a function"))) +} + +fn get_optional_function(callbacks: &JsValue, name: &str) -> Result, JsValue> { + let value = Reflect::get(callbacks, &JsValue::from_str(name))?; + if value.is_null() || value.is_undefined() { + return Ok(None); + } + value + .dyn_into::() + .map(Some) + .map_err(|_| JsValue::from_str(&format!("callbacks.{name} must be a function"))) +} + +fn noop_function() -> Function { + Function::new_no_args("") +} + +fn runtime_config_from_js(value: &JsValue) -> Result { + if value.is_null() || value.is_undefined() { + return Err(JsValue::from_str("runtimeConfig is required")); + } + + let host = get_required_object(value, "host", "runtimeConfig.host")?; + let platform = get_optional_object(value, "platform", "runtimeConfig.platform")?; + let people = get_required_object(value, "people", "runtimeConfig.people")?; + let pairing = get_required_object(value, "pairing", "runtimeConfig.pairing")?; + + RuntimeConfig::new( + get_required_string_at(value, "productId", "runtimeConfig.productId")?, + HostInfo { + name: get_required_string_at(&host, "name", "runtimeConfig.host.name")?, + icon: get_optional_string_at(&host, "icon", "runtimeConfig.host.icon")?, + version: get_optional_string_at(&host, "version", "runtimeConfig.host.version")?, + }, + PlatformInfo { + kind: platform + .as_ref() + .map(|p| get_optional_string_at(p, "type", "runtimeConfig.platform.type")) + .transpose()? + .flatten(), + version: platform + .as_ref() + .map(|p| get_optional_string_at(p, "version", "runtimeConfig.platform.version")) + .transpose()? + .flatten(), + }, + get_required_bytes32_at(&people, "genesisHash", "runtimeConfig.people.genesisHash")?, + get_required_string_at( + &pairing, + "deeplinkScheme", + "runtimeConfig.pairing.deeplinkScheme", + )?, + ) + .map_err(runtime_config_validation_to_js) +} + +fn runtime_config_field_to_js(field: &str) -> &str { + match field { + "product_id" => "productId", + "host_info.name" => "host.name", + "pairing_deeplink_scheme" => "pairing.deeplinkScheme", + "people_chain_genesis_hash" => "people.genesisHash", + other => other, + } +} + +fn runtime_config_validation_to_js(err: RuntimeConfigValidationError) -> JsValue { + match err { + RuntimeConfigValidationError::EmptyField { field } => JsValue::from_str(&format!( + "runtimeConfig.{} must not be empty", + runtime_config_field_to_js(field) + )), + RuntimeConfigValidationError::InvalidHostIcon { reason } => JsValue::from_str(&format!( + "runtimeConfig.host.icon must be an absolute HTTPS URL: {reason}" + )), + RuntimeConfigValidationError::InsecureHostIcon { scheme } => JsValue::from_str(&format!( + "runtimeConfig.host.icon must use https scheme, got {scheme:?}" + )), + RuntimeConfigValidationError::InvalidDeeplinkScheme { scheme } => JsValue::from_str( + &format!("runtimeConfig.pairing.deeplinkScheme must not include ://, got {scheme:?}"), + ), + } +} + +fn get_required_object(value: &JsValue, name: &str, path: &str) -> Result { + let property = Reflect::get(value, &JsValue::from_str(name))?; + if property.is_null() || property.is_undefined() { + return Err(JsValue::from_str(&format!("{path} is required"))); + } + if !property.is_object() { + return Err(JsValue::from_str(&format!("{path} must be an object"))); + } + Ok(property) +} + +fn get_optional_object( + value: &JsValue, + name: &str, + path: &str, +) -> Result, JsValue> { + let property = Reflect::get(value, &JsValue::from_str(name))?; + if property.is_null() || property.is_undefined() { + return Ok(None); + } + if !property.is_object() { + return Err(JsValue::from_str(&format!("{path} must be an object"))); + } + Ok(Some(property)) +} + +fn get_optional_string_at( + value: &JsValue, + name: &str, + path: &str, +) -> Result, JsValue> { + let property = Reflect::get(value, &JsValue::from_str(name))?; + if property.is_null() || property.is_undefined() { + return Ok(None); + } + property + .as_string() + .map(Some) + .ok_or_else(|| JsValue::from_str(&format!("{path} must be a string"))) +} + +fn get_required_string_at(value: &JsValue, name: &str, path: &str) -> Result { + get_optional_string_at(value, name, path)? + .ok_or_else(|| JsValue::from_str(&format!("{path} is required"))) +} + +fn get_optional_bytes32_at( + value: &JsValue, + name: &str, + path: &str, +) -> Result, JsValue> { + let property = Reflect::get(value, &JsValue::from_str(name))?; + if property.is_null() || property.is_undefined() { + return Ok(None); + } + if let Some(hex) = property.as_string() { + return parse_hex32(&hex) + .map(Some) + .map_err(|reason| JsValue::from_str(&format!("{path}: {reason}"))); + } + let array = property + .dyn_into::() + .map_err(|_| JsValue::from_str(&format!("{path} must be hex or Uint8Array")))?; + let bytes = array.to_vec(); + bytes.try_into().map(Some).map_err(|bytes: Vec| { + JsValue::from_str(&format!( + "{path} must be exactly 32 bytes, got {}", + bytes.len() + )) + }) +} + +fn get_required_bytes32_at(value: &JsValue, name: &str, path: &str) -> Result<[u8; 32], JsValue> { + get_optional_bytes32_at(value, name, path)? + .ok_or_else(|| JsValue::from_str(&format!("{path} is required"))) +} + +fn parse_hex32(value: &str) -> Result<[u8; 32], String> { + let raw = value.strip_prefix("0x").unwrap_or(value); + if raw.len() != 64 { + return Err(format!( + "expected 32-byte hex string, got {} hex chars", + raw.len() + )); + } + let bytes = hex::decode(raw).map_err(|_| "invalid hex".to_string())?; + bytes + .try_into() + .map_err(|bytes: Vec| format!("expected 32 bytes, got {}", bytes.len())) +} + +fn decode_permission_authorization_request( + payload: &[u8], +) -> Result { + PermissionAuthorizationRequest::decode(&mut &*payload).map_err(|err| { + JsValue::from_str(&format!( + "permission authorization request did not decode: {err}" + )) + }) +} + +fn decode_permission_authorization_requests( + payloads: &Array, +) -> Result, JsValue> { + let mut requests = Vec::with_capacity(payloads.length() as usize); + for payload in payloads.iter() { + let payload = payload + .dyn_into::() + .map_err(|_| JsValue::from_str("permission authorization request must be bytes"))?; + requests.push(decode_permission_authorization_request(&payload.to_vec())?); + } + Ok(requests) +} + +fn permission_authorization_status_to_js(status: PermissionAuthorizationStatus) -> JsValue { + JsValue::from_str(match status { + PermissionAuthorizationStatus::NotDetermined => "NotDetermined", + PermissionAuthorizationStatus::Denied => "Denied", + PermissionAuthorizationStatus::Authorized => "Authorized", + }) +} + +fn permission_authorization_status_from_js( + status: &str, +) -> Result { + match status { + "NotDetermined" => Ok(PermissionAuthorizationStatus::NotDetermined), + "Denied" => Ok(PermissionAuthorizationStatus::Denied), + "Authorized" => Ok(PermissionAuthorizationStatus::Authorized), + other => Err(JsValue::from_str(&format!( + "unknown permission authorization status: {other}" + ))), + } +} + +fn generic_error_to_js(err: v01::GenericError) -> JsValue { + JsValue::from_str(&err.reason) +} + +struct WasmCoreInner { + core: HostCore, + dispose_fn: SendWrapper, + disposed: Cell, + disposing: Cell, +} + +/// Set the live log level (`off`/`error`/`warn`/`info`/`debug`/`trace`). +/// Hosts may call this during boot, or again at any time to re-tune verbosity. +/// Unknown values are parsed as `off`. +#[wasm_bindgen(js_name = setLogLevel)] +pub fn set_log_level(level: &str) { + crate::logging::set_level_from_str(level); +} + +/// JS-callable handle to the TrUAPI core. Constructed once per shell boot. +#[wasm_bindgen] +pub struct WasmHostCore { + inner: Rc, +} + +#[wasm_bindgen] +impl WasmHostCore { + /// Build the core from a JS callbacks object. The object must define + /// every host capability the [`truapi_platform::Platform`] trait set + /// requires (camelCase property names; see the source for the full + /// list). + #[wasm_bindgen(constructor)] + pub fn new(callbacks: JsValue, runtime_config: JsValue) -> Result { + // Surface Rust panics to the browser console. A panic mid-dispatch + // aborts the call as a wasm trap; the host should treat a thrown error + // from `receiveFrame` as a fatal-instance signal and rebuild the + // core rather than continue using it. + console_error_panic_hook::set_once(); + crate::logging::init(); + let bridge = Arc::new(JsBridge::from_js(&callbacks)?); + let frame_sink = Arc::new(WasmFrameSink { + bridge: SendWrapper::new(bridge.clone()), + }); + let dispose_fn = SendWrapper::new(bridge.dispose.clone()); + let platform = Arc::new(WasmPlatform::new(bridge)); + let spawner: Spawner = Arc::new(|fut| { + wasm_bindgen_futures::spawn_local(fut); + }); + let runtime_config = runtime_config_from_js(&runtime_config)?; + let core = + HostCore::from_platform_with_config(platform, runtime_config, spawner, frame_sink); + Ok(Self { + inner: Rc::new(WasmCoreInner { + core, + dispose_fn, + disposed: Cell::new(false), + disposing: Cell::new(false), + }), + }) + } + + /// Push a SCALE-encoded protocol frame into the dispatcher. Responses + /// (and subscription items) flow back through the `emitFrame` + /// callback. + #[wasm_bindgen(js_name = receiveFrame)] + pub async fn receive_frame(&self, frame: Vec) -> Result<(), JsValue> { + self.inner + .core + .receive_frame(frame) + .await + .map_err(|err| JsValue::from_str(&err.to_string())) + } + + /// Read a stored permission authorization status without prompting. + /// + /// `payload` is a SCALE-encoded `PermissionAuthorizationRequest`. + #[wasm_bindgen(js_name = permissionAuthorizationStatus)] + pub async fn permission_authorization_status( + &self, + payload: Vec, + ) -> Result { + let request = decode_permission_authorization_request(&payload)?; + let status = self + .inner + .core + .permission_authorization_status(request) + .await + .map_err(generic_error_to_js)?; + Ok(permission_authorization_status_to_js(status)) + } + + /// Read stored permission authorization statuses without prompting. + /// + /// `payloads` is an array of SCALE-encoded + /// `PermissionAuthorizationRequest` values. Results follow the same order. + #[wasm_bindgen(js_name = permissionAuthorizationStatuses)] + pub async fn permission_authorization_statuses( + &self, + payloads: Array, + ) -> Result { + let requests = decode_permission_authorization_requests(&payloads)?; + let statuses = self + .inner + .core + .permission_authorization_statuses(requests) + .await + .map_err(generic_error_to_js)?; + let values = Array::new(); + for status in statuses { + values.push(&permission_authorization_status_to_js(status)); + } + Ok(values) + } + + /// Update a stored permission authorization status. Passing + /// `"NotDetermined"` clears the stored value so the next product request + /// prompts again. + #[wasm_bindgen(js_name = setPermissionAuthorizationStatus)] + pub async fn set_permission_authorization_status( + &self, + payload: Vec, + status: String, + ) -> Result<(), JsValue> { + let request = decode_permission_authorization_request(&payload)?; + let status = permission_authorization_status_from_js(&status)?; + self.inner + .core + .set_permission_authorization_status(request, status) + .await + .map_err(generic_error_to_js) + } + + /// Tear down the bridge. Invokes the JS-side `dispose` callback so the + /// host can drop its end of the wiring. + pub fn dispose(&self) -> Result<(), JsValue> { + if self.inner.disposed.get() { + return Ok(()); + } + if self.inner.disposing.replace(true) { + return Ok(()); + } + + self.inner.core.dispose(); + + let result = self.inner.dispose_fn.call0(&JsValue::NULL).map(|_| ()); + + self.inner.disposed.set(true); + self.inner.disposing.set(false); + result + } + + /// Core-owned logout/disconnect. Best-effort notifies the SSO peer when + /// the session has channel material, then clears in-memory and persisted + /// session state. + #[wasm_bindgen(js_name = disconnectSession)] + pub async fn disconnect_session(&self) -> Result<(), JsValue> { + self.inner.core.disconnect_session().await; + Ok(()) + } + + /// Cancel any in-flight `request_login` pairing. The host receives a + /// `Disconnected` auth state immediately and the pending login resolves + /// to `Rejected`. A no-op when no login is in progress. + #[wasm_bindgen(js_name = cancelPairing)] + pub fn cancel_pairing(&self) { + self.inner.core.cancel_pairing(); + } + + /// Notify the core that the host-global auth session slot may have changed. + #[wasm_bindgen(js_name = notifySessionStoreChanged)] + pub fn notify_session_store_changed(&self) { + if self.inner.disposed.get() { + return; + } + self.inner.core.notify_session_store_changed(); + } +} diff --git a/rust/crates/truapi-server/tests/common/mod.rs b/rust/crates/truapi-server/tests/common/mod.rs new file mode 100644 index 00000000..a4013e3b --- /dev/null +++ b/rust/crates/truapi-server/tests/common/mod.rs @@ -0,0 +1,198 @@ +#[cfg(target_arch = "wasm32")] +use std::sync::Arc; + +use std::sync::Mutex; + +use futures::stream::{self, BoxStream}; +use truapi::v01; +use truapi_platform::{ + AuthPresenter, ChainProvider, CoreStorage, CoreStorageKey, Features, HostInfo, + JsonRpcConnection, Navigation, Notifications, Permissions, PlatformInfo, PreimageHost, + ProductStorage, RuntimeConfig, ThemeHost, UserConfirmation, UserConfirmationReview, +}; +use truapi_server::frame::ProtocolMessage; +use truapi_server::transport::Transport; + +/// Transport stub that records every frame sent through it, for asserting +/// what the core emits during a dispatch. +#[derive(Default)] +pub struct RecordingTransport { + /// Frames captured in send order. + pub sent: Mutex>, +} + +impl Transport for RecordingTransport { + fn send(&self, message: ProtocolMessage) { + self.sent.lock().unwrap().push(message); + } + fn on_message( + &self, + _handler: Box, + ) -> Box { + Box::new(|| {}) + } +} + +/// Test spawner that matches the current target. +pub fn test_spawner() -> truapi_server::subscription::Spawner { + #[cfg(not(target_arch = "wasm32"))] + { + truapi_server::subscription::thread_per_subscription_spawner() + } + #[cfg(target_arch = "wasm32")] + { + Arc::new(futures::executor::block_on) + } +} + +/// Runtime configuration shared by integration tests. +pub fn test_runtime_config() -> RuntimeConfig { + RuntimeConfig { + product_id: "dotli.dot".to_string(), + host_info: HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://dot.li/dotli.png".to_string()), + version: None, + }, + platform_info: PlatformInfo::default(), + people_chain_genesis_hash: [0xa2; 32], + pairing_deeplink_scheme: "polkadotapp".to_string(), + } +} + +pub struct WireShapePlatform; + +#[truapi_platform::async_trait] +impl ProductStorage for WireShapePlatform { + async fn read(&self, _key: String) -> Result>, v01::HostLocalStorageReadError> { + Err(v01::HostLocalStorageReadError::Full) + } + async fn write( + &self, + _key: String, + _value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + Ok(()) + } + async fn clear(&self, _key: String) -> Result<(), v01::HostLocalStorageReadError> { + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl Navigation for WireShapePlatform { + async fn navigate_to(&self, _url: String) -> Result<(), v01::HostNavigateToError> { + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl Notifications for WireShapePlatform { + async fn push_notification( + &self, + _notification: v01::HostPushNotificationRequest, + ) -> Result { + Ok(v01::HostPushNotificationResponse { id: 0 }) + } + + async fn cancel_notification(&self, _id: u32) -> Result<(), v01::GenericError> { + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl Permissions for WireShapePlatform { + async fn device_permission( + &self, + _request: v01::HostDevicePermissionRequest, + ) -> Result { + Ok(v01::HostDevicePermissionResponse { granted: true }) + } + async fn remote_permission( + &self, + _request: v01::RemotePermissionRequest, + ) -> Result { + Ok(v01::RemotePermissionResponse { granted: true }) + } +} + +#[truapi_platform::async_trait] +impl Features for WireShapePlatform { + async fn feature_supported( + &self, + _request: v01::HostFeatureSupportedRequest, + ) -> Result { + Ok(v01::HostFeatureSupportedResponse { supported: true }) + } +} + +struct DeadConnection; + +impl JsonRpcConnection for DeadConnection { + fn send(&self, _request: String) {} + fn responses(&self) -> BoxStream<'static, String> { + Box::pin(stream::empty()) + } + fn close(&self) {} +} + +#[truapi_platform::async_trait] +impl ChainProvider for WireShapePlatform { + async fn connect( + &self, + _genesis_hash: Vec, + ) -> Result, v01::GenericError> { + Ok(Box::new(DeadConnection)) + } +} + +impl AuthPresenter for WireShapePlatform {} + +#[truapi_platform::async_trait] +impl CoreStorage for WireShapePlatform { + async fn read_core_storage( + &self, + _key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + Ok(None) + } + async fn write_core_storage( + &self, + _key: CoreStorageKey, + _value: Vec, + ) -> Result<(), v01::GenericError> { + Ok(()) + } + async fn clear_core_storage(&self, _key: CoreStorageKey) -> Result<(), v01::GenericError> { + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl UserConfirmation for WireShapePlatform { + async fn confirm_user_action( + &self, + _review: UserConfirmationReview, + ) -> Result { + Ok(false) + } +} + +impl ThemeHost for WireShapePlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + Box::pin(stream::empty()) + } +} + +#[truapi_platform::async_trait] +impl PreimageHost for WireShapePlatform { + async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + Ok(value) + } + fn lookup_preimage( + &self, + _key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + Box::pin(stream::empty()) + } +} diff --git a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs new file mode 100644 index 00000000..fbd0164b --- /dev/null +++ b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs @@ -0,0 +1,229 @@ +#![cfg(target_arch = "wasm32")] + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use hkdf::Hkdf; +use p256::SecretKey; +use p256::ecdh::diffie_hellman; +use p256::elliptic_curve::sec1::ToEncodedPoint; +use parity_scale_codec::{Decode, Encode}; +use schnorrkel::{ExpansionMode, MiniSecretKey}; +use sha2::Sha256; +use truapi_platform::{HostInfo, PlatformInfo, RuntimeConfig}; +use truapi_server::host_logic::entropy::derive_product_entropy; +use truapi_server::host_logic::product_account::{ + derive_product_public_key, product_public_key_to_address, +}; +use truapi_server::host_logic::session::SsoSessionInfo; +use truapi_server::host_logic::sso_pairing::{ + AES_GCM_NONCE_LEN, EncryptedHandshakeResponseV2, HandshakeMetadataEntry, HandshakeMetadataKey, + HandshakeSuccessV2, PairingBootstrap, SsoStatementData, VersionedHandshakeProposal, + VersionedHandshakeResponse, bootstrap_topic, build_pairing_deeplink, decode_app_handshake_data, + decrypt_session_statement_data, decrypt_v2_handshake_response, + encrypt_session_statement_data_with_nonce, establish_sso_session_info, +}; +use truapi_server::host_logic::statement_store::{ + build_signed_session_request_statement, decode_verified_statement_data, +}; +use wasm_bindgen_test::wasm_bindgen_test; + +const ROOT_PUBLIC_KEY: [u8; 32] = [ + 0x80, 0x05, 0x28, 0xc9, 0x55, 0x87, 0x3e, 0x4c, 0x78, 0xb7, 0xdf, 0x24, 0xf7, 0x1d, 0xb8, 0xf5, + 0x81, 0xaa, 0x99, 0xe3, 0x49, 0x3b, 0xf4, 0x96, 0xed, 0xf1, 0x51, 0xab, 0xc1, 0xd7, 0x20, 0x23, +]; + +const SS_PUBLIC: [u8; 32] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, +]; + +const ENC_PUBLIC: [u8; 65] = [ + 0x04, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, + 0x1f, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, + 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, + 0x3f, +]; + +fn entropy_secret() -> [u8; 32] { + std::array::from_fn(|i| i as u8) +} + +fn runtime_config() -> RuntimeConfig { + RuntimeConfig { + product_id: "dotli.dot".to_string(), + host_info: HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://example.invalid/dotli.png".to_string()), + version: Some("1.2.3".to_string()), + }, + platform_info: PlatformInfo { + kind: Some("Firefox".to_string()), + version: Some("192.32".to_string()), + }, + people_chain_genesis_hash: [0xa2; 32], + pairing_deeplink_scheme: "polkadotapp".to_string(), + } +} + +fn statement_session() -> SsoSessionInfo { + let mini_secret = MiniSecretKey::from_bytes(&[7; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + SsoSessionInfo { + ss_secret: keypair.secret.to_bytes(), + ss_public_key: keypair.public.to_bytes(), + enc_secret: [1; 32], + peer_enc_pubkey: [2; 65], + identity_account_id: [3; 32], + session_id_own: [4; 32], + session_id_peer: [5; 32], + request_channel: [6; 32], + response_channel: [7; 32], + peer_request_channel: [8; 32], + } +} + +fn sso_session() -> SsoSessionInfo { + let core_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let core_public = core_secret.public_key().to_encoded_point(false); + let bootstrap = PairingBootstrap { + deeplink: "polkadotapp://pair?handshake=00".to_string(), + topic: [0x11; 32], + statement_store_public_key: [0x22; 32], + statement_store_secret: [0x33; 64], + encryption_public_key: core_public.as_bytes().try_into().unwrap(), + encryption_secret_key: [1; 32], + }; + let peer_secret = SecretKey::from_slice(&[2; 32]).unwrap(); + let peer_sso_enc_pub_key = peer_secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(); + + establish_sso_session_info(&bootstrap, [0x55; 32], peer_sso_enc_pub_key).unwrap() +} + +#[wasm_bindgen_test] +fn product_account_and_entropy_vectors_match_dotli() { + let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", 0).unwrap(); + assert_eq!( + hex::encode(derived), + "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + ); + assert_eq!( + product_public_key_to_address(derived), + "5CyFsdhwjXy7wWpDEM6isungQ3LfGnu9UXkt7paBQ6DYRxk1" + ); + + let entropy = derive_product_entropy(&entropy_secret(), "myapp.dot", b"product-key").unwrap(); + assert_eq!( + hex::encode(entropy), + "ab1887248c9de3cf4b8c5a255782796d3d35a98c8eb2d7df61a410db8b14da36" + ); +} + +#[wasm_bindgen_test] +fn pairing_deeplink_topic_and_scale_vectors_match_dotli() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + assert!(deeplink.starts_with("polkadotapp://pair?handshake=01")); + let encoded = hex::decode(deeplink.split("handshake=").nth(1).unwrap()).unwrap(); + let decoded = VersionedHandshakeProposal::decode(&mut &encoded[..]).unwrap(); + let VersionedHandshakeProposal::V2(proposal) = decoded else { + panic!("expected V2 proposal"); + }; + assert_eq!(proposal.device.statement_account_id, SS_PUBLIC); + assert_eq!(proposal.device.encryption_public_key, ENC_PUBLIC); + assert!(proposal.metadata.contains(&HandshakeMetadataEntry( + HandshakeMetadataKey::HostName, + "Polkadot Web".to_string() + ))); + assert!(proposal.metadata.contains(&HandshakeMetadataEntry( + HandshakeMetadataKey::HostIcon, + "https://example.invalid/dotli.png".to_string() + ))); + assert_eq!( + hex::encode(bootstrap_topic(SS_PUBLIC, ENC_PUBLIC)), + "031c589833c39b1dfbe3c1304ced75fa7b0d841035db008e5b407bfadd2779a4" + ); + + let answer = VersionedHandshakeResponse::V2 { + encrypted_message: vec![0xde, 0xad], + public_key: ENC_PUBLIC, + }; + assert_eq!(decode_app_handshake_data(&answer.encode()).unwrap(), answer); +} + +#[wasm_bindgen_test] +fn p256_hkdf_aes_gcm_vectors_work_on_wasm() { + let core_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let wallet_ephemeral_secret = SecretKey::from_slice(&[2; 32]).unwrap(); + let wallet_ephemeral_public = wallet_ephemeral_secret.public_key().to_encoded_point(false); + + let shared_secret = diffie_hellman( + wallet_ephemeral_secret.to_nonzero_scalar(), + core_secret.public_key().as_affine(), + ); + let hkdf = Hkdf::::new(None, shared_secret.raw_secret_bytes()); + let mut aes_key = [0u8; 32]; + hkdf.expand(&[], &mut aes_key).unwrap(); + + let sensitive = EncryptedHandshakeResponseV2::Success(HandshakeSuccessV2 { + 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 nonce = [9u8; AES_GCM_NONCE_LEN]; + let cipher = Aes256Gcm::new_from_slice(&aes_key).unwrap(); + let mut encrypted = nonce.to_vec(); + encrypted.extend( + cipher + .encrypt(Nonce::from_slice(&nonce), sensitive.encode().as_slice()) + .unwrap(), + ); + + assert_eq!( + decrypt_v2_handshake_response( + core_secret.to_bytes().into(), + wallet_ephemeral_public.as_bytes().try_into().unwrap(), + &encrypted, + ) + .unwrap(), + sensitive + ); +} + +#[wasm_bindgen_test] +fn session_crypto_and_statement_proof_vectors_work_on_wasm() { + let session = sso_session(); + let data = SsoStatementData::Request { + request_id: "req-1".to_string(), + data: vec![vec![0xde, 0xad]], + }; + let nonce = [9u8; AES_GCM_NONCE_LEN]; + let encrypted = encrypt_session_statement_data_with_nonce(&session, &data, nonce).unwrap(); + + assert_eq!(&encrypted[..AES_GCM_NONCE_LEN], nonce); + assert_eq!( + SsoStatementData::decode(&mut &data.encode()[..]).unwrap(), + data + ); + assert_eq!( + decrypt_session_statement_data(&session, &encrypted).unwrap(), + data + ); + + let statement_session = statement_session(); + let statement = + build_signed_session_request_statement(&statement_session, vec![0xde, 0xad], 42).unwrap(); + let verified = + decode_verified_statement_data(&statement, Some(statement_session.ss_public_key)).unwrap(); + + assert_eq!(verified.signer, statement_session.ss_public_key); + assert_eq!(verified.data, vec![0xde, 0xad]); +} diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs new file mode 100644 index 00000000..a8da6f31 --- /dev/null +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -0,0 +1,525 @@ +//! Result-wire-shape regression test. +//! +//! The TS host/client codec expects every request response to be a +//! `Versioned>` envelope on the wire (one leading version byte, +//! then one result discriminant byte, then the SCALE-encoded value). This test stands up a +//! `TrUApiCore::from_platform_with_config` with a platform whose `Features` +//! impl returns `Ok(supported = true)` and asserts: +//! +//! - A `system_feature_supported_request` produces a response whose +//! payload begins with `0x00` (V1), then `0x00` (Ok), followed by the encoded +//! `HostFeatureSupportedResponse`. +//! - A `local_storage_read_request` whose stub returns +//! `Err(HostLocalStorageReadError::Full)` produces a response whose +//! payload begins with `0x00` (V1), then `0x01` (Err), followed by the encoded +//! `HostLocalStorageReadError::Full`. +//! +//! Both halves prove the wire layout stays in lockstep with the TS +//! `S.indexedTaggedUnion({ V1: S.Result(ok, err) })` codec. + +use std::sync::Arc; + +use parity_scale_codec::{Decode, Encode}; + +#[cfg(debug_assertions)] +use truapi::v02; +use truapi::versioned::system::HostFeatureSupportedRequest; +#[cfg(debug_assertions)] +use truapi::versioned::testing; +use truapi::versioned::{Versioned, account, payment, statement_store}; +use truapi::{CallError, v01}; + +use truapi_server::core::TrUApiCore; +use truapi_server::frame::{Payload, ProtocolMessage, request_ids, subscription_ids}; + +mod common; +use common::{RecordingTransport, WireShapePlatform, test_runtime_config, test_spawner}; + +const PAYMENTS_NOT_IMPLEMENTED: &str = "Payments are not supported in dot.li"; + +fn dispatch(core: &TrUApiCore, frame: ProtocolMessage) -> ProtocolMessage { + let encoded = frame.encode(); + let response_bytes = core + .receive_from_product(&encoded) + .expect("dispatcher emitted a response frame"); + ProtocolMessage::decode(&mut &response_bytes[..]).expect("decode response") +} + +#[test] +fn feature_supported_ok_response_uses_ok_discriminant() { + let core = make_core(); + let request = HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + }); + let ids = request_ids("system_feature_supported").expect("known request method"); + let frame = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }; + let response = dispatch(&core, frame); + assert_eq!(response.request_id, "p:1"); + assert_eq!(response.payload.id, ids.response_id); + + // Wire payload: [V1 disc=0x00][Ok disc=0x00][encoded response body]. + let mut expected = vec![0x00u8, 0x00u8]; + v01::HostFeatureSupportedResponse { supported: true }.encode_to(&mut expected); + assert_eq!(response.payload.value, expected); + assert_eq!(response.payload.value.first(), Some(&0x00)); + assert_eq!(response.payload.value.get(1), Some(&0x00)); +} + +#[cfg(debug_assertions)] +#[test] +fn testing_version_probe_v1_request_gets_v1_response() { + let core = make_core(); + let request = testing::TestingVersionProbeRequest::V1(v01::TestingVersionProbeRequest { + message: "hello V1".to_string(), + }); + let ids = request_ids("testing_version_probe").expect("known request method"); + let response = dispatch( + &core, + ProtocolMessage { + request_id: "p:testing-v1".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }, + ); + + let mut expected = vec![0x00u8, 0x00u8]; + v01::TestingVersionProbeResponse { + received_version: 1, + message: "hello V1".to_string(), + } + .encode_to(&mut expected); + assert_eq!(response.payload.value, expected); +} + +#[cfg(debug_assertions)] +#[test] +fn testing_version_probe_v2_request_gets_v2_response() { + let core = make_core(); + let request = testing::TestingVersionProbeRequest::V2(v02::TestingVersionProbeRequest { + message: "hello V2".to_string(), + marker: 42, + }); + let ids = request_ids("testing_version_probe").expect("known request method"); + let response = dispatch( + &core, + ProtocolMessage { + request_id: "p:testing-v2".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }, + ); + + let mut expected = vec![0x01u8, 0x00u8]; + v02::TestingVersionProbeResponse { + received_version: 2, + message: "hello V2".to_string(), + marker: 42, + } + .encode_to(&mut expected); + assert_eq!(response.payload.value, expected); +} + +#[cfg(debug_assertions)] +#[test] +fn testing_echo_error_uses_raw_result_shape() { + let core = make_core(); + let request = v01::EchoErrorRequest { + error: CallError::HostFailure { + reason: "forced by testing.echo_error".to_string(), + }, + }; + let ids = request_ids("testing_echo_error").expect("known request method"); + let response = dispatch( + &core, + ProtocolMessage { + request_id: "p:testing-framework".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }, + ); + + let mut expected = vec![0x01u8]; + CallError::::HostFailure { + reason: "forced by testing.echo_error".to_string(), + } + .encode_to(&mut expected); + assert_eq!(response.payload.value, expected); +} + +#[test] +fn local_storage_read_err_response_uses_err_discriminant() { + let core = make_core(); + let request = truapi::versioned::local_storage::HostLocalStorageReadRequest::V1( + v01::HostLocalStorageReadRequest { + key: "missing".to_string(), + }, + ); + let ids = request_ids("local_storage_read").expect("known request method"); + let frame = ProtocolMessage { + request_id: "p:2".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }; + let response = dispatch(&core, frame); + assert_eq!(response.request_id, "p:2"); + assert_eq!(response.payload.id, ids.response_id); + + // Wire payload: + // [V1 disc=0x00][Err disc=0x01][CallError::Domain][V1 error][encoded error body]. + let mut expected = vec![0x00u8, 0x01u8]; + CallError::Domain( + truapi::versioned::local_storage::HostLocalStorageReadError::V1( + v01::HostLocalStorageReadError::Full, + ), + ) + .encode_to(&mut expected); + assert_eq!(response.payload.value, expected); + assert_eq!(response.payload.value.first(), Some(&0x00)); + assert_eq!(response.payload.value.get(1), Some(&0x01)); +} + +fn versioned_result_err_payload(error: E) -> Vec +where + E: Clone + Encode + Versioned, +{ + let mut expected = vec![version_index(error.version()), 0x01u8]; + CallError::Domain(error).encode_to(&mut expected); + expected +} + +fn versioned_interrupt_err_payload(error: E) -> Vec +where + E: Clone + Encode + Versioned, +{ + let mut expected = vec![version_index(error.version())]; + CallError::Domain(error).encode_to(&mut expected); + expected +} + +fn assert_request_returns_domain_error( + core: &TrUApiCore, + request_id: &str, + method: &str, + value: Vec, + error: E, +) where + E: Clone + Encode + Versioned, +{ + let ids = request_ids(method).expect("known request method"); + let response = dispatch( + core, + ProtocolMessage { + request_id: request_id.into(), + payload: Payload { + id: ids.request_id, + value, + }, + }, + ); + assert_eq!(response.request_id, request_id); + assert_eq!(response.payload.id, ids.response_id); + assert_eq!(response.payload.value, versioned_result_err_payload(error)); +} + +fn assert_subscription_start_interrupts_error( + core: &TrUApiCore, + request_id: &str, + method: &str, + value: Vec, + error: E, +) where + E: Clone + Encode + Versioned, +{ + let ids = subscription_ids(method).expect("known subscription method"); + let transport = Arc::new(RecordingTransport::default()); + futures::executor::block_on(core.dispatch( + ProtocolMessage { + request_id: request_id.into(), + payload: Payload { + id: ids.start_id, + value, + }, + }, + transport.clone(), + )); + + let sent = transport.sent.lock().unwrap(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].request_id, request_id); + assert_eq!(sent[0].payload.id, ids.interrupt_id); + assert_eq!( + sent[0].payload.value, + versioned_interrupt_err_payload(error) + ); +} + +fn version_index(version: u8) -> u8 { + version.saturating_sub(1) +} + +#[test] +fn deferred_account_proof_returns_framework_unsupported() { + let core = make_core(); + let request = account::HostAccountCreateProofRequest::V1(v01::HostAccountCreateProofRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + ring_location: v01::RingLocation { + genesis_hash: vec![0u8; 32], + ring_root_hash: vec![1u8; 32], + hints: None, + }, + context: Vec::new(), + }); + + let ids = request_ids("account_create_account_proof").expect("known request method"); + let response = dispatch( + &core, + ProtocolMessage { + request_id: "p:account-proof".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }, + ); + assert_eq!(response.request_id, "p:account-proof"); + assert_eq!(response.payload.id, ids.response_id); + assert_eq!(response.payload.value, vec![0x00u8, 0x01u8, 0x02u8]); +} + +#[test] +fn deferred_payment_requests_return_dotli_not_implemented_errors() { + let core = make_core(); + let request = payment::HostPaymentRequest::V1(v01::HostPaymentRequest { + from: None, + amount: 1, + destination: [0u8; 32], + }); + + assert_request_returns_domain_error( + &core, + "p:payment", + "payment_request", + request.encode(), + payment::HostPaymentError::V1(v01::HostPaymentError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }), + ); + + let top_up = payment::HostPaymentTopUpRequest::V1(v01::HostPaymentTopUpRequest { + into: None, + amount: 1, + source: v01::PaymentTopUpSource::ProductAccount { + derivation_index: 0, + }, + }); + assert_request_returns_domain_error( + &core, + "p:top-up", + "payment_top_up", + top_up.encode(), + payment::HostPaymentTopUpError::V1(v01::HostPaymentTopUpError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }), + ); +} + +#[test] +fn deferred_payment_subscriptions_interrupt_dotli_not_implemented_errors() { + let core = make_core(); + let balance = + payment::HostPaymentBalanceSubscribeRequest::V1(v01::HostPaymentBalanceSubscribeRequest { + purse: None, + }); + assert_subscription_start_interrupts_error( + &core, + "p:balance", + "payment_balance_subscribe", + balance.encode(), + payment::HostPaymentBalanceSubscribeError::V1( + v01::HostPaymentBalanceSubscribeError::PermissionDenied, + ), + ); + + let status = + payment::HostPaymentStatusSubscribeRequest::V1(v01::HostPaymentStatusSubscribeRequest { + payment_id: "payment-id".to_string(), + }); + assert_subscription_start_interrupts_error( + &core, + "p:status", + "payment_status_subscribe", + status.encode(), + payment::HostPaymentStatusSubscribeError::V1( + v01::HostPaymentStatusSubscribeError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }, + ), + ); +} + +#[test] +fn statement_store_subscribe_topic_limit_interrupts_with_typed_error() { + let core = make_core(); + let request = statement_store::RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7u8; 32]; 129]), + ); + + assert_subscription_start_interrupts_error( + &core, + "p:ss-too-many", + "statement_store_subscribe", + request.encode(), + statement_store::RemoteStatementStoreSubscribeError::V1(v01::GenericError { + reason: "MatchAny has 129 topics, maximum is 128".to_string(), + }), + ); +} + +#[test] +fn malformed_result_subscription_start_interrupts_with_malformed_frame() { + let core = make_core(); + let method = "payment_balance_subscribe"; + let ids = subscription_ids(method).expect("known subscription method"); + let transport = Arc::new(RecordingTransport::default()); + + futures::executor::block_on(core.dispatch( + ProtocolMessage { + request_id: "p:malformed-sub".into(), + payload: Payload { + id: ids.start_id, + value: vec![0xff], + }, + }, + transport.clone(), + )); + + let sent = transport.sent.lock().unwrap(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].request_id, "p:malformed-sub"); + assert_eq!(sent[0].payload.id, ids.interrupt_id); + assert_eq!(sent[0].payload.value.first(), Some(&0x00)); + + let mut payload = &sent[0].payload.value[1..]; + let error = CallError::::decode(&mut payload) + .expect("decode malformed interrupt error"); + assert!(payload.is_empty()); + match error { + CallError::MalformedFrame { reason } => assert!(!reason.is_empty()), + other => panic!("expected MalformedFrame interrupt, got {other:?}"), + } +} + +fn make_core() -> TrUApiCore { + TrUApiCore::from_platform_with_config( + Arc::new(WireShapePlatform), + test_runtime_config(), + test_spawner(), + ) +} + +/// Untrusted product input that is not a decodable frame must be dropped +/// (return `None`), never panic. Exercises the decode-failure boundary in +/// `receive_from_product` that the happy-path tests above bypass. +#[test] +fn malformed_frames_are_dropped_without_panic() { + let core = make_core(); + + // Empty input and arbitrary garbage. + assert!(core.receive_from_product(&[]).is_none()); + assert!( + core.receive_from_product(&[0xff, 0xff, 0xff, 0xff]) + .is_none() + ); + + // A truncated SCALE string header (claims length but no body). + assert!( + core.receive_from_product(&[200u8 << 2, 0x61, 0x62]) + .is_none() + ); + + // A well-formed requestId envelope carrying an unknown wire discriminant. + let mut unknown_disc = Vec::new(); + "p:1".to_string().encode_to(&mut unknown_disc); + unknown_disc.push(0xFA); + unknown_disc.extend_from_slice(&[0u8; 4]); + assert!(core.receive_from_product(&unknown_disc).is_none()); +} + +/// Drive a subscription through the encoded-frame boundary: `_start` yields +/// the initial `_receive`, then `_stop` tears it down so a later session +/// change produces no further frames. Covers the wire layer the in-crate +/// `subscription.rs` unit tests bypass. +#[test] +fn subscription_start_receive_stop_through_wire_boundary() { + use std::time::{Duration, Instant}; + use truapi_server::transport::Transport; + + let core = make_core(); + let transport = Arc::new(RecordingTransport::default()); + let dyn_transport: Arc = transport.clone(); + + let method = "account_connection_status_subscribe"; + let ids = subscription_ids(method).expect("known subscription method"); + let start = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.start_id, + value: Vec::new(), + }, + }; + futures::executor::block_on(core.dispatch(start, dyn_transport.clone())); + + // Wait for the initial `_receive` item (Disconnected). + let deadline = Instant::now() + Duration::from_secs(2); + while transport.sent.lock().unwrap().is_empty() { + assert!(Instant::now() < deadline, "no initial _receive frame"); + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(transport.sent.lock().unwrap()[0].payload.id, ids.receive_id); + + // Stop the subscription, then push a session change. A live subscription + // would emit a Connected `_receive`; a stopped one must stay silent. + let stop = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.stop_id, + value: Vec::new(), + }, + }; + futures::executor::block_on(core.dispatch(stop, dyn_transport)); + std::thread::sleep(Duration::from_millis(50)); + + core.session_state() + .set_session(truapi_server::host_logic::session::SessionInfo { + public_key: [7u8; 32], + sso: None, + root_entropy_source: None, + identity_account_id: None, + lite_username: None, + full_username: None, + }); + std::thread::sleep(Duration::from_millis(50)); + + assert_eq!( + transport.sent.lock().unwrap().len(), + 1, + "stopped subscription must emit no further frames" + ); +} From 6aa9887d64504ae26ca3601ac872149b25b57583 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 1 Jul 2026 22:36:23 +0200 Subject: [PATCH 22/56] fixup! feat(truapi-server): add platform runtime and host bridge --- rust/crates/truapi-server/src/runtime.rs | 131 ++++++++++++++++++++++- 1 file changed, 127 insertions(+), 4 deletions(-) diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 3cb10358..193ffe50 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -645,13 +645,15 @@ impl PlatformRuntimeHost { &self, session: &SessionInfo, signer: &str, - ) -> Result<(), v01::HostSignPayloadError> { + ) -> Result<[u8; 32], v01::HostSignPayloadError> { let public_key = self .legacy_slot_zero_public_key(session) .map_err(|reason| v01::HostSignPayloadError::Unknown { reason })?; let expected = product_public_key_to_address(public_key); - if expected == signer { - Ok(()) + if expected == signer + || parse_legacy_signer_hex(signer).is_some_and(|key| key == public_key) + { + Ok(public_key) } else { Err(v01::HostSignPayloadError::Unknown { reason: "Account can't be derived from product account id".to_string(), @@ -677,6 +679,17 @@ impl PlatformRuntimeHost { } } +fn parse_legacy_signer_hex(signer: &str) -> Option<[u8; 32]> { + let raw = signer + .strip_prefix("0x") + .or_else(|| signer.strip_prefix("0X")) + .unwrap_or(signer); + if raw.len() != 64 { + return None; + } + hex::decode(raw).ok()?.try_into().ok() +} + /// Adapter from `truapi_platform::ChainProvider` into the /// [`RuntimeChainProvider`] surface the chain runtime expects. /// Reuses the platform-supplied json-rpc connection and converts the @@ -3012,11 +3025,60 @@ mod tests { crate::host_logic::sso::messages::RemoteMessageV1::SignRequest(request) ) if matches!( request.as_ref(), - crate::host_logic::sso::messages::SigningRequest::Raw(_) + crate::host_logic::sso::messages::SigningRequest::Raw(raw) + if raw.product_account_id == account_id("myapp.dot", 0) + && matches!( + &raw.data, + crate::host_logic::sso::messages::SigningRawPayload::Bytes(bytes) + if bytes == b"hello" + ) ) )); } + #[test] + fn legacy_sign_raw_accepts_derived_hex_then_returns_sso_response() { + let session = sso_session_info(); + let signer = derive_product_public_key(session.public_key, "myapp.dot", 0).unwrap(); + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + rpc_responses: sso_success_responses( + &session, + "legacy-sign-raw-hex-1", + sign_response_message("legacy-sign-raw-hex-1", vec![8, 8], None), + ), + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("legacy-sign-raw-hex-1".to_string()); + let request = + HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { + signer: format!("0x{}", hex::encode(signer)), + payload: raw_payload(), + }); + let response = + futures::executor::block_on(host.sign_raw_with_legacy_account(&cx, request)).unwrap(); + let HostSignRawWithLegacyAccountResponse::V1(inner) = response; + assert_eq!(inner.signature, vec![8, 8]); + + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::SignRequest(request), + ) = message.data + else { + panic!("expected raw signing request"); + }; + let crate::host_logic::sso::messages::SigningRequest::Raw(request) = *request else { + panic!("expected raw signing request"); + }; + assert_eq!(request.product_account_id, account_id("myapp.dot", 0)); + } + #[test] fn legacy_create_transaction_rejects_raw_key_mismatch() { let host = @@ -3042,6 +3104,67 @@ mod tests { } } + #[test] + fn legacy_create_transaction_accepts_derived_key_then_returns_sso_response() { + let session = sso_session_info(); + let signer = derive_product_public_key(session.public_key, "myapp.dot", 0).unwrap(); + let platform = Arc::new(StubPlatform { + create_transaction_confirmed: true, + rpc_responses: sso_success_responses( + &session, + "legacy-create-tx-1", + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-legacy-create-tx-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::CreateTransactionResponse( + crate::host_logic::sso::messages::CreateTransactionResponse { + responding_to: "legacy-create-tx-1".to_string(), + signed_transaction: Ok(vec![0xca, 0xfe]), + }, + ), + ), + }, + ), + ..Default::default() + }); + let host = PlatformRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("legacy-create-tx-1".to_string()); + let request = + HostCreateTransactionWithLegacyAccountRequest::V1(v01::LegacyAccountTxPayload { + signer, + genesis_hash: [1; 32], + call_data: vec![0], + extensions: vec![], + tx_ext_version: 0, + }); + + let response = + futures::executor::block_on(host.create_transaction_with_legacy_account(&cx, request)) + .unwrap(); + + let HostCreateTransactionWithLegacyAccountResponse::V1(inner) = response; + assert_eq!(inner.transaction, vec![0xca, 0xfe]); + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::CreateTransactionRequest(request), + ) = message.data + else { + panic!("expected product transaction request"); + }; + let crate::host_logic::sso::messages::CreateTransactionPayload::V1(payload) = + request.payload; + assert_eq!(payload.signer, account_id("myapp.dot", 0)); + assert_eq!( + signer, + derive_product_public_key(session.public_key, "myapp.dot", 0).unwrap() + ); + } + #[test] fn create_transaction_rejects_invalid_product_account() { let host = From 0f6e16cf2d56ce95eb37ab10ddd4556a62e88724 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 2 Jul 2026 10:11:52 +0200 Subject: [PATCH 23/56] fixup! feat(truapi-server): add platform runtime and host bridge --- rust/crates/truapi-server/src/runtime.rs | 5 - .../truapi-server/tests/wire_result_shape.rs | 91 ------------------- 2 files changed, 96 deletions(-) diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 193ffe50..2c176307 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -49,8 +49,6 @@ use futures::{FutureExt, StreamExt}; #[cfg(test)] use parity_scale_codec::Encode; use tracing::{debug, info, instrument}; -#[cfg(debug_assertions)] -use truapi::api::Testing; use truapi::api::{ Account, Chain, Chat, CoinPayment, Entropy, LocalStorage, Notifications, Payment, Permissions, Preimage, ResourceAllocation, Signing, System, Theme, @@ -1949,9 +1947,6 @@ impl Theme for PlatformRuntimeHost { } } -#[cfg(debug_assertions)] -impl Testing for PlatformRuntimeHost {} - // `Notifications` delegates to the platform so hosts can own scheduling and // cancellation while the core preserves the typed TrUAPI wire shape. impl Notifications for PlatformRuntimeHost { diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs index a8da6f31..ba9debc1 100644 --- a/rust/crates/truapi-server/tests/wire_result_shape.rs +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -21,11 +21,7 @@ use std::sync::Arc; use parity_scale_codec::{Decode, Encode}; -#[cfg(debug_assertions)] -use truapi::v02; use truapi::versioned::system::HostFeatureSupportedRequest; -#[cfg(debug_assertions)] -use truapi::versioned::testing; use truapi::versioned::{Versioned, account, payment, statement_store}; use truapi::{CallError, v01}; @@ -71,93 +67,6 @@ fn feature_supported_ok_response_uses_ok_discriminant() { assert_eq!(response.payload.value.get(1), Some(&0x00)); } -#[cfg(debug_assertions)] -#[test] -fn testing_version_probe_v1_request_gets_v1_response() { - let core = make_core(); - let request = testing::TestingVersionProbeRequest::V1(v01::TestingVersionProbeRequest { - message: "hello V1".to_string(), - }); - let ids = request_ids("testing_version_probe").expect("known request method"); - let response = dispatch( - &core, - ProtocolMessage { - request_id: "p:testing-v1".into(), - payload: Payload { - id: ids.request_id, - value: request.encode(), - }, - }, - ); - - let mut expected = vec![0x00u8, 0x00u8]; - v01::TestingVersionProbeResponse { - received_version: 1, - message: "hello V1".to_string(), - } - .encode_to(&mut expected); - assert_eq!(response.payload.value, expected); -} - -#[cfg(debug_assertions)] -#[test] -fn testing_version_probe_v2_request_gets_v2_response() { - let core = make_core(); - let request = testing::TestingVersionProbeRequest::V2(v02::TestingVersionProbeRequest { - message: "hello V2".to_string(), - marker: 42, - }); - let ids = request_ids("testing_version_probe").expect("known request method"); - let response = dispatch( - &core, - ProtocolMessage { - request_id: "p:testing-v2".into(), - payload: Payload { - id: ids.request_id, - value: request.encode(), - }, - }, - ); - - let mut expected = vec![0x01u8, 0x00u8]; - v02::TestingVersionProbeResponse { - received_version: 2, - message: "hello V2".to_string(), - marker: 42, - } - .encode_to(&mut expected); - assert_eq!(response.payload.value, expected); -} - -#[cfg(debug_assertions)] -#[test] -fn testing_echo_error_uses_raw_result_shape() { - let core = make_core(); - let request = v01::EchoErrorRequest { - error: CallError::HostFailure { - reason: "forced by testing.echo_error".to_string(), - }, - }; - let ids = request_ids("testing_echo_error").expect("known request method"); - let response = dispatch( - &core, - ProtocolMessage { - request_id: "p:testing-framework".into(), - payload: Payload { - id: ids.request_id, - value: request.encode(), - }, - }, - ); - - let mut expected = vec![0x01u8]; - CallError::::HostFailure { - reason: "forced by testing.echo_error".to_string(), - } - .encode_to(&mut expected); - assert_eq!(response.payload.value, expected); -} - #[test] fn local_storage_read_err_response_uses_err_discriminant() { let core = make_core(); From 80ff2c7ccf20bb019c2a84af4dffd976367bcde3 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 2 Jul 2026 10:58:24 +0200 Subject: [PATCH 24/56] fixup! feat(truapi-server): add platform runtime and host bridge --- rust/crates/truapi-server/src/runtime.rs | 21 ++++++++++++------- .../src/runtime/statement_store_rpc.rs | 2 +- rust/crates/truapi-server/src/test_support.rs | 17 ++++++++------- rust/crates/truapi-server/src/wasm.rs | 6 ++++-- rust/crates/truapi-server/tests/common/mod.rs | 17 ++++++++------- .../tests/wasm_crypto_vectors.rs | 15 ++++++------- 6 files changed, 45 insertions(+), 33 deletions(-) diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 2c176307..d210df4c 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -195,17 +195,18 @@ impl PlatformRuntimeHost { fn new_compat(platform: Arc, spawner: Spawner) -> Self { Self::new( platform, - RuntimeConfig { - product_id: "unknown.dot".to_string(), - host_info: truapi_platform::HostInfo { + RuntimeConfig::new( + "unknown.dot".to_string(), + truapi_platform::HostInfo { name: "Polkadot Web".to_string(), icon: Some("https://example.invalid/dotli.png".to_string()), version: None, }, - platform_info: truapi_platform::PlatformInfo::default(), - people_chain_genesis_hash: [0; 32], - pairing_deeplink_scheme: "polkadotapp".to_string(), - }, + truapi_platform::PlatformInfo::default(), + [0; 32], + "polkadotapp".to_string(), + ) + .expect("compat runtime config is valid"), spawner, ) } @@ -703,6 +704,12 @@ impl RuntimeChainProvider for HostChainProvider { &self, genesis_hash: Vec, ) -> Result, RuntimeFailure> { + let genesis_hash: [u8; 32] = genesis_hash.try_into().map_err(|genesis_hash: Vec| { + RuntimeFailure::host_failure( + "remote_chain_connect", + format!("genesis_hash must be 32 bytes, got {}", genesis_hash.len()), + ) + })?; self.platform .connect(genesis_hash) .await 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 508ae8a4..268c8129 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs @@ -70,7 +70,7 @@ impl StatementStoreRpc { async fn connect(&self, label: &'static str) -> Result, String> { self.platform - .connect(self.people_chain_genesis_hash.to_vec()) + .connect(self.people_chain_genesis_hash) .await .map(Arc::from) .map_err(|err| format!("{label} connect failed: {err:?}")) diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 794eed56..d21ffa80 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -162,17 +162,18 @@ pub(crate) fn stub_platform() -> Arc { /// Runtime configuration used by platform-backed runtime tests. pub(crate) fn runtime_config(product_id: &str) -> RuntimeConfig { - RuntimeConfig { - product_id: product_id.to_string(), - host_info: HostInfo { + RuntimeConfig::new( + product_id.to_string(), + HostInfo { name: "Polkadot Web".to_string(), icon: Some("https://example.invalid/dotli.png".to_string()), version: None, }, - platform_info: PlatformInfo::default(), - people_chain_genesis_hash: [0; 32], - pairing_deeplink_scheme: "polkadotapp".to_string(), - } + PlatformInfo::default(), + [0; 32], + "polkadotapp".to_string(), + ) + .expect("test runtime config is valid") } /// Basic connected session fixture without SSO channel material. @@ -984,7 +985,7 @@ fn json_rpc_id(frame: &str) -> Option { impl ChainProvider for StubPlatform { async fn connect( &self, - _genesis_hash: Vec, + _genesis_hash: [u8; 32], ) -> Result, v01::GenericError> { if let Some(reason) = self.chain_connect_error { return Err(v01::GenericError { diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index dae2920a..e981b352 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -211,7 +211,7 @@ impl ProductStorage for WasmPlatform { impl ChainProvider for WasmPlatform { async fn connect( &self, - genesis_hash: Vec, + genesis_hash: [u8; 32], ) -> Result, v01::GenericError> { let chain_connect = match self.bridge.chain_connect.clone() { Some(f) => f, @@ -317,7 +317,9 @@ impl UserConfirmation for WasmPlatform { review: UserConfirmationReview, ) -> Result { let Some(fn_) = self.bridge.confirm_user_action.as_ref() else { - return Ok(false); + return Err(generic( + "confirmUserAction callback not provided by host".to_string(), + )); }; invoke_bool(fn_, review.encode()).await.map_err(generic) } diff --git a/rust/crates/truapi-server/tests/common/mod.rs b/rust/crates/truapi-server/tests/common/mod.rs index a4013e3b..7f6eee17 100644 --- a/rust/crates/truapi-server/tests/common/mod.rs +++ b/rust/crates/truapi-server/tests/common/mod.rs @@ -47,17 +47,18 @@ pub fn test_spawner() -> truapi_server::subscription::Spawner { /// Runtime configuration shared by integration tests. pub fn test_runtime_config() -> RuntimeConfig { - RuntimeConfig { - product_id: "dotli.dot".to_string(), - host_info: HostInfo { + RuntimeConfig::new( + "dotli.dot".to_string(), + HostInfo { name: "Polkadot Web".to_string(), icon: Some("https://dot.li/dotli.png".to_string()), version: None, }, - platform_info: PlatformInfo::default(), - people_chain_genesis_hash: [0xa2; 32], - pairing_deeplink_scheme: "polkadotapp".to_string(), - } + PlatformInfo::default(), + [0xa2; 32], + "polkadotapp".to_string(), + ) + .expect("test runtime config is valid") } pub struct WireShapePlatform; @@ -140,7 +141,7 @@ impl JsonRpcConnection for DeadConnection { impl ChainProvider for WireShapePlatform { async fn connect( &self, - _genesis_hash: Vec, + _genesis_hash: [u8; 32], ) -> Result, v01::GenericError> { Ok(Box::new(DeadConnection)) } diff --git a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs index fbd0164b..df531ba1 100644 --- a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs +++ b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs @@ -50,20 +50,21 @@ fn entropy_secret() -> [u8; 32] { } fn runtime_config() -> RuntimeConfig { - RuntimeConfig { - product_id: "dotli.dot".to_string(), - host_info: HostInfo { + RuntimeConfig::new( + "dotli.dot".to_string(), + HostInfo { name: "Polkadot Web".to_string(), icon: Some("https://example.invalid/dotli.png".to_string()), version: Some("1.2.3".to_string()), }, - platform_info: PlatformInfo { + PlatformInfo { kind: Some("Firefox".to_string()), version: Some("192.32".to_string()), }, - people_chain_genesis_hash: [0xa2; 32], - pairing_deeplink_scheme: "polkadotapp".to_string(), - } + [0xa2; 32], + "polkadotapp".to_string(), + ) + .expect("test runtime config is valid") } fn statement_session() -> SsoSessionInfo { From c6091f0ac95f39be45ccbbd3bc8b908cf88d86a9 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 17:14:42 +0200 Subject: [PATCH 25/56] fixup! feat(truapi-server): add platform runtime and host bridge --- Cargo.lock | 1 + rust/crates/truapi-server/Cargo.toml | 4 + rust/crates/truapi-server/README.md | 101 ++ rust/crates/truapi-server/src/core.rs | 252 +-- rust/crates/truapi-server/src/host_core.rs | 400 ++++- rust/crates/truapi-server/src/lib.rs | 20 +- rust/crates/truapi-server/src/runtime.rs | 1557 +++++++---------- .../truapi-server/src/runtime/authority.rs | 225 +++ .../truapi-server/src/runtime/identity.rs | 67 +- .../truapi-server/src/runtime/pairing_host.rs | 565 ++++++ .../src/runtime/pairing_host/sso_channel.rs | 488 ++++++ .../truapi-server/src/runtime/services.rs | 77 + .../truapi-server/src/runtime/signing_host.rs | 616 +++++++ .../runtime/signing_host/local_activation.rs | 47 + .../truapi-server/src/runtime/sso_pairing.rs | 327 ++-- .../truapi-server/src/runtime/sso_remote.rs | 234 +-- .../src/runtime/statement_store.rs | 53 +- .../src/runtime/statement_store_rpc.rs | 17 +- rust/crates/truapi-server/src/test_support.rs | 133 +- rust/crates/truapi-server/src/wasm.rs | 338 ++-- rust/crates/truapi-server/tests/common/mod.rs | 31 +- .../tests/wasm_crypto_vectors.rs | 28 +- .../truapi-server/tests/wire_result_shape.rs | 17 +- rust/crates/truapi/src/lib.rs | 18 +- 24 files changed, 3828 insertions(+), 1788 deletions(-) create mode 100644 rust/crates/truapi-server/src/runtime/authority.rs create mode 100644 rust/crates/truapi-server/src/runtime/pairing_host.rs create mode 100644 rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs create mode 100644 rust/crates/truapi-server/src/runtime/services.rs create mode 100644 rust/crates/truapi-server/src/runtime/signing_host.rs create mode 100644 rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs diff --git a/Cargo.lock b/Cargo.lock index 4b514a60..5004eda9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3307,6 +3307,7 @@ dependencies = [ "wasm-bindgen-test", "web-sys", "web-time", + "zeroize", ] [[package]] diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index a4a86a0c..648fed22 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -11,6 +11,9 @@ crate-type = ["rlib", "cdylib"] [features] default = [] +[lints.rust] +unsafe_code = "forbid" + [dependencies] truapi = { path = "../truapi" } truapi-platform = { path = "../truapi-platform" } @@ -31,6 +34,7 @@ sp-crypto-hashing = { version = "0.1", default-features = false } bs58 = { version = "0.5", default-features = false, features = ["alloc"] } schnorrkel = { version = "0.11.5", default-features = false, features = ["alloc", "getrandom"] } substrate-bip39 = { version = "0.6", default-features = false } +zeroize = { version = "1", default-features = false, features = ["alloc"] } getrandom = { version = "0.2", features = ["js"] } p256 = { version = "0.13", default-features = false, features = ["ecdh"] } hkdf = "0.12" diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index c4a91856..a81f6e4b 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -13,6 +13,107 @@ _Runtime core for TrUAPI: dispatcher, protocol frames, SCALE-coded wire envelope - the [`Transport`] trait that platform-specific IPC backends implement - the auto-generated dispatcher/wire-table tables shipped under [`crate::generated`] +- the host embedding surface: one long-lived role handle + (`PairingHostRuntime` or `SigningHostRuntime`) per host application, exposing + shared [`RuntimeServices`] plus one [`ProductRuntime`] per product connection + +## Architecture + +Two ownership bands. A **per-product-connection** band (byte frames → +dispatcher → role-neutral product runtime) is minted once per host↔product +connection by the role handle and lives for that product's whole session; a +**shared per-host** band owns role-neutral infrastructure (`RuntimeServices`) +and the role object (`PairingHost` or `SigningHost`), which is itself the +`ProductAuthority`. Pure `host_logic` is a no-I/O library both bands call, not a +stage in the frame path; the host's `Platform` impl is the syscall floor. + +```text + ┌───────────────────────────────────────────────────────┐ + │ product sandboxed iframe · native WebView │ + └───────────────────────────────────────────────────────┘ + │ ▲ + SCALE frames │ │ MessageChannel · loopback + both directions ▼ │ WS + ┌───────────────────────────────────────────────────────┐ + │ binding layer : wasm.rs ( browser / node ) │ + │ thin byte bridge · no protocol logic │ + └───────────────────────────────────────────────────────┘ + + ══ per host→product connection ( one per connected product ) ══ + ┌───────────────────────────────────────────────────────┐ + │ ProductRuntime frame endpoint │ + │ decode each SCALE frame → dispatch one typed call │ + └───────────────────────────────────────────────────────┘ + │ typed method call + ▼ + ┌───────────────────────────────────────────────────────┐ + │ ProductRuntimeHost role-neutral │ + │ validate · permission-gate · confirm │ + └───────────────────────────────────────────────────────┘ + │ wallet-authority tail : + │ sign · alias · entropy · alloc + │ via Arc + ▼ + + ══ shared per host app ( one per host, all connections ) ══════ + the PairingHostRuntime | SigningHostRuntime handle owns both: + ┌─────────────────────────────┐ ┌────────────────────────┐ + │ role = ProductAuthority │ │ RuntimeServices │ + │ PairingHost | SigningHost │ │ platform · chain · RPC │ + └─────────────────────────────┘ └────────────────────────┘ + │ + │ PairingHost only : encrypted SSO channel + ▼ + ┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ + ╎ remote signing host ( external wallet ) ╎ + └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ + + both bands call host_logic for pure work, never traverse it : + ┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ + ╎ host_logic pure library ( no I/O ) ╎ + ╎ crypto · codecs · derivation · policy ╎ + └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ + + ══ host-owned floor · where every I/O above bottoms out ═══════ + ┌───────────────────────────────────────────────────────┐ + │ Platform impl ( TS / Swift / Kotlin ) │ + │ storage · prompts · chain RPC · navigation │ + └───────────────────────────────────────────────────────┘ +``` + +`ProductRuntimeHost` handles everything role-neutral (id normalization, +permission gating, confirmation, soft product-key derivation), then delegates +the wallet-authority tail (`sign_*`, `create_transaction`, `account_alias`, +`allocate_resources`, `derive_entropy`) through an `Arc` +handle with an `AuthoritySession` snapshot the role revalidates before touching +key material. + +The embedder builds a role handle, `PairingHostRuntime::new(...)` or +`SigningHostRuntime::new(...)`, then calls `product_runtime(product, sink)` for +each product connection. Role-specific operations live only on the matching handle: +`cancel_pairing` and `notify_session_store_changed` on the pairing handle, +`activate_local_session` on the signing handle. Calling the wrong operation is +a compile error, not a runtime `Unavailable`. + +### The two roles + +Both implement the role-neutral **`ProductAuthority`** trait; each owns its +role-specific lifecycle, so no method exists on a role that can't mean it: + +- **`PairingHost`** (seedless): the user's keys live in an external wallet, so + signing/aliases/entropy relay over an encrypted SSO channel (statement store + on the People chain; the channel lives in `pairing_host/sso_channel.rs`). It + owns pairing/login state, persisted auth-session reload, and remote + signing-host liveness monitoring. +- **`SigningHost`** (wallet-local): signs on device from local BIP-39 entropy, + no pairing flow. `signing_host/local_activation.rs` establishes a session + from host-held secret material. Extrinsic signing / transaction construction / + ring-VRF aliases / resource allocation currently return `Unavailable` pending + chain-metadata and on-chain support. + +`host_logic` stays pure: the orchestrators above call into it for codecs, +session/SSO crypto, key derivation, and permission policy, while all I/O +(statement-store RPC, storage, prompts, chain RPC) stays in the layers above. ## Wire envelope diff --git a/rust/crates/truapi-server/src/core.rs b/rust/crates/truapi-server/src/core.rs index 16a4c373..85ff7ea3 100644 --- a/rust/crates/truapi-server/src/core.rs +++ b/rust/crates/truapi-server/src/core.rs @@ -1,66 +1,27 @@ //! Internal dispatcher/runtime core. //! -//! Public host adapters should wrap this through [`crate::HostCore`], which +//! Public host adapters should wrap this through [`crate::ProductRuntime`], which //! owns the stable byte-frame ingress/egress and lifecycle API. use std::sync::{Arc, Mutex}; -use futures::future::BoxFuture; use parity_scale_codec::{Decode, Encode}; use tracing::instrument; use truapi::api::TrUApi; -use truapi::v01; -use truapi_platform::{Platform, RuntimeConfig}; +use truapi_platform::{PairingHostConfig, Platform, ProductContext}; use crate::dispatcher::Dispatcher; use crate::frame::ProtocolMessage; use crate::generated::dispatcher; use crate::host_logic::session::SessionState; -use crate::host_logic::session_store::SessionStoreChangeNotifier; -use crate::runtime::PlatformRuntimeHost; +use crate::runtime::{PairingHostRole, ProductAuthority, ProductRuntimeHost, RuntimeServices}; use crate::subscription::Spawner; use crate::transport::Transport; -use truapi_platform::{PermissionAuthorizationRequest, PermissionAuthorizationStatus}; -type DisconnectFn = Arc BoxFuture<'static, ()> + Send + Sync>; -type CancelLoginFn = Arc; -type PermissionAuthorizationStatusFn = Arc< - dyn Fn( - PermissionAuthorizationRequest, - ) -> BoxFuture<'static, Result> - + Send - + Sync, ->; -type PermissionAuthorizationStatusesFn = Arc< - dyn Fn( - Vec, - ) - -> BoxFuture<'static, Result, v01::GenericError>> - + Send - + Sync, ->; -type SetPermissionAuthorizationStatusFn = Arc< - dyn Fn( - PermissionAuthorizationRequest, - PermissionAuthorizationStatus, - ) -> BoxFuture<'static, Result<(), v01::GenericError>> - + Send - + Sync, ->; - -/// Top-level core. Owns the dispatcher and, on the platform path, the shared -/// session-state holder. +/// Top-level core. Owns the generated dispatcher. pub struct TrUApiCore { dispatcher: Dispatcher, - /// Always present; empty for [`Self::new`] (direct host path), connected - /// to a [`PlatformRuntimeHost`] for [`Self::from_platform_with_config`]. session_state: Arc, - session_store_changes: Arc, - disconnect: DisconnectFn, - cancel_login: CancelLoginFn, - permission_authorization_status: PermissionAuthorizationStatusFn, - permission_authorization_statuses: PermissionAuthorizationStatusesFn, - set_permission_authorization_status: SetPermissionAuthorizationStatusFn, } impl TrUApiCore { @@ -75,103 +36,62 @@ impl TrUApiCore { { let mut dispatcher = Dispatcher::new(spawner); dispatcher::register(&mut dispatcher, host); - let session_state = SessionState::new(); - let session_store_changes = SessionStoreChangeNotifier::new(); - let disconnect_state = session_state.clone(); Self { dispatcher, - session_state, - session_store_changes, - disconnect: Arc::new(move || { - let state = disconnect_state.clone(); - Box::pin(async move { - state.clear_session(); - }) - }), - cancel_login: Arc::new(|| {}), - permission_authorization_status: Arc::new(|_| { - Box::pin(async { - Err(v01::GenericError { - reason: - "permission authorization is only available on platform-backed cores" - .into(), - }) - }) - }), - permission_authorization_statuses: Arc::new(|_| { - Box::pin(async { - Err(v01::GenericError { - reason: - "permission authorization is only available on platform-backed cores" - .into(), - }) - }) - }), - set_permission_authorization_status: Arc::new(|_, _| { - Box::pin(async { - Err(v01::GenericError { - reason: - "permission authorization is only available on platform-backed cores" - .into(), - }) - }) - }), + session_state: SessionState::new(), } } - /// Build a core around a [`Platform`] implementation and explicit product - /// runtime configuration. + /// Build a product-facing core around a [`Platform`] implementation, + /// explicit host runtime config, and product context. #[instrument(skip_all, fields(runtime.method = "core.from_platform_with_config"))] pub fn from_platform_with_config

( platform: Arc

, - runtime_config: RuntimeConfig, + host_config: PairingHostConfig, + product: ProductContext, spawner: Spawner, ) -> Self where P: Platform + 'static, { - let runtime = Arc::new(PlatformRuntimeHost::new( + let platform: Arc = platform; + let services = RuntimeServices::new( platform, - runtime_config, + host_config.people_chain_genesis_hash, spawner.clone(), + ); + let pairing_host = PairingHostRole::new(services.clone(), host_config); + pairing_host.clone().start_session_store_sync(spawner); + Self::from_runtime_parts(services, pairing_host, product) + } + + /// Build a product-facing core from shared services and authority. + #[instrument(skip_all, fields(runtime.method = "core.from_runtime_parts"))] + pub(crate) fn from_runtime_parts( + services: Arc, + authority: Arc, + product: ProductContext, + ) -> Self { + let runtime = Arc::new(ProductRuntimeHost::from_services( + services.clone(), + authority.clone(), + product, )); - runtime.start_session_store_sync(spawner.clone()); - let session_state = runtime.session_state(); - let session_store_changes = runtime.session_store_changes(); - let disconnect_runtime = runtime.clone(); - let cancel_login_runtime = runtime.clone(); - let permission_status_runtime = runtime.clone(); - let permission_statuses_runtime = runtime.clone(); - let set_permission_status_runtime = runtime.clone(); + Self::from_product_runtime(runtime, services.spawner.clone(), authority.session_state()) + } + + /// Build a dispatcher core around an already-created product runtime. + #[instrument(skip_all, fields(runtime.method = "core.from_product_runtime"))] + pub(crate) fn from_product_runtime( + runtime: Arc, + spawner: Spawner, + session_state: Arc, + ) -> Self { let mut dispatcher = Dispatcher::new(spawner); dispatcher::register(&mut dispatcher, runtime); Self { dispatcher, session_state, - session_store_changes, - disconnect: Arc::new(move || { - let runtime = disconnect_runtime.clone(); - Box::pin(async move { - runtime.disconnect().await; - }) - }), - cancel_login: Arc::new(move || cancel_login_runtime.cancel_login()), - permission_authorization_status: Arc::new(move |request| { - let runtime = permission_status_runtime.clone(); - Box::pin(async move { runtime.permission_authorization_status(request).await }) - }), - permission_authorization_statuses: Arc::new(move |requests| { - let runtime = permission_statuses_runtime.clone(); - Box::pin(async move { runtime.permission_authorization_statuses(requests).await }) - }), - set_permission_authorization_status: Arc::new(move |request, status| { - let runtime = set_permission_status_runtime.clone(); - Box::pin(async move { - runtime - .set_permission_authorization_status(request, status) - .await - }) - }), } } @@ -182,70 +102,10 @@ impl TrUApiCore { self.session_state.clone() } - /// Notify the platform-backed session sync loop that the host-global auth - /// session slot may have changed. - #[instrument(skip_all, fields(runtime.method = "core.notify_session_store_changed"))] - pub fn notify_session_store_changed(&self) { - self.session_store_changes.notify(); - } - - /// Core-owned logout/disconnect. Platform-backed cores best-effort notify - /// the SSO peer and clear the host-global auth session; direct cores only - /// clear their in-memory session state. - #[instrument(skip_all, fields(runtime.method = "core.disconnect"))] - pub async fn disconnect_async(&self) { - (self.disconnect)().await; - } - - /// Blocking wrapper for embedders that do not drive async directly. - #[instrument(skip_all, fields(runtime.method = "core.disconnect_blocking"))] - pub fn disconnect(&self) { - futures::executor::block_on(self.disconnect_async()); - } - - /// Cancel any in-flight `request_login` pairing. The host UI receives a - /// `Disconnected` auth state immediately and the pending login resolves - /// to `Rejected`. A no-op when no login is in progress (and always a - /// no-op on the direct host path). - #[instrument(skip_all, fields(runtime.method = "core.cancel_login"))] - pub fn cancel_login(&self) { - (self.cancel_login)(); - } - - /// Read a stored permission authorization status without prompting. - #[instrument(skip_all, fields(runtime.method = "core.permission_authorization_status"))] - pub async fn permission_authorization_status( - &self, - request: PermissionAuthorizationRequest, - ) -> Result { - (self.permission_authorization_status)(request).await - } - - /// Read stored permission authorization statuses without prompting. - #[instrument(skip_all, fields(runtime.method = "core.permission_authorization_statuses"))] - pub async fn permission_authorization_statuses( - &self, - requests: Vec, - ) -> Result, v01::GenericError> { - (self.permission_authorization_statuses)(requests).await - } - - /// Update a stored permission authorization status. `NotDetermined` - /// clears the stored value so the next product request prompts again. - #[instrument(skip_all, fields(runtime.method = "core.set_permission_authorization_status"))] - pub async fn set_permission_authorization_status( - &self, - request: PermissionAuthorizationRequest, - status: PermissionAuthorizationStatus, - ) -> Result<(), v01::GenericError> { - (self.set_permission_authorization_status)(request, status).await - } - - /// Asynchronous form of [`Self::receive_from_product`]. Decodes the - /// incoming frame, runs it through the dispatcher, and returns the - /// SCALE-encoded response (if any). + /// Decode an incoming product frame, run it through the dispatcher, and + /// return the SCALE-encoded response frame when the method has one. #[instrument(skip_all, fields(runtime.method = "core.receive_from_product"))] - pub async fn receive_from_product_async(&self, frame: &[u8]) -> Option> { + pub async fn receive_from_product(&self, frame: &[u8]) -> Option> { let message = ProtocolMessage::decode(&mut &*frame).ok()?; let transport = Arc::new(ResponseTransport::default()); self.dispatcher @@ -254,14 +114,6 @@ impl TrUApiCore { transport.take().map(|response| response.encode()) } - /// Synchronous wrapper that blocks the current thread until the inner - /// future resolves. Convenient for embedding contexts that don't already - /// drive an async runtime. - #[instrument(skip_all, fields(runtime.method = "core.receive_from_product_blocking"))] - pub fn receive_from_product(&self, frame: &[u8]) -> Option> { - futures::executor::block_on(self.receive_from_product_async(frame)) - } - /// Dispatch an already-decoded protocol message through the underlying /// dispatcher. Bridges that own a long-lived transport (e.g. WebSocket, /// JS callback) call this directly so subscription items flow back @@ -275,8 +127,7 @@ impl TrUApiCore { /// Single-slot transport that captures the next response the dispatcher /// emits. Used by [`TrUApiCore::receive_from_product`] to bridge between the -/// dispatcher's push model and the synchronous "decode in, encoded out" -/// shape exposed to embedders. +/// dispatcher's push model and the one-response frame API exposed to embedders. #[derive(Default)] struct ResponseTransport { response: Mutex>, @@ -318,9 +169,11 @@ mod tests { #[test] fn from_platform_dispatches_feature_supported() { + let (host_config, product) = runtime_config("dotli.dot"); let core = TrUApiCore::from_platform_with_config( Arc::new(StubPlatform::default()), - runtime_config("dotli.dot"), + host_config, + product, test_spawner(), ); let request = HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Chain { @@ -335,8 +188,7 @@ mod tests { }, }; let encoded = frame.encode(); - let response_bytes = core - .receive_from_product(&encoded) + let response_bytes = futures::executor::block_on(core.receive_from_product(&encoded)) .expect("dispatcher should emit a response"); let response = ProtocolMessage::decode(&mut &response_bytes[..]).expect("decode response"); assert_eq!(response.request_id, "p:1"); @@ -359,9 +211,9 @@ mod tests { value: request_bytes, }, }; - let response_bytes = core - .receive_from_product(&frame.encode()) - .expect("dispatcher should emit a response"); + let response_bytes = + futures::executor::block_on(core.receive_from_product(&frame.encode())) + .expect("dispatcher should emit a response"); let response = ProtocolMessage::decode(&mut &response_bytes[..]).expect("decode response"); assert_eq!(response.request_id, "p:1"); assert_eq!(response.payload.id, ids.response_id); @@ -369,9 +221,11 @@ mod tests { } fn make_core() -> TrUApiCore { + let (host_config, product) = runtime_config("dotli.dot"); TrUApiCore::from_platform_with_config( Arc::new(StubPlatform::default()), - runtime_config("dotli.dot"), + host_config, + product, test_spawner(), ) } diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index aa0426ab..37e5ce7e 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -1,10 +1,13 @@ //! Stable host-embedding API for the TrUAPI server runtime. //! -//! `HostCore` is the target-neutral boundary embedders should use. Platform -//! adapters provide a [`truapi_platform::Platform`] implementation, a task -//! [`Spawner`], and a [`FrameSink`] for outgoing protocol frames. Target-specific -//! shells such as wasm-bindgen, iOS FFI, or desktop IPC should keep their -//! conversion code outside this module. +//! `ProductRuntime` is the target-neutral boundary embedders should use. +//! Platform adapters provide: +//! - a [`truapi_platform::Platform`] implementation for host callbacks, +//! - a task [`Spawner`] for runtime-owned async work, +//! - a [`FrameSink`] for outgoing protocol frames. +//! +//! Target-specific shells such as wasm-bindgen, iOS FFI, or desktop IPC should +//! keep their conversion code outside this module. use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -16,11 +19,16 @@ use thiserror::Error; use tracing::instrument; use truapi::v01; use truapi_platform::{ - PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, RuntimeConfig, + CoreAdmin, PairingHostAdmin, PairingHostConfig, PermissionAuthorizationRequest, + PermissionAuthorizationStatus, Platform, ProductContext, SigningHostConfig, }; use crate::core::TrUApiCore; use crate::frame::ProtocolMessage; +use crate::runtime::{ + LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, RuntimeServices, + SigningHostRole, +}; use crate::subscription::Spawner; use crate::transport::Transport; @@ -34,9 +42,9 @@ pub trait FrameSink: Send + Sync { fn emit_frame(&self, frame: Vec); } -/// Errors returned by [`HostCore::receive_frame`]. +/// Errors returned by [`ProductRuntime::receive_frame`]. #[derive(Debug, Error)] -pub enum HostCoreError { +pub enum ProductRuntimeError { /// Incoming bytes did not decode as a protocol frame. #[error("invalid frame: {reason}")] InvalidFrame { @@ -45,39 +53,354 @@ pub enum HostCoreError { }, } +fn product_context(product_id: &str) -> Result { + ProductContext::new(product_id.to_string()).map_err(|err| v01::GenericError { + reason: err.to_string(), + }) +} + +/// A seedless pairing host: the user's keys live in an external wallet reached +/// over the SSO pairing channel. +/// +/// Owns the shared services plus pairing-host state. Local-session activation +/// is a signing-host operation and is not present here. +pub struct PairingHostRuntime { + services: Arc, + pairing_host: Arc, +} + +impl PairingHostRuntime { + /// Build a long-lived pairing-host runtime around a platform implementation. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.new"))] + pub fn new

(platform: Arc

, config: PairingHostConfig, spawner: Spawner) -> Self + where + P: Platform + 'static, + { + let platform: Arc = platform; + let services = RuntimeServices::new( + platform.clone(), + config.people_chain_genesis_hash, + spawner.clone(), + ); + let pairing_host = PairingHostRole::new(services.clone(), config); + pairing_host.clone().start_session_store_sync(spawner); + Self { + services, + pairing_host, + } + } + + /// Build a product-facing runtime from this pairing host. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.product_runtime"))] + pub fn product_runtime( + &self, + product: ProductContext, + sink: Arc, + ) -> ProductRuntime { + ProductRuntime::new( + self.services.clone(), + self.pairing_host.clone(), + product, + sink, + ) + } + + /// Build a product-scoped administration handle from this pairing host. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.product_admin"))] + pub fn product_admin(&self, product: ProductContext) -> HostAdmin { + HostAdmin::new(self.services.clone(), self.pairing_host.clone(), product) + } + + /// Disconnect the active account-authority session. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.disconnect_session"))] + pub async fn disconnect_session(&self) { + self.pairing_host.disconnect().await; + } + + /// Cancel an in-flight SSO pairing request. A no-op when no pairing is + /// active. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.cancel_pairing"))] + pub fn cancel_pairing(&self) { + self.pairing_host.cancel_login(); + } + + /// Notify the pairing runtime that the persisted auth-session blob may + /// have changed and should be re-read. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.notify_session_store_changed"))] + pub fn notify_session_store_changed(&self) { + self.pairing_host.notify_session_store_changed(); + } + + /// Read a stored permission authorization status for a product without prompting. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.permission_authorization_status", product_id = %product_id))] + pub async fn permission_authorization_status( + &self, + product_id: &str, + request: PermissionAuthorizationRequest, + ) -> Result { + self.product_admin(product_context(product_id)?) + .permission_authorization_status(request) + .await + } + + /// Read stored permission authorization statuses for a product without prompting. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.permission_authorization_statuses", product_id = %product_id))] + pub async fn permission_authorization_statuses( + &self, + product_id: &str, + requests: Vec, + ) -> Result, v01::GenericError> { + self.product_admin(product_context(product_id)?) + .permission_authorization_statuses(requests) + .await + } + + /// Update a stored permission authorization status for a product. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.set_permission_authorization_status", product_id = %product_id))] + pub async fn set_permission_authorization_status( + &self, + product_id: &str, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + self.product_admin(product_context(product_id)?) + .set_permission_authorization_status(request, status) + .await + } +} + +impl PairingHostAdmin for PairingHostRuntime { + fn cancel_pairing(&self) { + PairingHostRuntime::cancel_pairing(self); + } + + fn notify_session_store_changed(&self) { + PairingHostRuntime::notify_session_store_changed(self); + } +} + +/// A wallet-local signing host: the user's keys are held on this device. +/// +/// 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. +pub struct SigningHostRuntime { + services: Arc, + signing_host: Arc, +} + +impl SigningHostRuntime { + /// Build a long-lived signing-host runtime around a platform implementation. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.new"))] + pub fn new

(platform: Arc

, config: SigningHostConfig, spawner: Spawner) -> Self + where + P: Platform + 'static, + { + let platform: Arc = platform; + let services = + RuntimeServices::new(platform.clone(), config.people_chain_genesis_hash, spawner); + let signing_host = SigningHostRole::new(platform); + Self { + services, + signing_host, + } + } + + /// Build a product-facing runtime from this signing host. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.product_runtime"))] + pub fn product_runtime( + &self, + product: ProductContext, + sink: Arc, + ) -> ProductRuntime { + ProductRuntime::new( + self.services.clone(), + self.signing_host.clone(), + product, + sink, + ) + } + + /// Build a product-scoped administration handle from this signing host. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.product_admin"))] + pub fn product_admin(&self, product: ProductContext) -> HostAdmin { + HostAdmin::new(self.services.clone(), self.signing_host.clone(), product) + } + + /// Disconnect the active account-authority session. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.disconnect_session"))] + pub async fn disconnect_session(&self) { + self.signing_host.disconnect().await; + } + + /// Activate a wallet-local session from host-held secret material (raw + /// BIP-39 entropy). + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.activate_local_session"))] + pub async fn activate_local_session(&self, secret: Vec) -> Result<(), v01::GenericError> { + self.signing_host + .activate_local_session(secret) + .await + .map_err(|err| v01::GenericError { + reason: err.reason(), + }) + } +} + +/// Product-scoped administration handle for host UI. +/// +/// Host UI should use this when it needs to inspect or update core-owned state +/// without owning a product frame endpoint. +pub struct HostAdmin { + authority: Arc, + product_runtime: Arc, +} + +impl HostAdmin { + /// Build an admin handle from a long-lived host runtime. + #[instrument(skip_all, fields(runtime.method = "host_admin.new"))] + pub(crate) fn new( + services: Arc, + authority: Arc, + product: ProductContext, + ) -> Self { + let product_runtime = Arc::new(ProductRuntimeHost::from_services( + services, + authority.clone(), + product, + )); + Self { + authority, + product_runtime, + } + } + + /// Core-owned logout/disconnect. + #[instrument(skip_all, fields(runtime.method = "host_admin.disconnect_session"))] + pub async fn disconnect_session(&self) { + self.authority.disconnect().await; + } + + /// Read a stored permission authorization status without prompting. + #[instrument(skip_all, fields(runtime.method = "host_admin.permission_authorization_status"))] + pub async fn permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + ) -> Result { + self.product_runtime + .permission_authorization_status(request) + .await + } + + /// Read stored permission authorization statuses without prompting. + #[instrument(skip_all, fields(runtime.method = "host_admin.permission_authorization_statuses"))] + pub async fn permission_authorization_statuses( + &self, + requests: Vec, + ) -> Result, v01::GenericError> { + self.product_runtime + .permission_authorization_statuses(requests) + .await + } + + /// Update a stored permission authorization status. + #[instrument(skip_all, fields(runtime.method = "host_admin.set_permission_authorization_status"))] + pub async fn set_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + self.product_runtime + .set_permission_authorization_status(request, status) + .await + } +} + +#[truapi_platform::async_trait] +impl CoreAdmin for HostAdmin { + async fn disconnect_session(&self) -> Result<(), v01::GenericError> { + HostAdmin::disconnect_session(self).await; + Ok(()) + } + + async fn get_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + ) -> Result { + self.permission_authorization_status(request).await + } + + async fn get_permission_authorization_statuses( + &self, + requests: Vec, + ) -> Result, v01::GenericError> { + self.permission_authorization_statuses(requests).await + } + + async fn set_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + HostAdmin::set_permission_authorization_status(self, request, status).await + } +} + /// Target-neutral host runtime wrapper. /// -/// `HostCore` owns the dispatcher/runtime core and handles byte-frame ingress, -/// response/subscription egress, in-flight dispatch cancellation on dispose, -/// and core-owned auth/session lifecycle operations. -pub struct HostCore { +/// `ProductRuntime` is product-scoped. It owns the dispatcher core for one product +/// connection and handles byte-frame ingress, response/subscription egress, and +/// in-flight dispatch cancellation on dispose. +pub struct ProductRuntime { core: TrUApiCore, + admin: HostAdmin, transport: Arc, disposed: Arc, in_flight: Mutex>, next_dispatch_id: AtomicU64, } -impl HostCore { - /// Build a host core around a platform implementation and outgoing frame - /// sink. - #[instrument(skip_all, fields(runtime.method = "host_core.from_platform_with_config"))] +impl ProductRuntime { + /// Build a product-facing host core around a platform implementation and + /// outgoing frame sink. + #[instrument(skip_all, fields(runtime.method = "product_runtime.from_platform_with_config"))] pub fn from_platform_with_config

( platform: Arc

, - runtime_config: RuntimeConfig, + host_config: PairingHostConfig, + product: ProductContext, spawner: Spawner, sink: Arc, ) -> Self where P: Platform + 'static, { + let pairing = PairingHostRuntime::new(platform, host_config, spawner); + pairing.product_runtime(product, sink) + } + + /// Build a product-facing runtime from shared services and an authority. + #[instrument(skip_all, fields(runtime.method = "product_runtime.new"))] + pub(crate) fn new( + services: Arc, + authority: Arc, + product: ProductContext, + sink: Arc, + ) -> Self { let disposed = Arc::new(AtomicBool::new(false)); let transport = Arc::new(SinkTransport { sink, disposed: disposed.clone(), }); + let admin = HostAdmin::new(services.clone(), authority.clone(), product); Self { - core: TrUApiCore::from_platform_with_config(platform, runtime_config, spawner), + core: TrUApiCore::from_product_runtime( + admin.product_runtime.clone(), + services.spawner.clone(), + authority.session_state(), + ), + admin, transport, disposed, in_flight: Mutex::new(HashMap::new()), @@ -90,14 +413,14 @@ impl HostCore { /// Calls after [`Self::dispose`] are ignored and return `Ok(())` without /// decoding. If dispose happens while a dispatch is in flight, the dispatch /// is aborted and this method still returns `Ok(())`. - #[instrument(skip_all, fields(runtime.method = "host_core.receive_frame"))] - pub async fn receive_frame(&self, frame: Vec) -> Result<(), HostCoreError> { + #[instrument(skip_all, fields(runtime.method = "product_runtime.receive_frame"))] + pub async fn receive_frame(&self, frame: Vec) -> Result<(), ProductRuntimeError> { if self.disposed.load(Ordering::Acquire) { return Ok(()); } let message = ProtocolMessage::decode(&mut frame.as_slice()).map_err(|err| { - HostCoreError::InvalidFrame { + ProductRuntimeError::InvalidFrame { reason: err.to_string(), } })?; @@ -121,55 +444,38 @@ impl HostCore { /// Core-owned logout/disconnect. Best-effort notifies the SSO peer when /// the session has channel material, then clears in-memory and persisted /// session state. - #[instrument(skip_all, fields(runtime.method = "host_core.disconnect_session"))] + #[instrument(skip_all, fields(runtime.method = "product_runtime.disconnect_session"))] pub async fn disconnect_session(&self) { - self.core.disconnect_async().await; - } - - /// Cancel an in-flight pairing request. No-op when no pairing is active. - #[instrument(skip_all, fields(runtime.method = "host_core.cancel_pairing"))] - pub fn cancel_pairing(&self) { - self.core.cancel_login(); - } - - /// Notify the core that the host-global auth session slot may have changed. - /// The core re-reads the persisted blob and emits any resulting - /// session/auth state changes. - #[instrument(skip_all, fields(runtime.method = "host_core.notify_session_store_changed"))] - pub fn notify_session_store_changed(&self) { - if self.disposed.load(Ordering::Acquire) { - return; - } - self.core.notify_session_store_changed(); + self.admin.disconnect_session().await; } /// Read a stored permission authorization status without prompting. - #[instrument(skip_all, fields(runtime.method = "host_core.permission_authorization_status"))] + #[instrument(skip_all, fields(runtime.method = "product_runtime.permission_authorization_status"))] pub async fn permission_authorization_status( &self, request: PermissionAuthorizationRequest, ) -> Result { - self.core.permission_authorization_status(request).await + self.admin.permission_authorization_status(request).await } /// Read stored permission authorization statuses without prompting. - #[instrument(skip_all, fields(runtime.method = "host_core.permission_authorization_statuses"))] + #[instrument(skip_all, fields(runtime.method = "product_runtime.permission_authorization_statuses"))] pub async fn permission_authorization_statuses( &self, requests: Vec, ) -> Result, v01::GenericError> { - self.core.permission_authorization_statuses(requests).await + self.admin.permission_authorization_statuses(requests).await } /// Update a stored permission authorization status. `NotDetermined` /// clears the stored value so the next product request prompts again. - #[instrument(skip_all, fields(runtime.method = "host_core.set_permission_authorization_status"))] + #[instrument(skip_all, fields(runtime.method = "product_runtime.set_permission_authorization_status"))] pub async fn set_permission_authorization_status( &self, request: PermissionAuthorizationRequest, status: PermissionAuthorizationStatus, ) -> Result<(), v01::GenericError> { - self.core + self.admin .set_permission_authorization_status(request, status) .await } @@ -179,7 +485,7 @@ impl HostCore { /// Disposal suppresses future outgoing frames and aborts in-flight dispatch /// futures. Adapter-specific resource cleanup remains the adapter's /// responsibility. - #[instrument(skip_all, fields(runtime.method = "host_core.dispose"))] + #[instrument(skip_all, fields(runtime.method = "product_runtime.dispose"))] pub fn dispose(&self) { if self.disposed.swap(true, Ordering::AcqRel) { return; diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index 4597a94e..e83b3d26 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -1,14 +1,12 @@ //! TrUAPI server runtime: dispatcher, frames, SCALE encoding, stream management. //! -//! The host embedding path is [`HostCore::from_platform_with_config`]. It -//! wraps a [`truapi_platform::Platform`] implementation and exposes a stable -//! byte-frame API that target adapters can use from WASM, native mobile, or -//! desktop shells. +//! Hosts instantiate a role runtime around a [`truapi_platform::Platform`] +//! implementation, then create product-scoped [`ProductRuntime`] endpoints that +//! expose the stable byte-frame API used from WASM, native mobile, or desktop +//! shells. //! //! Host-facing bridges: -//! - [`wasm`] (wasm32 only): wasm-bindgen surface exposing `WasmHostCore`. - -#![forbid(unsafe_code)] +//! - [`wasm`] (wasm32 only): wasm-bindgen surface exposing `WasmProductRuntime`. pub(crate) mod chain_runtime; pub mod core; @@ -30,9 +28,13 @@ pub mod generated; #[cfg(target_arch = "wasm32")] pub mod wasm; -pub use host_core::{FrameSink, HostCore, HostCoreError}; +pub use host_core::{ + FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeError, + SigningHostRuntime, +}; pub use truapi_platform::{ - PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, RuntimeConfig, + HostRuntimeConfig, PairingHostConfig, PermissionAuthorizationRequest, + PermissionAuthorizationStatus, Platform, ProductContext, SigningHostConfig, }; #[cfg(target_arch = "wasm32")] diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index d210df4c..3517be62 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -1,4 +1,4 @@ -//! `PlatformRuntimeHost` adapts a [`truapi_platform::Platform`] into the +//! `ProductRuntimeHost` adapts one product connection into the //! typed `truapi::api::*` host traits the generated dispatcher routes to. //! //! Most methods are straight delegations to the platform; the rest carry @@ -8,47 +8,48 @@ //! `CallError::unavailable()`. pub(crate) mod auth_state; +mod authority; mod identity; +mod pairing_host; +pub(crate) mod services; +mod signing_host; pub(crate) mod sso_pairing; pub(crate) mod sso_remote; pub(crate) mod statement_store; mod statement_store_rpc; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; -use crate::chain_runtime::{ChainRuntime, RuntimeChainProvider, RuntimeFailure}; +use crate::chain_runtime::RuntimeFailure; use crate::host_logic::dotns::{NavigateDecision, parse_navigate}; -use crate::host_logic::entropy::derive_product_entropy_from_source; use crate::host_logic::features::feature_supported; use crate::host_logic::permissions::PermissionsService; use crate::host_logic::product_account::{ - derive_product_public_key, is_product_identifier, normalize_product_identifier, - product_public_key_to_address, + derive_product_public_key, product_public_key_to_address, }; -use crate::host_logic::session::{ - SessionInfo, SessionState, decode_persisted_session, encode_persisted_session, -}; -use crate::host_logic::session_store::SessionStoreChangeNotifier; -use crate::host_logic::sso::messages::SsoSessionStatement; -use crate::host_logic::sso::messages::{ - OnExistingAllowancePolicy, SsoAllocationOutcome, SsoRemoteResponse, alias_request_message, - create_transaction_message, decode_sso_session_statement, resource_allocation_message, - sign_payload_message, sign_raw_message, -}; -use crate::host_logic::statement_store::parse_new_statements_result; +use crate::host_logic::session::SessionInfo; +#[cfg(test)] +use crate::host_logic::session::SessionState; +#[cfg(test)] use crate::subscription::Spawner; -use auth_state::AuthStateMachine; -use identity::resolve_session_identity_with_chain; -use sso_remote::{ - SSO_LOCAL_DISCONNECT_REASON, SSO_PEER_DISCONNECT_REASON, SessionDisconnects, sso_message_id, +pub(crate) use authority::ProductAuthority; +#[cfg(test)] +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}; + +use authority::{ + AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; -use statement_store_rpc::StatementStoreRpc; -use futures::future::{AbortHandle, Abortable}; -use futures::{FutureExt, StreamExt}; +#[cfg(test)] +use futures::FutureExt; +use futures::StreamExt; #[cfg(test)] use parity_scale_codec::Encode; -use tracing::{debug, info, instrument}; +use tracing::{info, instrument}; use truapi::api::{ Account, Chain, Chat, CoinPayment, Entropy, LocalStorage, Notifications, Payment, Permissions, Preimage, ResourceAllocation, Signing, System, Theme, @@ -128,424 +129,171 @@ use truapi::versioned::system::{ }; use truapi::versioned::theme::HostThemeSubscribeItem; use truapi::{CallContext, CallError, Subscription}; +#[cfg(test)] +use truapi_platform::Platform; use truapi_platform::{ - AccountAliasReview, CoreStorageKey, CreateTransactionReview, JsonRpcConnection, - PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, PreimageSubmitReview, - RuntimeConfig, SessionUiInfo, SignPayloadReview, SignRawReview, UserConfirmationReview, + AccountAliasReview, CreateTransactionReview, PermissionAuthorizationRequest, + PermissionAuthorizationStatus, PreimageSubmitReview, ProductContext, SessionUiInfo, + SignPayloadReview, SignRawReview, UserConfirmationReview, normalize_product_identifier, }; -/// Adapter that exposes a [`truapi_platform::Platform`] through the +/// Product-scoped adapter that exposes a long-lived host runtime through the /// `truapi::api::*` trait set the generated dispatcher routes to. -pub struct PlatformRuntimeHost { - platform: Arc, - runtime_config: RuntimeConfig, - /// chainHead-v1 state machine. The provider adapter forwards - /// [`truapi_platform::ChainProvider::connect`] into the json-rpc layer. - chain: ChainRuntime, - /// Currently-paired session, pushed by the host through a side channel. - /// Account-management subscriptions read from this in lieu of round-tripping - /// a callback on every connection-status query. - session_state: Arc, - session_store_changes: Arc, - session_disconnects: Arc, - sso_disconnect_monitor: Arc>>, - spawner: Spawner, - /// Auth UI state machine; the single funnel for `auth_state_changed`. - auth_state: AuthStateMachine, -} - -struct SsoDisconnectMonitor { - session_id_own: [u8; 32], - session_id_peer: [u8; 32], - abort: AbortHandle, +pub struct ProductRuntimeHost { + services: Arc, + authority: Arc, + product: ProductContext, + /// Stable per-product-runtime id used to scope long-lived chain follow + /// operation ids within one shared host runtime. + core_instance: u64, } -impl Drop for SsoDisconnectMonitor { - fn drop(&mut self) { - self.abort.abort(); - } -} - -impl PlatformRuntimeHost { - /// Wrap a platform implementation. The runtime takes ownership via `Arc`. - /// `spawner` is used by the embedded chain runtime to drive json-rpc - /// response loops and follow-setup futures. - pub fn new( - platform: Arc, - runtime_config: RuntimeConfig, - spawner: Spawner, +impl ProductRuntimeHost { + /// Build a product-scoped dispatcher target from a long-lived host runtime. + pub(crate) fn from_services( + services: Arc, + authority: Arc, + product: ProductContext, ) -> Self { - let chain_provider = Self::chain_provider(platform.clone()); + let core_instance = services.next_core_instance(); Self { - auth_state: AuthStateMachine::new(platform.clone()), - platform, - runtime_config, - chain: ChainRuntime::new(chain_provider, spawner.clone()), - session_state: SessionState::new(), - session_store_changes: SessionStoreChangeNotifier::new(), - session_disconnects: Arc::new(SessionDisconnects::default()), - sso_disconnect_monitor: Arc::new(Mutex::new(None)), - spawner, + services, + authority, + product, + core_instance, } } + #[cfg(test)] + pub fn new

( + platform: Arc

, + config: (truapi_platform::PairingHostConfig, ProductContext), + spawner: Spawner, + ) -> Self + where + P: Platform + 'static, + { + let (host_config, product) = config; + let platform: Arc = platform; + Self::new_pairing_for_tests(platform, host_config, product, spawner).0 + } + /// Compatibility constructor used only by tests that do not exercise /// product-scoped behavior. #[cfg(test)] fn new_compat(platform: Arc, spawner: Spawner) -> Self { - Self::new( + Self::new_compat_with_pairing(platform, spawner).0 + } + + #[cfg(test)] + fn new_compat_with_pairing( + platform: Arc, + spawner: Spawner, + ) -> (Self, Arc) { + let host_config = truapi_platform::PairingHostConfig::new( + truapi_platform::HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://example.invalid/dotli.png".to_string()), + version: None, + }, + truapi_platform::PlatformInfo::default(), + [0; 32], + "polkadotapp".to_string(), + ) + .expect("compat runtime config is valid"); + Self::new_pairing_for_tests( platform, - RuntimeConfig::new( - "unknown.dot".to_string(), - truapi_platform::HostInfo { - name: "Polkadot Web".to_string(), - icon: Some("https://example.invalid/dotli.png".to_string()), - version: None, - }, - truapi_platform::PlatformInfo::default(), - [0; 32], - "polkadotapp".to_string(), - ) - .expect("compat runtime config is valid"), + host_config, + ProductContext::new("unknown.dot".to_string()) + .expect("compat product context is valid"), spawner, ) } - /// Chain provider backing the chainHead-v1 runtime. Chain access routes - /// through the platform's host-owned `ChainProvider`. - fn chain_provider(platform: Arc) -> Arc { - Arc::new(HostChainProvider { platform }) - } - - /// Clone of the shared session-state holder used by core subscriptions - /// and tests. Real host lifecycle flows through CoreStorage session sync - /// and `disconnect`. - pub fn session_state(&self) -> Arc { - self.session_state.clone() - } - - /// Clone of the notifier used to wake the session-store sync loop. - pub fn session_store_changes(&self) -> Arc { - self.session_store_changes.clone() - } - - fn start_sso_disconnect_monitor(&self, session: &SessionInfo) { - start_sso_disconnect_monitor( - self.platform.clone(), - self.runtime_config.clone(), - self.session_state.clone(), - self.session_disconnects.clone(), - self.auth_state.clone(), - self.sso_disconnect_monitor.clone(), - self.spawner.clone(), - session, - ); - } - - /// Start syncing the in-memory session from the host-global auth session - /// slot. - /// The store emits coarse ticks; each tick triggers a fresh read so same- - /// runtime writes and cross-runtime logout/re-pair take the same path. - #[instrument(skip_all, fields(runtime.method = "session_store.sync"))] - pub(crate) fn start_session_store_sync(&self, spawner: Spawner) { - let platform = self.platform.clone(); - let chain = self.chain.clone(); - let runtime_config = self.runtime_config.clone(); - let session_state = self.session_state.clone(); - let auth_state = self.auth_state.clone(); - let session_disconnects = self.session_disconnects.clone(); - let sso_disconnect_monitor = self.sso_disconnect_monitor.clone(); - let spawner_for_monitor = self.spawner.clone(); - let session_store_changes = self.session_store_changes.clone(); - spawner(Box::pin(async move { - let mut ticks = session_store_changes.subscribe(); - // Clearing the store can itself notify this subscription; clear at - // most once per read-error streak so a persistently failing read - // cannot spin the loop through its own clear notifications. - let mut cleared_after_read_error = false; - while ticks.next().await.is_some() { - match platform - .read_core_storage(CoreStorageKey::AuthSession) - .await - { - Ok(Some(blob)) => { - cleared_after_read_error = false; - match decode_persisted_session(&blob) { - Ok(session) => { - let resolved = resolve_session_identity_with_chain( - &chain, - runtime_config.people_chain_genesis_hash, - session, - ) - .await; - if encode_persisted_session(&resolved) != blob { - let _ = platform - .write_core_storage( - CoreStorageKey::AuthSession, - encode_persisted_session(&resolved), - ) - .await; - } - auth_state.connected(&connected_session_ui_info(&resolved)); - session_state.set_session(resolved); - if let Some(session) = session_state.current() { - start_sso_disconnect_monitor( - platform.clone(), - runtime_config.clone(), - session_state.clone(), - session_disconnects.clone(), - auth_state.clone(), - sso_disconnect_monitor.clone(), - spawner_for_monitor.clone(), - &session, - ); - } - } - Err(_) => { - session_state.clear_session(); - stop_sso_disconnect_monitor(&sso_disconnect_monitor); - let _ = platform - .clear_core_storage(CoreStorageKey::AuthSession) - .await; - auth_state.store_disconnected(); - } - } - } - Ok(None) => { - cleared_after_read_error = false; - session_state.clear_session(); - stop_sso_disconnect_monitor(&sso_disconnect_monitor); - auth_state.store_disconnected(); - } - Err(_) => { - session_state.clear_session(); - stop_sso_disconnect_monitor(&sso_disconnect_monitor); - auth_state.store_disconnected(); - if !cleared_after_read_error { - cleared_after_read_error = true; - let _ = platform - .clear_core_storage(CoreStorageKey::AuthSession) - .await; - } - } - } - } - })); - } - - /// Core-owned logout/disconnect path. It best-effort notifies the SSO - /// peer, then clears in-memory and persisted session state regardless of - /// any transport failure. - #[instrument(skip_all, fields(runtime.method = "account.disconnect"))] - pub(crate) async fn disconnect(&self) { - self.cancel_login(); - self.session_disconnects.notify(SSO_LOCAL_DISCONNECT_REASON); - let session = self.session_state.current(); - self.clear_disconnected_session().await; - if let Some(session) = session.as_ref() { - let _ = self.submit_sso_disconnected(session).await; - } + #[cfg(test)] + fn new_pairing_for_tests( + platform: Arc, + host_config: truapi_platform::PairingHostConfig, + product: ProductContext, + spawner: Spawner, + ) -> (Self, Arc) { + let services = RuntimeServices::new( + platform.clone(), + host_config.people_chain_genesis_hash, + spawner.clone(), + ); + let pairing_host = PairingHost::new(services.clone(), host_config); + let core_instance = services.next_core_instance(); + let host = Self { + services, + authority: pairing_host.clone(), + product, + core_instance, + }; + (host, pairing_host) } - /// Cancel any in-flight `request_login` pairing: the host UI gets a - /// `Disconnected` state immediately and the login flow resolves to - /// `Rejected`. A no-op when no login is in progress. - #[instrument(skip_all, fields(runtime.method = "account.cancel_login"))] - pub(crate) fn cancel_login(&self) { - self.auth_state.login_cancelled(); + /// Test-only access to the shared session-state holder. + #[cfg(test)] + pub(crate) fn test_session_state(&self) -> Arc { + self.authority.session_state() } - /// Static product/host configuration for this runtime instance. - #[allow(dead_code)] - pub fn runtime_config(&self) -> &RuntimeConfig { - &self.runtime_config + /// Disconnect this runtime from its paired signing host. + #[cfg(test)] + #[instrument(skip_all, fields(runtime.method = "account.disconnect"))] + pub(crate) async fn disconnect(&self) { + self.authority.disconnect().await; } fn is_product_account_valid_for_caller(&self, dot_ns_identifier: &str) -> bool { - let dot_ns_identifier = normalize_product_identifier(dot_ns_identifier); - let product_id = normalize_product_identifier(&self.runtime_config.product_id); - if self.runtime_config.product_id.starts_with("localhost:") { - is_product_identifier(&dot_ns_identifier) - } else { - dot_ns_identifier == product_id - } + let Ok(dot_ns_identifier) = normalize_product_identifier(dot_ns_identifier) else { + return false; + }; + let product_id = self.product_id(); + product_id.starts_with("localhost:") || dot_ns_identifier == product_id } fn normalize_product_account_id( product_account_id: v01::ProductAccountId, - ) -> v01::ProductAccountId { - v01::ProductAccountId { - dot_ns_identifier: normalize_product_identifier(&product_account_id.dot_ns_identifier), + ) -> Result { + Ok(v01::ProductAccountId { + dot_ns_identifier: normalize_product_identifier(&product_account_id.dot_ns_identifier) + .map_err(|_| ())?, derivation_index: product_account_id.derivation_index, - } + }) } fn product_id(&self) -> String { - normalize_product_identifier(&self.runtime_config.product_id) + self.product.product_id.as_str().to_string() } - fn legacy_slot_zero_public_key(&self, session: &SessionInfo) -> Result<[u8; 32], String> { + fn legacy_slot_zero_public_key(&self, session: &AuthoritySession) -> Result<[u8; 32], String> { derive_product_public_key(session.public_key, &self.product_id(), 0) .map_err(|err| err.to_string()) } - #[instrument(skip_all, fields(runtime.method = "session_store.clear_disconnected"))] - async fn clear_disconnected_session(&self) { - debug!("clearing disconnected SSO session state"); - stop_sso_disconnect_monitor(&self.sso_disconnect_monitor); - self.session_state.clear_session(); - let _ = self - .platform - .clear_core_storage(CoreStorageKey::AuthSession) - .await; - self.auth_state.store_disconnected(); - let _ = self - .platform - .clear_core_storage(CoreStorageKey::PairingDeviceIdentity) - .await; - } -} - -fn stop_sso_disconnect_monitor(monitor: &Arc>>) { - monitor - .lock() - .expect("SSO disconnect monitor mutex poisoned") - .take(); -} - -/// True when an own/peer session-id pair matches the captured SSO session. -fn same_sso_session( - own: [u8; 32], - peer: [u8; 32], - sso: &crate::host_logic::session::SsoSessionInfo, -) -> bool { - own == sso.session_id_own && peer == sso.session_id_peer -} - -#[allow(clippy::too_many_arguments)] -fn start_sso_disconnect_monitor( - platform: Arc, - runtime_config: RuntimeConfig, - session_state: Arc, - session_disconnects: Arc, - auth_state: AuthStateMachine, - monitor: Arc>>, - spawner: Spawner, - session: &SessionInfo, -) { - let Some(sso) = session.sso.clone() else { - stop_sso_disconnect_monitor(&monitor); - return; - }; - - { - let mut current = monitor - .lock() - .expect("SSO disconnect monitor mutex poisoned"); - if current.as_ref().is_some_and(|active| { - same_sso_session(active.session_id_own, active.session_id_peer, &sso) - }) { - return; - } - let (abort, registration) = AbortHandle::new_pair(); - *current = Some(SsoDisconnectMonitor { - session_id_own: sso.session_id_own, - session_id_peer: sso.session_id_peer, - abort, - }); - - let monitor_slot = monitor.clone(); - let monitor_spawner = spawner.clone(); - let future = async move { - let result = wait_for_sso_peer_disconnect( - platform.clone(), - runtime_config.people_chain_genesis_hash, - sso.clone(), - monitor_spawner.clone(), - ) - .await; - let peer_disconnected = result.is_ok(); - let should_clear = peer_disconnected - && session_state.current().as_ref().is_some_and(|current| { - current.sso.as_ref().is_some_and(|current_sso| { - same_sso_session( - current_sso.session_id_own, - current_sso.session_id_peer, - &sso, - ) - }) - }); - { - let mut active = monitor_slot - .lock() - .expect("SSO disconnect monitor mutex poisoned"); - if active.as_ref().is_some_and(|active| { - same_sso_session(active.session_id_own, active.session_id_peer, &sso) - }) { - *active = None; - } - } - if should_clear { - session_disconnects.notify(SSO_PEER_DISCONNECT_REASON); - session_state.clear_session(); - let _ = platform - .clear_core_storage(CoreStorageKey::AuthSession) - .await; - auth_state.store_disconnected(); - let _ = platform - .clear_core_storage(CoreStorageKey::PairingDeviceIdentity) - .await; - } - }; - spawner(Box::pin(Abortable::new(future, registration).map(|_| ()))); + fn product_storage_key(&self, key: String) -> String { + product_storage_key(self.product.product_id.as_str(), &key) } -} -#[instrument(skip_all, fields(runtime.method = "sso.peer_disconnect.monitor"))] -async fn wait_for_sso_peer_disconnect( - platform: Arc, - people_chain_genesis_hash: [u8; 32], - session: crate::host_logic::session::SsoSessionInfo, - spawner: Spawner, -) -> Result<(), String> { - let statement_store = StatementStoreRpc::new(platform, people_chain_genesis_hash, spawner); - let rpc_client = statement_store.client("SSO disconnect monitor").await?; - let mut subscription = - statement_store_rpc::subscribe_match_all(&rpc_client, &[session.session_id_peer]) - .await - .map_err(|err| format!("SSO disconnect monitor subscribe failed: {err}"))?; - while let Some(item) = subscription.next().await { - let value = item.map_err(|err| format!("SSO disconnect monitor item failed: {err}"))?; - let page = parse_new_statements_result("sso-peer-disconnect-monitor".to_string(), &value) - .map_err(|err| err.to_string())?; - for statement in page.statements { - if matches!( - decode_sso_session_statement( - &session, - &statement, - "truapi:sso-peer-disconnect-monitor", - "truapi:sso-peer-disconnect-monitor", - )?, - Some(SsoSessionStatement::Disconnected) - ) { - return Ok(()); - } - } + fn follow_id(&self, id: &str) -> String { + format!("c{}:{id}", self.core_instance) } - Err("SSO disconnect monitor response stream ended".to_string()) } -impl PlatformRuntimeHost { +impl ProductRuntimeHost { /// Read a stored permission authorization status without prompting. #[instrument(skip_all, fields(runtime.method = "permissions.authorization_status"))] pub(crate) async fn permission_authorization_status( &self, request: PermissionAuthorizationRequest, ) -> Result { + let product_id = self.product_id(); let service = PermissionsService::new( - self.platform.as_ref(), - self.platform.as_ref(), - &self.runtime_config.product_id, + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, ); service.authorization_status(&request).await } @@ -556,10 +304,11 @@ impl PlatformRuntimeHost { &self, requests: Vec, ) -> Result, v01::GenericError> { + let product_id = self.product_id(); let service = PermissionsService::new( - self.platform.as_ref(), - self.platform.as_ref(), - &self.runtime_config.product_id, + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, ); service.authorization_statuses(&requests).await } @@ -572,20 +321,22 @@ impl PlatformRuntimeHost { request: PermissionAuthorizationRequest, status: PermissionAuthorizationStatus, ) -> Result<(), v01::GenericError> { + let product_id = self.product_id(); let service = PermissionsService::new( - self.platform.as_ref(), - self.platform.as_ref(), - &self.runtime_config.product_id, + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, ); service.set_authorization_status(&request, status).await } #[instrument(skip_all, fields(runtime.method = "permissions.chain_submit_authorization"))] async fn chain_submit_authorization(&self) -> Result { + let product_id = self.product_id(); let service = PermissionsService::new( - self.platform.as_ref(), - self.platform.as_ref(), - &self.runtime_config.product_id, + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, ); service .check_or_prompt_remote(v01::RemotePermissionRequest { @@ -606,43 +357,9 @@ impl PlatformRuntimeHost { } } - /// Shared tail of the four `sign_*` methods: submit the already-built SSO - /// message, require a `Sign` wallet response, and map its payload into the - /// caller's concrete response variant. `wrap` adapts a sign-payload domain - /// error into the method's error type. - async fn submit_sign_request( - &self, - cx: &CallContext, - session: &SessionInfo, - wrap: fn(v01::HostSignPayloadError) -> E, - action: &str, - message: crate::host_logic::sso::messages::RemoteMessage, - into_response: impl FnOnce(v01::HostSignPayloadResponse) -> R, - ) -> Result> { - let response = self - .submit_sso_remote_message(cx, session, action, message) - .await - .map_err(|reason| signing_unknown_call_error(wrap, reason))?; - let SsoRemoteResponse::Sign(response) = response else { - return Err(signing_unknown_call_error( - wrap, - UNEXPECTED_SSO_SIGNING_RESPONSE, - )); - }; - response - .payload - .map(|payload| { - into_response(v01::HostSignPayloadResponse { - signature: payload.signature, - signed_transaction: payload.signed_transaction, - }) - }) - .map_err(|reason| signing_unknown_call_error(wrap, reason)) - } - fn validate_legacy_address_signer( &self, - session: &SessionInfo, + session: &AuthoritySession, signer: &str, ) -> Result<[u8; 32], v01::HostSignPayloadError> { let public_key = self @@ -662,7 +379,7 @@ impl PlatformRuntimeHost { fn validate_legacy_public_key_signer( &self, - session: &SessionInfo, + session: &AuthoritySession, signer: [u8; 32], ) -> Result<(), v01::HostCreateTransactionError> { let public_key = self @@ -689,33 +406,13 @@ fn parse_legacy_signer_hex(signer: &str) -> Option<[u8; 32]> { hex::decode(raw).ok()?.try_into().ok() } -/// Adapter from `truapi_platform::ChainProvider` into the -/// [`RuntimeChainProvider`] surface the chain runtime expects. -/// Reuses the platform-supplied json-rpc connection and converts the -/// platform `GenericError` into a `RuntimeFailure::Unavailable`. -struct HostChainProvider { - platform: Arc, -} - -#[async_trait::async_trait] -impl RuntimeChainProvider for HostChainProvider { - #[instrument(skip_all, fields(runtime.method = "chain.provider.connect"))] - async fn connect( - &self, - genesis_hash: Vec, - ) -> Result, RuntimeFailure> { - let genesis_hash: [u8; 32] = genesis_hash.try_into().map_err(|genesis_hash: Vec| { - RuntimeFailure::host_failure( - "remote_chain_connect", - format!("genesis_hash must be 32 bytes, got {}", genesis_hash.len()), - ) - })?; - self.platform - .connect(genesis_hash) - .await - .map(Arc::from) - .map_err(|_| RuntimeFailure::unavailable("remote_chain_connect")) - } +fn product_storage_key(product_id: &str, key: &str) -> String { + format!( + "truapi:product-storage:v1:{}:{}:{}", + product_id.len(), + product_id, + key + ) } fn runtime_failure_to_call_error(failure: RuntimeFailure) -> CallError { @@ -728,7 +425,7 @@ fn runtime_failure_to_call_error(failure: RuntimeFailure) -> CallError { // System // --------------------------------------------------------------------------- -impl System for PlatformRuntimeHost { +impl System for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "system.feature_supported"))] async fn feature_supported( &self, @@ -736,7 +433,7 @@ impl System for PlatformRuntimeHost { request: HostFeatureSupportedRequest, ) -> Result> { let HostFeatureSupportedRequest::V1(inner) = request; - feature_supported(self.platform.as_ref(), inner) + feature_supported(self.services.platform.as_ref(), inner) .await .map(HostFeatureSupportedResponse::V1) .map_err(|err| CallError::Domain(HostFeatureSupportedError::V1(err))) @@ -764,7 +461,8 @@ impl System for PlatformRuntimeHost { } }, }; - self.platform + self.services + .platform .navigate_to(resolved) .await .map(|()| HostNavigateToResponse::V1) @@ -776,7 +474,7 @@ impl System for PlatformRuntimeHost { // Permissions // --------------------------------------------------------------------------- -impl Permissions for PlatformRuntimeHost { +impl Permissions for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "permissions.request_device_permission"))] async fn request_device_permission( &self, @@ -784,10 +482,11 @@ impl Permissions for PlatformRuntimeHost { request: HostDevicePermissionRequest, ) -> Result> { let HostDevicePermissionRequest::V1(inner) = request; + let product_id = self.product_id(); let service = PermissionsService::new( - self.platform.as_ref(), - self.platform.as_ref(), - &self.runtime_config.product_id, + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, ); match service.check_or_prompt_device(inner).await { Ok(decision) => Ok(HostDevicePermissionResponse::V1( @@ -808,10 +507,11 @@ impl Permissions for PlatformRuntimeHost { request: RemotePermissionRequest, ) -> Result> { let RemotePermissionRequest::V1(inner) = request; + let product_id = self.product_id(); let service = PermissionsService::new( - self.platform.as_ref(), - self.platform.as_ref(), - &self.runtime_config.product_id, + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, ); match service.check_or_prompt_remote(inner).await { Ok(decision) => Ok(RemotePermissionResponse::V1( @@ -830,7 +530,7 @@ impl Permissions for PlatformRuntimeHost { // LocalStorage // --------------------------------------------------------------------------- -impl LocalStorage for PlatformRuntimeHost { +impl LocalStorage for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "local_storage.read"))] async fn read( &self, @@ -838,8 +538,9 @@ impl LocalStorage for PlatformRuntimeHost { request: HostLocalStorageReadRequest, ) -> Result> { let HostLocalStorageReadRequest::V1(v01::HostLocalStorageReadRequest { key }) = request; - self.platform - .read(key) + self.services + .platform + .read(self.product_storage_key(key)) .await .map(|value| { HostLocalStorageReadResponse::V1(v01::HostLocalStorageReadResponse { value }) @@ -855,8 +556,9 @@ impl LocalStorage for PlatformRuntimeHost { ) -> Result> { let HostLocalStorageWriteRequest::V1(v01::HostLocalStorageWriteRequest { key, value }) = request; - self.platform - .write(key, value) + self.services + .platform + .write(self.product_storage_key(key), value) .await .map(|()| HostLocalStorageWriteResponse::V1) .map_err(|err| CallError::Domain(HostLocalStorageWriteError::V1(err))) @@ -869,8 +571,9 @@ impl LocalStorage for PlatformRuntimeHost { request: HostLocalStorageClearRequest, ) -> Result> { let HostLocalStorageClearRequest::V1(v01::HostLocalStorageClearRequest { key }) = request; - self.platform - .clear(key) + self.services + .platform + .clear(self.product_storage_key(key)) .await .map(|()| HostLocalStorageClearResponse::V1) .map_err(|err| CallError::Domain(HostLocalStorageClearError::V1(err))) @@ -884,7 +587,7 @@ impl LocalStorage for PlatformRuntimeHost { // Account-management flows live in the Rust core itself, backed by the shared // session state and, for alias/proof/login success paths, the SSO service. -impl Account for PlatformRuntimeHost { +impl Account for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "account.get_account"))] async fn get_account( &self, @@ -892,15 +595,14 @@ impl Account for PlatformRuntimeHost { request: HostAccountGetRequest, ) -> Result> { let HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id }) = request; - let product_account_id = Self::normalize_product_account_id(product_account_id); - - if !is_product_identifier(&product_account_id.dot_ns_identifier) { - return Err(CallError::Domain(HostAccountGetError::V1( - v01::HostAccountGetError::DomainNotValid, - ))); - } + let product_account_id = + Self::normalize_product_account_id(product_account_id).map_err(|()| { + CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::DomainNotValid, + )) + })?; - let Some(session) = self.session_state.current() else { + let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostAccountGetError::V1( v01::HostAccountGetError::NotConnected, ))); @@ -934,21 +636,22 @@ impl Account for PlatformRuntimeHost { request; let product_account_id = Self::normalize_product_account_id(product_account_id); - let Some(session) = self.session_state.current() else { + let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostAccountGetAliasError::V1( v01::HostAccountGetError::NotConnected, ))); }; - if !is_product_identifier(&product_account_id.dot_ns_identifier) { - return Err(CallError::Domain(HostAccountGetAliasError::V1( + let product_account_id = product_account_id.map_err(|()| { + CallError::Domain(HostAccountGetAliasError::V1( v01::HostAccountGetError::DomainNotValid, - ))); - } + )) + })?; let product_id = self.product_id(); if product_account_id.dot_ns_identifier != product_id { let confirmed = self + .services .platform .confirm_user_action(UserConfirmationReview::AccountAlias(AccountAliasReview { requesting_product_id: product_id.clone(), @@ -965,29 +668,13 @@ impl Account for PlatformRuntimeHost { } } - let message_id = sso_message_id(cx, "account-alias"); - let message = alias_request_message(message_id.clone(), product_account_id, product_id); - let response = self - .submit_sso_remote_message_without_timeout(cx, &session, "account-alias", message) + self.authority + .account_alias(cx, &session, product_account_id, product_id) .await - .map_err(|reason| { - CallError::Domain(HostAccountGetAliasError::V1( - v01::HostAccountGetError::Unknown { reason }, - )) - })?; - let SsoRemoteResponse::RingVrfAlias(response) = response else { - return Err(CallError::Domain(HostAccountGetAliasError::V1( - v01::HostAccountGetError::Unknown { - reason: "Unexpected SSO response for account alias request".to_string(), - }, - ))); - }; - response - .payload .map(HostAccountGetAliasResponse::V1) - .map_err(|reason| { + .map_err(|err| { CallError::Domain(HostAccountGetAliasError::V1( - v01::HostAccountGetError::Unknown { reason }, + account_get_error_from_authority(err), )) }) } @@ -1007,18 +694,13 @@ impl Account for PlatformRuntimeHost { _cx: &CallContext, _request: HostGetLegacyAccountsRequest, ) -> Result> { - let Some(session) = self.session_state.current() else { + let Some(session) = self.authority.current_session() else { return Ok(HostGetLegacyAccountsResponse::V1( v01::HostGetLegacyAccountsResponse { accounts: vec![] }, )); }; let product_id = self.product_id(); - if !is_product_identifier(&product_id) { - return Err(CallError::Domain(HostGetLegacyAccountsError::V1( - v01::HostAccountGetError::DomainNotValid, - ))); - } let public_key = derive_product_public_key(session.public_key, &product_id, 0).map_err(|err| { @@ -1033,7 +715,7 @@ impl Account for PlatformRuntimeHost { v01::HostGetLegacyAccountsResponse { accounts: vec![v01::LegacyAccount { public_key: public_key.to_vec(), - name: session.lite_username, + name: session.lite_username.clone(), }], }, )) @@ -1045,7 +727,7 @@ impl Account for PlatformRuntimeHost { _cx: &CallContext, _request: HostGetUserIdRequest, ) -> Result> { - let Some(session) = self.session_state.current() else { + let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostGetUserIdError::V1( v01::HostGetUserIdError::NotConnected, ))); @@ -1053,8 +735,14 @@ impl Account for PlatformRuntimeHost { let primary_username = session .full_username + .clone() .filter(|value| !value.is_empty()) - .or_else(|| session.lite_username.filter(|value| !value.is_empty())) + .or_else(|| { + session + .lite_username + .clone() + .filter(|value| !value.is_empty()) + }) .ok_or_else(|| { CallError::Domain(HostGetUserIdError::V1(v01::HostGetUserIdError::Unknown { reason: "No primary username for this session".to_string(), @@ -1071,16 +759,16 @@ impl Account for PlatformRuntimeHost { &self, _cx: &CallContext, ) -> Subscription { - Subscription::new(self.session_state.subscribe()) + Subscription::new(self.authority.session_state().subscribe()) } - #[instrument(skip_all, fields(runtime.method = "account.request_login", product = %self.runtime_config.product_id))] + #[instrument(skip_all, fields(runtime.method = "account.request_login", product = %self.product.product_id))] async fn request_login( &self, _cx: &CallContext, _request: HostRequestLoginRequest, ) -> Result> { - self.request_login_flow().await + self.authority.request_login(&self.product).await } } @@ -1094,37 +782,58 @@ fn connected_session_ui_info(session: &SessionInfo) -> SessionUiInfo { } } -const UNEXPECTED_SSO_SIGNING_RESPONSE: &str = "Unexpected SSO response for signing request"; -const UNEXPECTED_SSO_TRANSACTION_RESPONSE: &str = "Unexpected SSO response for transaction request"; +fn account_get_error_from_authority(err: AuthorityError) -> v01::HostAccountGetError { + match err { + AuthorityError::Rejected => v01::HostAccountGetError::Rejected, + AuthorityError::Disconnected => v01::HostAccountGetError::NotConnected, + AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { + v01::HostAccountGetError::Unknown { reason } + } + } +} -fn signing_unknown_call_error( +fn signing_call_error( wrap: fn(v01::HostSignPayloadError) -> E, - reason: impl Into, + err: AuthorityError, ) -> CallError { - CallError::Domain(wrap(v01::HostSignPayloadError::Unknown { - reason: reason.into(), + CallError::Domain(wrap(match err { + AuthorityError::Rejected | AuthorityError::Disconnected => { + v01::HostSignPayloadError::Rejected + } + AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { + v01::HostSignPayloadError::Unknown { reason } + } })) } -fn transaction_unknown_call_error( +fn transaction_call_error( wrap: fn(v01::HostCreateTransactionError) -> E, - reason: impl Into, + err: AuthorityError, ) -> CallError { - CallError::Domain(wrap(v01::HostCreateTransactionError::Unknown { - reason: reason.into(), + CallError::Domain(wrap(match err { + AuthorityError::Rejected | AuthorityError::Disconnected => { + v01::HostCreateTransactionError::Rejected + } + AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { + v01::HostCreateTransactionError::Unknown { reason } + } })) } -impl Signing for PlatformRuntimeHost { +impl Signing for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "signing.sign_payload"))] async fn sign_payload( &self, cx: &CallContext, request: HostSignPayloadRequest, ) -> Result> { - info!("sign_payload: requesting wallet signature"); + info!("sign_payload: requesting signing-host signature"); let HostSignPayloadRequest::V1(mut inner) = request; - inner.account = Self::normalize_product_account_id(inner.account); + inner.account = Self::normalize_product_account_id(inner.account).map_err(|()| { + CallError::Domain(HostSignPayloadError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + })?; if !self.is_product_account_valid_for_caller(&inner.account.dot_ns_identifier) { return Err(CallError::Domain(HostSignPayloadError::V1( v01::HostSignPayloadError::PermissionDenied, @@ -1134,12 +843,13 @@ impl Signing for PlatformRuntimeHost { v01::HostSignPayloadError::PermissionDenied, )) .await?; - let Some(session) = self.session_state.current() else { + let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostSignPayloadError::V1( v01::HostSignPayloadError::Rejected, ))); }; let confirmed = self + .services .platform .confirm_user_action(UserConfirmationReview::SignPayload( SignPayloadReview::Product(inner.clone()), @@ -1153,17 +863,11 @@ impl Signing for PlatformRuntimeHost { v01::HostSignPayloadError::Rejected, ))); } - let message_id = sso_message_id(cx, "sign-payload"); - let message = sign_payload_message(message_id, inner); - self.submit_sign_request( - cx, - &session, - HostSignPayloadError::V1, - "sign-payload", - message, - HostSignPayloadResponse::V1, - ) - .await + self.authority + .sign_payload(cx, &session, SignPayloadAuthorityRequest::Product(inner)) + .await + .map(HostSignPayloadResponse::V1) + .map_err(|reason| signing_call_error(HostSignPayloadError::V1, reason)) } #[instrument(skip_all, fields(runtime.method = "signing.sign_raw"))] @@ -1172,9 +876,13 @@ impl Signing for PlatformRuntimeHost { cx: &CallContext, request: HostSignRawRequest, ) -> Result> { - info!("sign_raw: requesting wallet signature"); + info!("sign_raw: requesting signing-host signature"); let HostSignRawRequest::V1(mut inner) = request; - inner.account = Self::normalize_product_account_id(inner.account); + inner.account = Self::normalize_product_account_id(inner.account).map_err(|()| { + CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + })?; if !self.is_product_account_valid_for_caller(&inner.account.dot_ns_identifier) { return Err(CallError::Domain(HostSignRawError::V1( v01::HostSignPayloadError::PermissionDenied, @@ -1184,12 +892,13 @@ impl Signing for PlatformRuntimeHost { v01::HostSignPayloadError::PermissionDenied, )) .await?; - let Some(session) = self.session_state.current() else { + let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostSignRawError::V1( v01::HostSignPayloadError::Rejected, ))); }; let confirmed = self + .services .platform .confirm_user_action(UserConfirmationReview::SignRaw(SignRawReview::Product( inner.clone(), @@ -1203,17 +912,11 @@ impl Signing for PlatformRuntimeHost { v01::HostSignPayloadError::Rejected, ))); } - let message_id = sso_message_id(cx, "sign-raw"); - let message = sign_raw_message(message_id, inner); - self.submit_sign_request( - cx, - &session, - HostSignRawError::V1, - "sign-raw", - message, - HostSignRawResponse::V1, - ) - .await + self.authority + .sign_raw(cx, &session, SignRawAuthorityRequest::Product(inner)) + .await + .map(HostSignRawResponse::V1) + .map_err(|reason| signing_call_error(HostSignRawError::V1, reason)) } #[instrument(skip_all, fields(runtime.method = "signing.create_transaction"))] @@ -1222,9 +925,13 @@ impl Signing for PlatformRuntimeHost { cx: &CallContext, request: HostCreateTransactionRequest, ) -> Result> { - info!("create_transaction: requesting wallet signature"); + info!("create_transaction: requesting signing-host signature"); let HostCreateTransactionRequest::V1(mut inner) = request; - inner.signer = Self::normalize_product_account_id(inner.signer); + inner.signer = Self::normalize_product_account_id(inner.signer).map_err(|()| { + CallError::Domain(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::PermissionDenied, + )) + })?; if !self.is_product_account_valid_for_caller(&inner.signer.dot_ns_identifier) { return Err(CallError::Domain(HostCreateTransactionError::V1( v01::HostCreateTransactionError::PermissionDenied, @@ -1234,12 +941,13 @@ impl Signing for PlatformRuntimeHost { v01::HostCreateTransactionError::PermissionDenied, )) .await?; - let Some(session) = self.session_state.current() else { + let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostCreateTransactionError::V1( v01::HostCreateTransactionError::Rejected, ))); }; let confirmed = self + .services .platform .confirm_user_action(UserConfirmationReview::CreateTransaction( CreateTransactionReview::Product(inner.clone()), @@ -1253,30 +961,15 @@ impl Signing for PlatformRuntimeHost { v01::HostCreateTransactionError::Rejected, ))); } - let message_id = sso_message_id(cx, "create-transaction"); - let message = create_transaction_message(message_id, inner); - let response = self - .submit_sso_remote_message(cx, &session, "create-transaction", message) + self.authority + .create_transaction( + cx, + &session, + CreateTransactionAuthorityRequest::Product(inner), + ) .await - .map_err(|reason| { - transaction_unknown_call_error(HostCreateTransactionError::V1, reason) - })?; - let SsoRemoteResponse::CreateTransaction(response) = response else { - return Err(transaction_unknown_call_error( - HostCreateTransactionError::V1, - UNEXPECTED_SSO_TRANSACTION_RESPONSE, - )); - }; - response - .signed_transaction - .map(|transaction| { - HostCreateTransactionResponse::V1(v01::HostCreateTransactionResponse { - transaction, - }) - }) - .map_err(|reason| { - transaction_unknown_call_error(HostCreateTransactionError::V1, reason) - }) + .map(HostCreateTransactionResponse::V1) + .map_err(|reason| transaction_call_error(HostCreateTransactionError::V1, reason)) } #[instrument(skip_all, fields(runtime.method = "signing.sign_payload_with_legacy_account"))] @@ -1289,7 +982,7 @@ impl Signing for PlatformRuntimeHost { CallError, > { let HostSignPayloadWithLegacyAccountRequest::V1(inner) = request; - let Some(session) = self.session_state.current() else { + let Some(session) = self.authority.current_session() else { return Err(CallError::Domain( HostSignPayloadWithLegacyAccountError::V1(v01::HostSignPayloadError::Rejected), )); @@ -1301,6 +994,7 @@ impl Signing for PlatformRuntimeHost { )) .await?; let confirmed = self + .services .platform .confirm_user_action(UserConfirmationReview::SignPayload( SignPayloadReview::LegacyAccount(inner.clone()), @@ -1314,26 +1008,21 @@ impl Signing for PlatformRuntimeHost { HostSignPayloadWithLegacyAccountError::V1(v01::HostSignPayloadError::Rejected), )); } - let message_id = sso_message_id(cx, "legacy-sign-payload"); - let message = sign_payload_message( - message_id, - v01::HostSignPayloadRequest { - account: v01::ProductAccountId { - dot_ns_identifier: self.product_id(), - derivation_index: 0, + self.authority + .sign_payload( + cx, + &session, + SignPayloadAuthorityRequest::LegacyAccount { + product_account: v01::ProductAccountId { + dot_ns_identifier: self.product_id(), + derivation_index: 0, + }, + request: inner, }, - payload: inner.payload, - }, - ); - self.submit_sign_request( - cx, - &session, - HostSignPayloadWithLegacyAccountError::V1, - "legacy-sign-payload", - message, - HostSignPayloadWithLegacyAccountResponse::V1, - ) - .await + ) + .await + .map(HostSignPayloadWithLegacyAccountResponse::V1) + .map_err(|reason| signing_call_error(HostSignPayloadWithLegacyAccountError::V1, reason)) } #[instrument(skip_all, fields(runtime.method = "signing.sign_raw_with_legacy_account"))] @@ -1344,7 +1033,7 @@ impl Signing for PlatformRuntimeHost { ) -> Result> { let HostSignRawWithLegacyAccountRequest::V1(inner) = request; - let Some(session) = self.session_state.current() else { + let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostSignRawWithLegacyAccountError::V1( v01::HostSignPayloadError::Rejected, ))); @@ -1356,6 +1045,7 @@ impl Signing for PlatformRuntimeHost { )) .await?; let confirmed = self + .services .platform .confirm_user_action(UserConfirmationReview::SignRaw( SignRawReview::LegacyAccount(inner.clone()), @@ -1369,26 +1059,21 @@ impl Signing for PlatformRuntimeHost { v01::HostSignPayloadError::Rejected, ))); } - let message_id = sso_message_id(cx, "legacy-sign-raw"); - let message = sign_raw_message( - message_id, - v01::HostSignRawRequest { - account: v01::ProductAccountId { - dot_ns_identifier: self.product_id(), - derivation_index: 0, + self.authority + .sign_raw( + cx, + &session, + SignRawAuthorityRequest::LegacyAccount { + product_account: v01::ProductAccountId { + dot_ns_identifier: self.product_id(), + derivation_index: 0, + }, + request: inner, }, - payload: inner.payload, - }, - ); - self.submit_sign_request( - cx, - &session, - HostSignRawWithLegacyAccountError::V1, - "legacy-sign-raw", - message, - HostSignRawWithLegacyAccountResponse::V1, - ) - .await + ) + .await + .map(HostSignRawWithLegacyAccountResponse::V1) + .map_err(|reason| signing_call_error(HostSignRawWithLegacyAccountError::V1, reason)) } #[instrument(skip_all, fields(runtime.method = "signing.create_transaction_with_legacy_account"))] @@ -1401,7 +1086,7 @@ impl Signing for PlatformRuntimeHost { CallError, > { let HostCreateTransactionWithLegacyAccountRequest::V1(inner) = request; - let Some(session) = self.session_state.current() else { + let Some(session) = self.authority.current_session() else { return Err(CallError::Domain( HostCreateTransactionWithLegacyAccountError::V1( v01::HostCreateTransactionError::Rejected, @@ -1417,6 +1102,7 @@ impl Signing for PlatformRuntimeHost { )) .await?; let confirmed = self + .services .platform .confirm_user_action(UserConfirmationReview::CreateTransaction( CreateTransactionReview::LegacyAccount(inner.clone()), @@ -1432,47 +1118,28 @@ impl Signing for PlatformRuntimeHost { ), )); } - let message_id = sso_message_id(cx, "legacy-create-transaction"); - let message = create_transaction_message( - message_id, - v01::ProductAccountTxPayload { - signer: v01::ProductAccountId { - dot_ns_identifier: self.product_id(), - derivation_index: 0, + self.authority + .create_transaction( + cx, + &session, + CreateTransactionAuthorityRequest::LegacyAccount { + product_account: v01::ProductAccountId { + dot_ns_identifier: self.product_id(), + derivation_index: 0, + }, + request: inner, }, - genesis_hash: inner.genesis_hash, - call_data: inner.call_data, - extensions: inner.extensions, - tx_ext_version: inner.tx_ext_version, - }, - ); - let response = self - .submit_sso_remote_message(cx, &session, "legacy-create-transaction", message) + ) .await - .map_err(|reason| { - transaction_unknown_call_error( - HostCreateTransactionWithLegacyAccountError::V1, - reason, - ) - })?; - let SsoRemoteResponse::CreateTransaction(response) = response else { - return Err(transaction_unknown_call_error( - HostCreateTransactionWithLegacyAccountError::V1, - UNEXPECTED_SSO_TRANSACTION_RESPONSE, - )); - }; - response - .signed_transaction - .map(|transaction| { + .map(|response| { HostCreateTransactionWithLegacyAccountResponse::V1( - v01::HostCreateTransactionWithLegacyAccountResponse { transaction }, + v01::HostCreateTransactionWithLegacyAccountResponse { + transaction: response.transaction, + }, ) }) .map_err(|reason| { - transaction_unknown_call_error( - HostCreateTransactionWithLegacyAccountError::V1, - reason, - ) + transaction_call_error(HostCreateTransactionWithLegacyAccountError::V1, reason) }) } } @@ -1488,7 +1155,7 @@ impl Signing for PlatformRuntimeHost { // translated into `RemoteChainHeadFollowItem` items on the subscription // stream. -impl Chain for PlatformRuntimeHost { +impl Chain for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "chain.follow_head_subscribe"))] async fn follow_head_subscribe( &self, @@ -1496,8 +1163,9 @@ impl Chain for PlatformRuntimeHost { request: RemoteChainHeadFollowRequest, ) -> Subscription { let RemoteChainHeadFollowRequest::V1(inner) = request; - let follow_subscription_id = cx.request_id().to_string(); + let follow_subscription_id = self.follow_id(cx.request_id()); let stream = self + .services .chain .remote_chain_head_follow(follow_subscription_id, inner) .map(RemoteChainHeadFollowItem::V1); @@ -1510,8 +1178,10 @@ impl Chain for PlatformRuntimeHost { _cx: &CallContext, request: RemoteChainHeadHeaderRequest, ) -> Result> { - let RemoteChainHeadHeaderRequest::V1(inner) = request; - self.chain + let RemoteChainHeadHeaderRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain .remote_chain_head_header(inner) .await .map(RemoteChainHeadHeaderResponse::V1) @@ -1524,8 +1194,10 @@ impl Chain for PlatformRuntimeHost { _cx: &CallContext, request: RemoteChainHeadBodyRequest, ) -> Result> { - let RemoteChainHeadBodyRequest::V1(inner) = request; - self.chain + let RemoteChainHeadBodyRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain .remote_chain_head_body(inner) .await .map(RemoteChainHeadBodyResponse::V1) @@ -1538,8 +1210,10 @@ impl Chain for PlatformRuntimeHost { _cx: &CallContext, request: RemoteChainHeadStorageRequest, ) -> Result> { - let RemoteChainHeadStorageRequest::V1(inner) = request; - self.chain + let RemoteChainHeadStorageRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain .remote_chain_head_storage(inner) .await .map(RemoteChainHeadStorageResponse::V1) @@ -1552,8 +1226,10 @@ impl Chain for PlatformRuntimeHost { _cx: &CallContext, request: RemoteChainHeadCallRequest, ) -> Result> { - let RemoteChainHeadCallRequest::V1(inner) = request; - self.chain + let RemoteChainHeadCallRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain .remote_chain_head_call(inner) .await .map(RemoteChainHeadCallResponse::V1) @@ -1566,8 +1242,10 @@ impl Chain for PlatformRuntimeHost { _cx: &CallContext, request: RemoteChainHeadUnpinRequest, ) -> Result> { - let RemoteChainHeadUnpinRequest::V1(inner) = request; - self.chain + let RemoteChainHeadUnpinRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain .remote_chain_head_unpin(inner) .await .map(|()| RemoteChainHeadUnpinResponse::V1) @@ -1580,8 +1258,10 @@ impl Chain for PlatformRuntimeHost { _cx: &CallContext, request: RemoteChainHeadContinueRequest, ) -> Result> { - let RemoteChainHeadContinueRequest::V1(inner) = request; - self.chain + let RemoteChainHeadContinueRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain .remote_chain_head_continue(inner) .await .map(|()| RemoteChainHeadContinueResponse::V1) @@ -1595,8 +1275,10 @@ impl Chain for PlatformRuntimeHost { request: RemoteChainHeadStopOperationRequest, ) -> Result> { - let RemoteChainHeadStopOperationRequest::V1(inner) = request; - self.chain + let RemoteChainHeadStopOperationRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain .remote_chain_head_stop_operation(inner) .await .map(|()| RemoteChainHeadStopOperationResponse::V1) @@ -1611,7 +1293,8 @@ impl Chain for PlatformRuntimeHost { ) -> Result> { let RemoteChainSpecGenesisHashRequest::V1(inner) = request; - self.chain + self.services + .chain .remote_chain_spec_genesis_hash(inner.genesis_hash) .await .map(RemoteChainSpecGenesisHashResponse::V1) @@ -1625,7 +1308,8 @@ impl Chain for PlatformRuntimeHost { request: RemoteChainSpecChainNameRequest, ) -> Result> { let RemoteChainSpecChainNameRequest::V1(inner) = request; - self.chain + self.services + .chain .remote_chain_spec_chain_name(inner.genesis_hash) .await .map(RemoteChainSpecChainNameResponse::V1) @@ -1639,7 +1323,8 @@ impl Chain for PlatformRuntimeHost { request: RemoteChainSpecPropertiesRequest, ) -> Result> { let RemoteChainSpecPropertiesRequest::V1(inner) = request; - self.chain + self.services + .chain .remote_chain_spec_properties(inner.genesis_hash) .await .map(RemoteChainSpecPropertiesResponse::V1) @@ -1656,7 +1341,8 @@ impl Chain for PlatformRuntimeHost { CallError, > { let RemoteChainTransactionBroadcastRequest::V1(inner) = request; - self.chain + self.services + .chain .remote_chain_transaction_broadcast(inner) .await .map(RemoteChainTransactionBroadcastResponse::V1) @@ -1671,7 +1357,8 @@ impl Chain for PlatformRuntimeHost { ) -> Result> { let RemoteChainTransactionStopRequest::V1(inner) = request; - self.chain + self.services + .chain .remote_chain_transaction_stop(inner) .await .map(|()| RemoteChainTransactionStopResponse::V1) @@ -1690,9 +1377,9 @@ impl Chain for PlatformRuntimeHost { const PAYMENTS_NOT_IMPLEMENTED: &str = "Payments are not supported in dot.li"; -impl Chat for PlatformRuntimeHost {} -impl CoinPayment for PlatformRuntimeHost {} -impl Payment for PlatformRuntimeHost { +impl Chat for ProductRuntimeHost {} +impl CoinPayment for ProductRuntimeHost {} +impl Payment for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "payment.balance_subscribe"))] async fn balance_subscribe( &self, @@ -1750,7 +1437,7 @@ impl Payment for PlatformRuntimeHost { } } -impl ResourceAllocation for PlatformRuntimeHost { +impl ResourceAllocation for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "resource_allocation.request"))] async fn request( &self, @@ -1759,7 +1446,7 @@ impl ResourceAllocation for PlatformRuntimeHost { ) -> Result> { let HostRequestResourceAllocationRequest::V1(inner) = request; - let Some(session) = self.session_state.current() else { + let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostRequestResourceAllocationError::V1( v01::ResourceAllocationError::Unknown { reason: "No active session".to_string(), @@ -1768,6 +1455,7 @@ impl ResourceAllocation for PlatformRuntimeHost { }; let confirmed = self + .services .platform .confirm_user_action(UserConfirmationReview::ResourceAllocation(inner.clone())) .await @@ -1781,60 +1469,24 @@ impl ResourceAllocation for PlatformRuntimeHost { }, ))); } - let message_id = sso_message_id(cx, "resource-allocation"); - let message = resource_allocation_message( - message_id, - self.product_id(), - inner.resources, - OnExistingAllowancePolicy::Increase, - ); - let response = self - .submit_sso_remote_message(cx, &session, "resource-allocation", message) + self.authority + .allocate_resources(cx, &session, self.product_id(), inner) .await - .map_err(|reason| { + .map(HostRequestResourceAllocationResponse::V1) + .map_err(|err| { CallError::Domain(HostRequestResourceAllocationError::V1( - v01::ResourceAllocationError::Unknown { reason }, - )) - })?; - let SsoRemoteResponse::ResourceAllocation(response) = response else { - return Err(CallError::Domain(HostRequestResourceAllocationError::V1( - v01::ResourceAllocationError::Unknown { - reason: "Unexpected SSO response for resource allocation request".to_string(), - }, - ))); - }; - response - .payload - .map(|outcomes| { - HostRequestResourceAllocationResponse::V1( - v01::HostRequestResourceAllocationResponse { - outcomes: outcomes - .into_iter() - .map(resource_allocation_outcome) - .collect(), + v01::ResourceAllocationError::Unknown { + reason: err.reason(), }, - ) - }) - .map_err(|reason| { - CallError::Domain(HostRequestResourceAllocationError::V1( - v01::ResourceAllocationError::Unknown { reason }, )) }) } } - -fn resource_allocation_outcome(outcome: SsoAllocationOutcome) -> v01::AllocationOutcome { - match outcome { - SsoAllocationOutcome::Allocated(_) => v01::AllocationOutcome::Allocated, - SsoAllocationOutcome::Rejected => v01::AllocationOutcome::Rejected, - SsoAllocationOutcome::NotAvailable => v01::AllocationOutcome::NotAvailable, - } -} // --------------------------------------------------------------------------- // Entropy // --------------------------------------------------------------------------- -impl Entropy for PlatformRuntimeHost { +impl Entropy for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "entropy.derive"))] async fn derive( &self, @@ -1842,30 +1494,23 @@ impl Entropy for PlatformRuntimeHost { request: HostDeriveEntropyRequest, ) -> Result> { let HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { context }) = request; - let Some(session) = self.session_state.current() else { + let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostDeriveEntropyError::V1( v01::HostDeriveEntropyError::Unknown { reason: "Not connected".to_string(), }, ))); }; - let Some(root_entropy_source) = session.root_entropy_source else { - return Err(CallError::Domain(HostDeriveEntropyError::V1( - v01::HostDeriveEntropyError::Unknown { - reason: "Session secret missing".to_string(), - }, - ))); - }; - - let entropy = - derive_product_entropy_from_source(&root_entropy_source, &self.product_id(), &context) - .map_err(|err| { - CallError::Domain(HostDeriveEntropyError::V1( - v01::HostDeriveEntropyError::Unknown { - reason: err.to_string(), - }, - )) - })?; + let entropy = self + .authority + .derive_entropy(&session, &self.product_id(), &context) + .map_err(|err| { + CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { + reason: err.reason(), + }, + )) + })?; Ok(HostDeriveEntropyResponse::V1( v01::HostDeriveEntropyResponse { entropy }, @@ -1877,7 +1522,7 @@ impl Entropy for PlatformRuntimeHost { // Preimage // --------------------------------------------------------------------------- -impl Preimage for PlatformRuntimeHost { +impl Preimage for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "preimage.lookup_subscribe"))] async fn lookup_subscribe( &self, @@ -1888,6 +1533,7 @@ impl Preimage for PlatformRuntimeHost { key, }) = request; let stream = self + .services .platform .lookup_preimage(key) .filter_map(|item| async move { @@ -1908,6 +1554,7 @@ impl Preimage for PlatformRuntimeHost { ) -> Result> { let RemotePreimageSubmitRequest::V1(value) = request; let confirmed = self + .services .platform .confirm_user_action(UserConfirmationReview::PreimageSubmit( PreimageSubmitReview { @@ -1927,7 +1574,8 @@ impl Preimage for PlatformRuntimeHost { }, ))); } - self.platform + self.services + .platform .submit_preimage(value) .await .map(RemotePreimageSubmitResponse::V1) @@ -1939,24 +1587,28 @@ impl Preimage for PlatformRuntimeHost { // Theme // --------------------------------------------------------------------------- -impl Theme for PlatformRuntimeHost { +impl Theme for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "theme.subscribe"))] async fn subscribe(&self, _cx: &CallContext) -> Subscription { - let stream = self.platform.subscribe_theme().filter_map(|item| async { - item.ok().map(|variant| { - HostThemeSubscribeItem::V1(v01::HostThemeSubscribeItem { - name: v01::ThemeName::Default, - variant, + let stream = self + .services + .platform + .subscribe_theme() + .filter_map(|item| async { + item.ok().map(|variant| { + HostThemeSubscribeItem::V1(v01::HostThemeSubscribeItem { + name: v01::ThemeName::Default, + variant, + }) }) - }) - }); + }); Subscription::new(Box::pin(stream)) } } // `Notifications` delegates to the platform so hosts can own scheduling and // cancellation while the core preserves the typed TrUAPI wire shape. -impl Notifications for PlatformRuntimeHost { +impl Notifications for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "notifications.send_push_notification"))] async fn send_push_notification( &self, @@ -1964,7 +1616,8 @@ impl Notifications for PlatformRuntimeHost { request: HostPushNotificationRequest, ) -> Result> { let HostPushNotificationRequest::V1(inner) = request; - self.platform + self.services + .platform .push_notification(inner) .await .map(HostPushNotificationResponse::V1) @@ -1984,7 +1637,8 @@ impl Notifications for PlatformRuntimeHost { { let HostPushNotificationCancelRequest::V1(v01::HostPushNotificationCancelRequest { id }) = request; - self.platform + self.services + .platform .cancel_notification(id) .await .map(|()| HostPushNotificationCancelResponse::V1) @@ -2004,8 +1658,6 @@ mod tests { use std::sync::Mutex; use truapi_platform::{AuthState, CoreStorageKey}; - use super::sso_remote::SSO_PEER_DISCONNECT_REASON; - fn wait_until(mut condition: impl FnMut() -> bool, message: &str) { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); while !condition() { @@ -2016,7 +1668,7 @@ mod tests { #[test] fn feature_supported_round_trips_through_runtime() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); let cx = CallContext::new(); let request = HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Chain { genesis_hash: vec![0u8; 32], @@ -2026,9 +1678,31 @@ mod tests { assert!(inner.supported); } + #[test] + fn chain_follow_ids_are_scoped_per_product_core() { + let (host_config, product) = runtime_config("same.dot"); + let spawner = test_spawner(); + let platform: Arc = stub_platform(); + let services = RuntimeServices::new( + platform.clone(), + host_config.people_chain_genesis_hash, + spawner.clone(), + ); + let pairing_host = PairingHost::new(services.clone(), host_config); + let first = ProductRuntimeHost::from_services( + services.clone(), + pairing_host.clone(), + product.clone(), + ); + let second = ProductRuntimeHost::from_services(services, pairing_host, product); + + assert_eq!(first.follow_id("request-1"), "c1:request-1"); + assert_eq!(second.follow_id("request-1"), "c2:request-1"); + } + #[test] fn navigate_to_uses_dotns_decision_and_then_platform() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); let cx = CallContext::new(); let request = HostNavigateToRequest::V1(v01::HostNavigateToRequest { url: "mytestapp.dot".to_string(), @@ -2039,7 +1713,7 @@ mod tests { #[test] fn navigate_to_rejects_empty_input_without_calling_platform() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); let cx = CallContext::new(); let request = HostNavigateToRequest::V1(v01::HostNavigateToRequest { url: "".to_string(), @@ -2061,7 +1735,7 @@ mod tests { pushed_notifications: pushed_notifications.clone(), ..Default::default() }); - let host = PlatformRuntimeHost::new_compat(platform, test_spawner()); + let host = ProductRuntimeHost::new_compat(platform, test_spawner()); let cx = CallContext::new(); let request = HostPushNotificationRequest::V1(v01::HostPushNotificationRequest { text: "Hello".to_string(), @@ -2096,7 +1770,7 @@ mod tests { cancelled_notifications: cancelled_notifications.clone(), ..Default::default() }); - let host = PlatformRuntimeHost::new_compat(platform, test_spawner()); + let host = ProductRuntimeHost::new_compat(platform, test_spawner()); let cx = CallContext::new(); let request = HostPushNotificationCancelRequest::V1(v01::HostPushNotificationCancelRequest { @@ -2118,7 +1792,7 @@ mod tests { #[test] fn get_account_requires_session() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); let cx = CallContext::new(); let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { @@ -2137,8 +1811,8 @@ mod tests { #[test] fn get_account_rejects_invalid_product_identifier() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); - host.session_state().set_session(session_info()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { @@ -2157,8 +1831,8 @@ mod tests { #[test] fn get_account_derives_dotli_product_key() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); - host.session_state().set_session(session_info()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { @@ -2176,8 +1850,8 @@ mod tests { #[test] fn get_account_normalizes_product_identifier_before_deriving() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); - host.session_state().set_session(session_info()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { @@ -2196,7 +1870,7 @@ mod tests { #[test] fn get_account_alias_requires_session() { let host = - PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); let cx = CallContext::new(); let err = futures::executor::block_on( host.get_account_alias(&cx, account_alias_request("myapp.dot")), @@ -2213,8 +1887,10 @@ mod tests { #[test] fn get_account_alias_rejects_invalid_product_identifier() { let host = - PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); - host.session_state().set_session(session_info()); + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let mut session = sso_session_info(); + session.root_entropy_source = session_info().root_entropy_source; + host.test_session_state().set_session(session); let cx = CallContext::new(); let err = futures::executor::block_on( host.get_account_alias(&cx, account_alias_request("example.com")), @@ -2252,12 +1928,12 @@ mod tests { ), ..Default::default() }); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("myapp.dot"), test_spawner(), ); - host.session_state().set_session(session.clone()); + host.test_session_state().set_session(session.clone()); let cx = CallContext::with_request_id("alias-1".to_string()); let response = futures::executor::block_on( host.get_account_alias(&cx, account_alias_request("myapp.dot")), @@ -2301,12 +1977,12 @@ mod tests { ), ..Default::default() }); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("MyApp.DOT"), test_spawner(), ); - host.session_state().set_session(session.clone()); + host.test_session_state().set_session(session.clone()); let cx = CallContext::with_request_id("alias-1".to_string()); futures::executor::block_on( host.get_account_alias(&cx, account_alias_request("MyApp.DOT")), @@ -2326,8 +2002,8 @@ mod tests { #[test] fn get_account_alias_cross_domain_rejects_when_user_declines() { let host = - PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); - host.session_state().set_session(session_info()); + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let err = futures::executor::block_on( host.get_account_alias(&cx, account_alias_request("other.dot")), @@ -2343,7 +2019,7 @@ mod tests { #[test] fn get_account_alias_cross_domain_maps_confirmation_failure_to_host_failure() { - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( Arc::new(StubPlatform { account_alias_error: Some("modal failed"), ..Default::default() @@ -2351,7 +2027,7 @@ mod tests { runtime_config("myapp.dot"), test_spawner(), ); - host.session_state().set_session(session_info()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let err = futures::executor::block_on( host.get_account_alias(&cx, account_alias_request("other.dot")), @@ -2387,12 +2063,12 @@ mod tests { ), ..Default::default() }); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("myapp.dot"), test_spawner(), ); - host.session_state().set_session(session.clone()); + host.test_session_state().set_session(session.clone()); let cx = CallContext::with_request_id("alias-2".to_string()); let response = futures::executor::block_on( host.get_account_alias(&cx, account_alias_request("other.dot")), @@ -2412,12 +2088,12 @@ mod tests { #[test] fn get_legacy_accounts_returns_derived_slot_zero_when_connected() { - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( stub_platform(), runtime_config("localhost:3000"), test_spawner(), ); - host.session_state().set_session(session_info()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let response = futures::executor::block_on( host.get_legacy_accounts(&cx, HostGetLegacyAccountsRequest::V1), @@ -2434,7 +2110,7 @@ mod tests { #[test] fn get_legacy_accounts_returns_empty_when_disconnected() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); let cx = CallContext::new(); let response = futures::executor::block_on( host.get_legacy_accounts(&cx, HostGetLegacyAccountsRequest::V1), @@ -2446,8 +2122,8 @@ mod tests { #[test] fn get_user_id_returns_primary_username() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); - host.session_state().set_session(session_info()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let response = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)).unwrap(); @@ -2458,8 +2134,10 @@ mod tests { #[test] fn derive_entropy_matches_dotli_vector() { let host = - PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); - host.session_state().set_session(session_info()); + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let mut session = sso_session_info(); + session.root_entropy_source = session_info().root_entropy_source; + host.test_session_state().set_session(session); let cx = CallContext::new(); let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { context: b"product-key".to_vec(), @@ -2474,7 +2152,7 @@ mod tests { #[test] fn derive_entropy_requires_session() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); let cx = CallContext::new(); let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { context: b"product-key".to_vec(), @@ -2490,10 +2168,10 @@ mod tests { #[test] fn derive_entropy_requires_secret() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); - let mut session = session_info(); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let mut session = sso_session_info(); session.root_entropy_source = None; - host.session_state().set_session(session); + host.test_session_state().set_session(session); let cx = CallContext::new(); let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { context: b"product-key".to_vec(), @@ -2509,8 +2187,10 @@ mod tests { #[test] fn derive_entropy_rejects_empty_context_like_dotli_key() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); - host.session_state().set_session(session_info()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let mut session = sso_session_info(); + session.root_entropy_source = session_info().root_entropy_source; + host.test_session_state().set_session(session); let cx = CallContext::new(); let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { context: vec![] }); @@ -2525,7 +2205,7 @@ mod tests { #[test] fn preimage_submit_confirms_and_delegates_to_platform() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); let cx = CallContext::new(); let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); let response = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap(); @@ -2534,7 +2214,7 @@ mod tests { #[test] fn preimage_lookup_subscribe_maps_platform_values() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); let cx = CallContext::new(); let request = RemotePreimageLookupSubscribeRequest::V1(v01::RemotePreimageLookupSubscribeRequest { @@ -2552,7 +2232,7 @@ mod tests { #[test] fn theme_subscribe_maps_platform_values() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); let cx = CallContext::new(); let mut subscription = futures::executor::block_on(Theme::subscribe(&host, &cx)); let item = futures::executor::block_on(subscription.next()).expect("theme item"); @@ -2568,8 +2248,8 @@ mod tests { #[test] fn sign_raw_rejects_invalid_product_account() { let host = - PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); - host.session_state().set_session(session_info()); + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = HostSignRawRequest::V1(v01::HostSignRawRequest { account: account_id("other.dot", 0), @@ -2587,7 +2267,7 @@ mod tests { #[test] fn sign_raw_rejects_without_session_after_valid_account() { let host = - PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); let cx = CallContext::new(); let request = HostSignRawRequest::V1(v01::HostSignRawRequest { account: account_id("myapp.dot", 0), @@ -2602,15 +2282,15 @@ mod tests { #[test] fn sign_raw_denies_when_chain_submit_denied() { - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( Arc::new(StubPlatform { - remote_permission_granted: false, + remote_permission_denied: true, ..Default::default() }), runtime_config("myapp.dot"), test_spawner(), ); - host.session_state().set_session(session_info()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = HostSignRawRequest::V1(v01::HostSignRawRequest { account: account_id("myapp.dot", 0), @@ -2628,8 +2308,8 @@ mod tests { #[test] fn sign_raw_rejects_when_user_declines_confirmation() { let host = - PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); - host.session_state().set_session(session_info()); + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = HostSignRawRequest::V1(v01::HostSignRawRequest { account: account_id("myapp.dot", 0), @@ -2654,12 +2334,12 @@ mod tests { ), ..Default::default() }); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("myapp.dot"), test_spawner(), ); - host.session_state().set_session(session.clone()); + host.test_session_state().set_session(session.clone()); let cx = CallContext::with_request_id("sign-raw-1".to_string()); let request = HostSignRawRequest::V1(v01::HostSignRawRequest { account: account_id("myapp.dot", 0), @@ -2719,13 +2399,13 @@ mod tests { rpc_responses: sso_peer_disconnect_responses(&session, "sign-raw-disconnect"), ..Default::default() }); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("myapp.dot"), test_spawner(), ); - host.session_state().set_session(session); - let mut statuses = host.session_state().subscribe(); + host.test_session_state().set_session(session); + let mut statuses = host.test_session_state().subscribe(); assert_eq!( futures::executor::block_on(statuses.next()).unwrap(), HostAccountConnectionStatusSubscribeItem::V1( @@ -2742,11 +2422,9 @@ mod tests { assert!(matches!( err, - CallError::Domain(HostSignRawError::V1( - v01::HostSignPayloadError::Unknown { reason } - )) if reason == SSO_PEER_DISCONNECT_REASON + CallError::Domain(HostSignRawError::V1(v01::HostSignPayloadError::Rejected)) )); - assert!(host.session_state().current().is_none()); + assert!(host.test_session_state().current().is_none()); assert_eq!( *platform .session_clears @@ -2769,13 +2447,15 @@ mod tests { rpc_responses: sso_peer_disconnect_monitor_responses(&session), ..Default::default() }); - let host = PlatformRuntimeHost::new( + let (host_config, product) = runtime_config("myapp.dot"); + let (host, pairing_host) = ProductRuntimeHost::new_pairing_for_tests( platform.clone(), - runtime_config("myapp.dot"), + host_config, + product, test_spawner(), ); - host.session_state().set_session(session.clone()); - let mut statuses = host.session_state().subscribe(); + host.test_session_state().set_session(session.clone()); + let mut statuses = host.test_session_state().subscribe(); assert_eq!( futures::executor::block_on(statuses.next()).unwrap(), HostAccountConnectionStatusSubscribeItem::V1( @@ -2783,7 +2463,7 @@ mod tests { ) ); - host.start_sso_disconnect_monitor(&session); + pairing_host.start_session_supervision_for_current_session(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); let disconnected = loop { @@ -2797,7 +2477,7 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(5)); }; - assert!(host.session_state().current().is_none()); + assert!(host.test_session_state().current().is_none()); assert_eq!( *platform .session_clears @@ -2815,15 +2495,15 @@ mod tests { #[test] fn sign_payload_denies_when_chain_submit_denied() { - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( Arc::new(StubPlatform { - remote_permission_granted: false, + remote_permission_denied: true, ..Default::default() }), runtime_config("myapp.dot"), test_spawner(), ); - host.session_state().set_session(session_info()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = HostSignPayloadRequest::V1(v01::HostSignPayloadRequest { account: account_id("myapp.dot", 0), @@ -2840,7 +2520,7 @@ mod tests { #[test] fn sign_payload_maps_confirmation_failure_to_host_failure() { - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( Arc::new(StubPlatform { sign_payload_error: Some("modal failed"), ..Default::default() @@ -2848,7 +2528,7 @@ mod tests { runtime_config("myapp.dot"), test_spawner(), ); - host.session_state().set_session(session_info()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = HostSignPayloadRequest::V1(v01::HostSignPayloadRequest { account: account_id("myapp.dot", 0), @@ -2872,12 +2552,12 @@ mod tests { ), ..Default::default() }); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("myapp.dot"), test_spawner(), ); - host.session_state().set_session(session.clone()); + host.test_session_state().set_session(session.clone()); let cx = CallContext::with_request_id("sign-payload-1".to_string()); let request = HostSignPayloadRequest::V1(v01::HostSignPayloadRequest { account: account_id("myapp.dot", 0), @@ -2923,12 +2603,12 @@ mod tests { ), ..Default::default() }); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("myapp.dot"), test_spawner(), ); - host.session_state().set_session(session.clone()); + host.test_session_state().set_session(session.clone()); let cx = CallContext::with_request_id("create-tx-1".to_string()); let request = HostCreateTransactionRequest::V1(product_tx_payload("myapp.dot")); let response = futures::executor::block_on(host.create_transaction(&cx, request)).unwrap(); @@ -2946,8 +2626,8 @@ mod tests { #[test] fn legacy_sign_raw_rejects_signer_mismatch() { let host = - PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); - host.session_state().set_session(session_info()); + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { @@ -2966,15 +2646,15 @@ mod tests { #[test] fn legacy_sign_raw_denies_when_chain_submit_denied() { - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( Arc::new(StubPlatform { - remote_permission_granted: false, + remote_permission_denied: true, ..Default::default() }), runtime_config("myapp.dot"), test_spawner(), ); - host.session_state().set_session(sso_session_info()); + host.test_session_state().set_session(sso_session_info()); let cx = CallContext::new(); let request = HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { @@ -3003,12 +2683,12 @@ mod tests { ), ..Default::default() }); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("myapp.dot"), test_spawner(), ); - host.session_state().set_session(session.clone()); + host.test_session_state().set_session(session.clone()); let cx = CallContext::with_request_id("legacy-sign-raw-1".to_string()); let request = HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { @@ -3021,20 +2701,26 @@ mod tests { assert_eq!(inner.signature, vec![9, 9]); assert_eq!(inner.signed_transaction, None); let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::RemoteMessageV1::SignRequest(request), + ) = message.data + else { + panic!("expected product raw signing request"); + }; + let crate::host_logic::sso::messages::SigningRequest::Raw(request) = *request else { + panic!("expected raw signing payload"); + }; + assert_eq!( + request.product_account_id, + v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + } + ); assert!(matches!( - &message.data, - crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::SignRequest(request) - ) if matches!( - request.as_ref(), - crate::host_logic::sso::messages::SigningRequest::Raw(raw) - if raw.product_account_id == account_id("myapp.dot", 0) - && matches!( - &raw.data, - crate::host_logic::sso::messages::SigningRawPayload::Bytes(bytes) - if bytes == b"hello" - ) - ) + &request.data, + crate::host_logic::sso::messages::SigningRawPayload::Bytes(bytes) + if bytes == b"hello" )); } @@ -3051,12 +2737,12 @@ mod tests { ), ..Default::default() }); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("myapp.dot"), test_spawner(), ); - host.session_state().set_session(session.clone()); + host.test_session_state().set_session(session.clone()); let cx = CallContext::with_request_id("legacy-sign-raw-hex-1".to_string()); let request = HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { @@ -3073,19 +2759,25 @@ mod tests { crate::host_logic::sso::messages::RemoteMessageV1::SignRequest(request), ) = message.data else { - panic!("expected raw signing request"); + panic!("expected product raw signing request"); }; let crate::host_logic::sso::messages::SigningRequest::Raw(request) = *request else { - panic!("expected raw signing request"); + panic!("expected raw signing payload"); }; - assert_eq!(request.product_account_id, account_id("myapp.dot", 0)); + assert_eq!( + request.product_account_id, + v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + } + ); } #[test] fn legacy_create_transaction_rejects_raw_key_mismatch() { let host = - PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); - host.session_state().set_session(session_info()); + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = HostCreateTransactionWithLegacyAccountRequest::V1(v01::LegacyAccountTxPayload { @@ -3129,12 +2821,12 @@ mod tests { ), ..Default::default() }); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("myapp.dot"), test_spawner(), ); - host.session_state().set_session(session.clone()); + host.test_session_state().set_session(session.clone()); let cx = CallContext::with_request_id("legacy-create-tx-1".to_string()); let request = HostCreateTransactionWithLegacyAccountRequest::V1(v01::LegacyAccountTxPayload { @@ -3160,18 +2852,20 @@ mod tests { }; let crate::host_logic::sso::messages::CreateTransactionPayload::V1(payload) = request.payload; - assert_eq!(payload.signer, account_id("myapp.dot", 0)); assert_eq!( - signer, - derive_product_public_key(session.public_key, "myapp.dot", 0).unwrap() + payload.signer, + v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + } ); } #[test] fn create_transaction_rejects_invalid_product_account() { let host = - PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); - host.session_state().set_session(session_info()); + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = HostCreateTransactionRequest::V1(product_tx_payload("other.dot")); let err = futures::executor::block_on(host.create_transaction(&cx, request)).unwrap_err(); @@ -3185,7 +2879,7 @@ mod tests { #[test] fn resource_allocation_rejects_without_session() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); let cx = CallContext::new(); let err = futures::executor::block_on(ResourceAllocation::request( &host, @@ -3203,8 +2897,8 @@ mod tests { #[test] fn resource_allocation_rejects_when_user_declines() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); - host.session_state().set_session(session_info()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let err = futures::executor::block_on(ResourceAllocation::request( &host, @@ -3222,14 +2916,14 @@ mod tests { #[test] fn resource_allocation_maps_confirmation_failure_to_host_failure() { - let host = PlatformRuntimeHost::new_compat( + let host = ProductRuntimeHost::new_compat( Arc::new(StubPlatform { resource_allocation_error: Some("modal failed"), ..Default::default() }), test_spawner(), ); - host.session_state().set_session(session_info()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let err = futures::executor::block_on(ResourceAllocation::request( &host, @@ -3272,8 +2966,8 @@ mod tests { ), ..Default::default() }); - let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); - host.session_state().set_session(session.clone()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session.clone()); let cx = CallContext::with_request_id("alloc-1".to_string()); let response = futures::executor::block_on(ResourceAllocation::request( &host, @@ -3308,15 +3002,18 @@ mod tests { )), ..Default::default() }); - let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); - host.start_session_store_sync(test_spawner()); + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); wait_until( - || host.session_state().current() == Some(stored.clone()), + || host.test_session_state().current() == Some(stored.clone()), "session store sync did not restore valid blob", ); - assert_eq!(host.session_state().current(), Some(stored.clone())); + assert_eq!(host.test_session_state().current(), Some(stored.clone())); assert_eq!( *platform .auth_states @@ -3330,7 +3027,7 @@ mod tests { fn session_store_sync_replaces_valid_blob_and_broadcasts_connected() { let mut replacement = sso_session_info(); replacement.public_key = [0x44; 32]; - let host = PlatformRuntimeHost::new_compat( + let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing( Arc::new(StubPlatform { session_blob: Some(crate::host_logic::session::encode_persisted_session( &replacement, @@ -3339,11 +3036,13 @@ mod tests { }), test_spawner(), ); - host.session_state().set_session(sso_session_info()); - let mut statuses = host.session_state().subscribe(); + host.test_session_state().set_session(sso_session_info()); + let mut statuses = host.test_session_state().subscribe(); let _ = futures::executor::block_on(statuses.next()); - host.start_session_store_sync(test_spawner()); + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); assert_eq!( futures::executor::block_on(statuses.next()).unwrap(), @@ -3351,7 +3050,7 @@ mod tests { v01::HostAccountConnectionStatusSubscribeItem::Connected ) ); - assert_eq!(host.session_state().current(), Some(replacement)); + assert_eq!(host.test_session_state().current(), Some(replacement)); } #[test] @@ -3360,16 +3059,19 @@ mod tests { session_blob: Some(vec![0xff]), ..Default::default() }); - let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); - host.session_state().set_session(sso_session_info()); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + host.test_session_state().set_session(sso_session_info()); - host.start_session_store_sync(test_spawner()); + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); wait_until( - || host.session_state().current().is_none(), + || host.test_session_state().current().is_none(), "session store sync did not clear invalid blob", ); - assert!(host.session_state().current().is_none()); + assert!(host.test_session_state().current().is_none()); // `set_session` bypasses the auth state cell, so the cell never left // `Disconnected` and clearing the invalid blob emits nothing. assert!( @@ -3384,7 +3086,7 @@ mod tests { #[test] fn session_store_sync_clears_unreadable_blob() { let session_clears = Arc::new(Mutex::new(0)); - let host = PlatformRuntimeHost::new_compat( + let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing( Arc::new(StubPlatform { session_error: Some("storage unavailable"), session_clears: session_clears.clone(), @@ -3392,15 +3094,17 @@ mod tests { }), test_spawner(), ); - host.session_state().set_session(sso_session_info()); + host.test_session_state().set_session(sso_session_info()); - host.start_session_store_sync(test_spawner()); + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); wait_until( || *session_clears.lock().unwrap() == 1, "session store sync did not clear unreadable blob", ); - assert!(host.session_state().current().is_none()); + assert!(host.test_session_state().current().is_none()); assert_eq!(*session_clears.lock().unwrap(), 1); } @@ -3409,7 +3113,7 @@ mod tests { #[test] fn session_store_sync_clears_once_on_initial_persistent_read_error() { let session_clears = Arc::new(Mutex::new(0)); - let host = PlatformRuntimeHost::new_compat( + let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing( Arc::new(StubPlatform { session_error: Some("storage unavailable"), session_clears: session_clears.clone(), @@ -3417,28 +3121,30 @@ mod tests { }), test_spawner(), ); - host.session_state().set_session(sso_session_info()); + host.test_session_state().set_session(sso_session_info()); - host.start_session_store_sync(test_spawner()); + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); wait_until( || *session_clears.lock().unwrap() == 1, "clear_stored_session was never called", ); assert_eq!(*session_clears.lock().unwrap(), 1); - assert!(host.session_state().current().is_none()); + assert!(host.test_session_state().current().is_none()); } #[test] fn disconnect_submits_disconnected_message_best_effort() { let platform = Arc::new(StubPlatform::default()); - let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); let session = sso_session_info(); - host.session_state().set_session(session.clone()); + host.test_session_state().set_session(session.clone()); futures::executor::block_on(host.disconnect()); - assert!(host.session_state().current().is_none()); + assert!(host.test_session_state().current().is_none()); assert_eq!( *platform .session_clears @@ -3457,8 +3163,8 @@ mod tests { #[test] fn disconnect_clears_session_store_and_broadcasts_disconnected() { let platform = Arc::new(StubPlatform::default()); - let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); - host.session_state().set_session(sso_session_info()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(sso_session_info()); platform .local_storage .lock() @@ -3467,7 +3173,7 @@ mod tests { core_storage_test_key(CoreStorageKey::PairingDeviceIdentity), vec![1, 2, 3], ); - let mut statuses = host.session_state().subscribe(); + let mut statuses = host.test_session_state().subscribe(); assert_eq!( futures::executor::block_on(statuses.next()).unwrap(), HostAccountConnectionStatusSubscribeItem::V1( @@ -3477,7 +3183,7 @@ mod tests { futures::executor::block_on(host.disconnect()); - assert!(host.session_state().current().is_none()); + assert!(host.test_session_state().current().is_none()); assert_eq!( *platform .session_clears @@ -3521,8 +3227,11 @@ mod tests { )), ..Default::default() }); - let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); - host.start_session_store_sync(test_spawner()); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); wait_until( || { platform @@ -3552,12 +3261,12 @@ mod tests { #[test] fn disconnect_tolerates_repeated_logout_when_already_disconnected() { let platform = Arc::new(StubPlatform::default()); - let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); futures::executor::block_on(host.disconnect()); futures::executor::block_on(host.disconnect()); - assert!(host.session_state().current().is_none()); + assert!(host.test_session_state().current().is_none()); assert_eq!( *platform .session_clears @@ -3568,23 +3277,9 @@ mod tests { assert!(platform.sent_rpc.lock().unwrap().is_empty()); } - #[test] - fn disconnect_notifies_pending_sso_waiters() { - let platform = Arc::new(StubPlatform::default()); - let host = PlatformRuntimeHost::new_compat(platform, test_spawner()); - let (_waiter_id, disconnect) = host.session_disconnects.subscribe(); - - futures::executor::block_on(host.disconnect()); - - assert_eq!( - futures::executor::block_on(disconnect).unwrap(), - SSO_LOCAL_DISCONNECT_REASON - ); - } - #[test] fn permissions_grants_and_caches() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); let cx = CallContext::new(); let request = HostDevicePermissionRequest::V1(v01::HostDevicePermissionRequest::Camera); let response = @@ -3595,7 +3290,7 @@ mod tests { #[test] fn feature_supported_encodes_response_to_known_bytes() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); let cx = CallContext::new(); let request = HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Chain { genesis_hash: vec![0u8; 32], diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs new file mode 100644 index 00000000..1350bccb --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -0,0 +1,225 @@ +use async_trait::async_trait; +use std::sync::Arc; +use truapi::latest::{ + HostAccountGetAliasResponse, HostCreateTransactionResponse, + HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, + HostSignPayloadRequest, HostSignPayloadResponse, HostSignPayloadWithLegacyAccountRequest, + HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, + ProductAccountId, ProductAccountTxPayload, +}; +use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; +use truapi::{CallContext, CallError}; +use truapi_platform::ProductContext; + +use crate::host_logic::session::{SessionInfo, SessionState}; + +/// Snapshot of an account-authority session selected by the authority. +/// +/// This is the neutral session projection product runtimes can use while +/// preserving authority-private material inside the concrete authority +/// implementation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AuthoritySession { + /// Root account public key for the active authority session. + pub public_key: [u8; 32], + /// Identity account resolved from the signing host, when available. + pub identity_account_id: Option<[u8; 32]>, + /// Lightweight username resolved from People-chain identity, when available. + pub lite_username: Option, + /// Fully qualified username resolved from People-chain identity, when available. + pub full_username: Option, + /// Opaque session token used to reject stale pre-confirmation snapshots. + pub validation_id: Vec, +} + +impl AuthoritySession { + pub(crate) fn from_session_info(info: &SessionInfo, validation_id: Vec) -> Self { + Self { + public_key: info.public_key, + identity_account_id: info.identity_account_id, + lite_username: info.lite_username.clone(), + full_username: info.full_username.clone(), + validation_id, + } + } +} + +/// Typed account-authority failure before it is mapped to an API-specific error. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] +pub(crate) enum AuthorityError { + /// User or authority rejected the request. + #[display("Rejected")] + Rejected, + /// The selected authority session is no longer active. + #[display("Disconnected")] + Disconnected, + /// The authority cannot service the request. + #[display("{reason}")] + Unavailable { reason: String }, + /// Catch-all authority failure. + #[display("{reason}")] + Unknown { reason: String }, +} + +impl AuthorityError { + pub(crate) fn reason(self) -> String { + self.to_string() + } +} + +/// Payload-signing request selected by the product API entrypoint. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SignPayloadAuthorityRequest { + /// Sign a payload with a product-derived account. + Product(HostSignPayloadRequest), + /// Sign a payload through the legacy-account API. + LegacyAccount { + /// Product slot-zero account that backs the validated legacy signer. + product_account: ProductAccountId, + /// Original legacy-account request. + request: HostSignPayloadWithLegacyAccountRequest, + }, +} + +/// Raw-signing request selected by the product API entrypoint. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SignRawAuthorityRequest { + /// Sign raw data with a product-derived account. + Product(HostSignRawRequest), + /// Sign raw data through the legacy-account API using the product slot-zero account. + LegacyAccount { + /// Product slot-zero account that backs the validated legacy signer. + product_account: ProductAccountId, + /// Original legacy-account request. + request: HostSignRawWithLegacyAccountRequest, + }, +} + +/// Transaction-creation request selected by the product API entrypoint. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CreateTransactionAuthorityRequest { + /// Create a transaction with a product-derived account. + Product(ProductAccountTxPayload), + /// Create a transaction through the legacy-account API using the product slot-zero account. + LegacyAccount { + /// Product slot-zero account that backs the validated legacy signer. + product_account: ProductAccountId, + /// Original legacy-account transaction request. + request: LegacyAccountTxPayload, + }, +} + +/// Host-level account authority used by product runtimes. +/// +/// Pairing hosts implement this by forwarding authority requests to a paired +/// signing host. A signing-host implementation can later provide the same +/// surface from local keys without changing product runtime code. +#[async_trait] +pub(crate) trait ProductAuthority: Send + Sync { + /// Current account-authority session, if connected. + fn current_session(&self) -> Option; + + /// Shared session holder owned by this authority. + /// + /// Product runtimes use it for connection-status subscriptions. The + /// concrete authority keeps ownership of the actual session material. + fn session_state(&self) -> Arc; + + /// Request account connection for the calling product. + async fn request_login( + &self, + product: &ProductContext, + ) -> Result>; + + /// Disconnect the current account-authority session. + async fn disconnect(&self); + + /// Sign a SCALE transaction payload for a product account. + async fn sign_payload( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignPayloadAuthorityRequest, + ) -> Result; + + /// Sign arbitrary bytes for a product account. + async fn sign_raw( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignRawAuthorityRequest, + ) -> Result; + + /// Build and sign a transaction for a product account. + async fn create_transaction( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: CreateTransactionAuthorityRequest, + ) -> Result; + + /// Request an alias proof for a product account in another product context. + async fn account_alias( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_account_id: ProductAccountId, + requesting_product_id: String, + ) -> Result; + + /// Ask the account authority to allocate product-scoped resources. + async fn allocate_resources( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + request: HostRequestResourceAllocationRequest, + ) -> Result; + + /// Derive product-scoped entropy for a connected session. + fn derive_entropy( + &self, + session: &AuthoritySession, + product_id: &str, + context: &[u8], + ) -> Result<[u8; 32], AuthorityError>; +} + +/// Build the neutral authority-session snapshot for `session`. +pub(super) fn authority_session(session: &SessionInfo) -> AuthoritySession { + AuthoritySession::from_session_info(session, authority_session_validation_id(session)) +} + +/// Revalidate a pre-confirmation snapshot against the live session, returning +/// the current [`SessionInfo`] when it still matches. +/// +/// Both roles use this before touching key material: a snapshot taken before +/// user confirmation must still be the current authority session when the +/// signature or derivation happens, otherwise the request is rejected. +pub(super) fn require_current_session( + session_state: &SessionState, + session: &AuthoritySession, +) -> Result { + let current = session_state + .current() + .ok_or(AuthorityError::Disconnected)?; + if authority_session_validation_id(¤t) == session.validation_id { + Ok(current) + } else { + Err(AuthorityError::Disconnected) + } +} + +/// Opaque token identifying which concrete session a snapshot was taken from. +pub(super) fn authority_session_validation_id(session: &SessionInfo) -> Vec { + let mut id = Vec::with_capacity(67); + if let Some(sso) = &session.sso { + id.extend_from_slice(b"sso"); + id.extend_from_slice(&sso.session_id_own); + id.extend_from_slice(&sso.session_id_peer); + } else { + id.extend_from_slice(b"local"); + id.extend_from_slice(&session.public_key); + } + id +} diff --git a/rust/crates/truapi-server/src/runtime/identity.rs b/rust/crates/truapi-server/src/runtime/identity.rs index 1b3af084..d5bda8e9 100644 --- a/rust/crates/truapi-server/src/runtime/identity.rs +++ b/rust/crates/truapi-server/src/runtime/identity.rs @@ -8,7 +8,6 @@ use std::time::Duration; #[cfg(target_arch = "wasm32")] use web_time::Duration; -use super::PlatformRuntimeHost; use crate::chain_runtime::ChainRuntime; use crate::host_logic::identity::{ PeopleIdentity, decode_people_identity, resources_consumers_storage_key, @@ -18,25 +17,13 @@ use crate::host_logic::session::SessionInfo; use futures::stream::BoxStream; use futures::{FutureExt, StreamExt, pin_mut}; use tracing::{debug, instrument, warn}; -use truapi::v01; -use truapi::v01::{ - OperationStartedResult, RemoteChainHeadFollowItem as V01RemoteChainHeadFollowItem, - StorageQueryType, +use truapi::latest::{ + OperationStartedResult, RemoteChainHeadFollowItem, RemoteChainHeadFollowRequest, + RemoteChainHeadStorageRequest, StorageQueryItem, StorageQueryType, }; -impl PlatformRuntimeHost { - /// Resolve usernames for `session` against this runtime's people chain. - #[instrument(skip_all, fields(runtime.method = "session.identity.resolve"))] - pub(super) async fn resolve_session_identity(&self, session: SessionInfo) -> SessionInfo { - resolve_session_identity_with_chain( - &self.chain, - self.runtime_config.people_chain_genesis_hash, - session, - ) - .await - } -} - +/// Monotonic salt for local identity lookup follow ids, avoiding collisions +/// between concurrent People-chain identity lookups. static IDENTITY_LOOKUP_COUNTER: AtomicU64 = AtomicU64::new(1); /// Fill in missing usernames by querying the people chain; returns the @@ -147,7 +134,7 @@ async fn lookup_people_identity( ); let mut follow = chain.remote_chain_head_follow( follow_id.clone(), - v01::RemoteChainHeadFollowRequest { + RemoteChainHeadFollowRequest { genesis_hash: genesis_hash.clone(), with_runtime: false, }, @@ -155,11 +142,11 @@ async fn lookup_people_identity( let hash = wait_for_identity_follow_hash(&mut follow).await?; let response = chain - .remote_chain_head_storage(v01::RemoteChainHeadStorageRequest { + .remote_chain_head_storage(RemoteChainHeadStorageRequest { genesis_hash, follow_subscription_id: follow_id, hash, - items: vec![v01::StorageQueryItem { + items: vec![StorageQueryItem { key: key.clone(), query_type: StorageQueryType::Value, }], @@ -183,7 +170,7 @@ async fn lookup_people_identity( #[instrument(skip_all, fields(runtime.method = "session.identity.wait_follow_hash"))] async fn wait_for_identity_follow_hash( - follow: &mut BoxStream<'static, V01RemoteChainHeadFollowItem>, + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, ) -> Result, String> { let timeout = futures_timer::Delay::new(Duration::from_secs(10)).fuse(); pin_mut!(timeout); @@ -192,14 +179,14 @@ async fn wait_for_identity_follow_hash( pin_mut!(next); futures::select! { item = next => match item { - Some(V01RemoteChainHeadFollowItem::Initialized { finalized_block_hashes, .. }) => { + Some(RemoteChainHeadFollowItem::Initialized { finalized_block_hashes, .. }) => { let fallback = finalized_block_hashes.last().cloned(); return wait_for_identity_best_hash(follow, fallback).await; } - Some(V01RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { + Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { return Ok(best_block_hash); } - Some(V01RemoteChainHeadFollowItem::Stop) | None => { + Some(RemoteChainHeadFollowItem::Stop) | None => { return Err("People-chain follow stopped before initialization".to_string()); } _ => {} @@ -210,7 +197,7 @@ async fn wait_for_identity_follow_hash( } async fn wait_for_identity_best_hash( - follow: &mut BoxStream<'static, V01RemoteChainHeadFollowItem>, + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, fallback: Option>, ) -> Result, String> { let timeout = futures_timer::Delay::new(Duration::from_secs(2)).fuse(); @@ -221,13 +208,13 @@ async fn wait_for_identity_best_hash( pin_mut!(next); futures::select! { item = next => match item { - Some(V01RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { + Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { return Ok(best_block_hash); } - Some(V01RemoteChainHeadFollowItem::NewBlock { block_hash, .. }) => { + Some(RemoteChainHeadFollowItem::NewBlock { block_hash, .. }) => { candidate = Some(block_hash); } - Some(V01RemoteChainHeadFollowItem::Stop) | None => { + Some(RemoteChainHeadFollowItem::Stop) | None => { return candidate.ok_or_else(|| { "People-chain follow stopped before best block".to_string() }); @@ -245,7 +232,7 @@ async fn wait_for_identity_best_hash( #[instrument(skip_all, fields(runtime.method = "session.identity.wait_storage_value"))] async fn wait_for_identity_storage_value( - follow: &mut BoxStream<'static, V01RemoteChainHeadFollowItem>, + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, operation_id: &str, key: &[u8], ) -> Result>, String> { @@ -257,7 +244,7 @@ async fn wait_for_identity_storage_value( pin_mut!(next); futures::select! { item = next => match item { - Some(V01RemoteChainHeadFollowItem::OperationStorageItems { operation_id: item_operation_id, items }) + Some(RemoteChainHeadFollowItem::OperationStorageItems { operation_id: item_operation_id, items }) if item_operation_id == operation_id => { for item in items { @@ -266,22 +253,22 @@ async fn wait_for_identity_storage_value( } } } - Some(V01RemoteChainHeadFollowItem::OperationStorageDone { operation_id: item_operation_id }) + Some(RemoteChainHeadFollowItem::OperationStorageDone { operation_id: item_operation_id }) if item_operation_id == operation_id => { return Ok(value); } - Some(V01RemoteChainHeadFollowItem::OperationInaccessible { operation_id: item_operation_id }) + Some(RemoteChainHeadFollowItem::OperationInaccessible { operation_id: item_operation_id }) if item_operation_id == operation_id => { return Ok(None); } - Some(V01RemoteChainHeadFollowItem::OperationError { operation_id: item_operation_id, error }) + Some(RemoteChainHeadFollowItem::OperationError { operation_id: item_operation_id, error }) if item_operation_id == operation_id => { return Err(error); } - Some(V01RemoteChainHeadFollowItem::Stop) | None => { + Some(RemoteChainHeadFollowItem::Stop) | None => { return Err("People-chain follow stopped during storage lookup".to_string()); } _ => {} @@ -299,11 +286,11 @@ mod tests { #[test] fn identity_follow_prefers_best_block_after_initialization() { let mut follow = stream::iter(vec![ - V01RemoteChainHeadFollowItem::Initialized { + RemoteChainHeadFollowItem::Initialized { finalized_block_hashes: vec![vec![0x01]], finalized_block_runtime: None, }, - V01RemoteChainHeadFollowItem::BestBlockChanged { + RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash: vec![0x02], }, ]) @@ -318,16 +305,16 @@ mod tests { #[test] fn identity_follow_uses_new_block_before_stale_finalized_fallback() { let mut follow = stream::iter(vec![ - V01RemoteChainHeadFollowItem::Initialized { + RemoteChainHeadFollowItem::Initialized { finalized_block_hashes: vec![vec![0x01]], finalized_block_runtime: None, }, - V01RemoteChainHeadFollowItem::NewBlock { + RemoteChainHeadFollowItem::NewBlock { block_hash: vec![0x03], parent_block_hash: vec![0x01], new_runtime: None, }, - V01RemoteChainHeadFollowItem::Stop, + RemoteChainHeadFollowItem::Stop, ]) .boxed(); diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs new file mode 100644 index 00000000..65969b14 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -0,0 +1,565 @@ +//! Pairing-host role for inter-host account authority. +//! +//! A pairing host does not own the user's signing keys. It pairs with a signing +//! host, keeps the active inter-host session, and sends authority requests to +//! that signing host over the SSO channel in [`sso_channel`]. + +mod sso_channel; + +use std::sync::{Arc, Mutex, Weak}; + +use futures::channel::oneshot; +use sso_channel::SsoDisconnectMonitor; + +use super::auth_state::AuthStateMachine; +use super::authority::{ + AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, authority_session, + require_current_session, +}; +use super::connected_session_ui_info; +use super::identity::resolve_session_identity_with_chain; +use super::services::RuntimeServices; +use super::sso_pairing::{SsoPairingFlow, SsoPairingOutcome}; +use super::sso_remote::{SSO_PEER_DISCONNECT_REASON, SessionDisconnects, SsoSessionKey}; +use super::statement_store_rpc::StatementStoreRpc; +use crate::chain_runtime::ChainRuntime; +use crate::host_logic::entropy::derive_product_entropy_from_source; +use crate::host_logic::session::{SessionInfo, SessionState, encode_persisted_session}; +use crate::host_logic::session_store::SessionStoreChangeNotifier; +use crate::subscription::Spawner; + +use futures::StreamExt; +use tracing::instrument; +use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; +use truapi::{CallContext, CallError, v01}; +use truapi_platform::{CoreStorageKey, PairingHostConfig, Platform, ProductContext}; + +/// Distinguishes all remote authority request entrypoints by wire label. +#[derive(Clone, Copy, Debug, derive_more::Display)] +pub(super) enum AuthorityRequestKind { + #[display("sign-payload")] + SignPayload, + #[display("sign-raw")] + SignRaw, + #[display("create-transaction")] + CreateTransaction, + #[display("legacy-sign-payload")] + LegacySignPayload, + #[display("legacy-sign-raw")] + LegacySignRaw, + #[display("legacy-create-transaction")] + LegacyCreateTransaction, +} + +impl From<&SignPayloadAuthorityRequest> for AuthorityRequestKind { + fn from(request: &SignPayloadAuthorityRequest) -> Self { + match request { + SignPayloadAuthorityRequest::Product(_) => Self::SignPayload, + SignPayloadAuthorityRequest::LegacyAccount { .. } => Self::LegacySignPayload, + } + } +} + +impl From<&SignRawAuthorityRequest> for AuthorityRequestKind { + fn from(request: &SignRawAuthorityRequest) -> Self { + match request { + SignRawAuthorityRequest::Product(_) => Self::SignRaw, + SignRawAuthorityRequest::LegacyAccount { .. } => Self::LegacySignRaw, + } + } +} + +impl From<&CreateTransactionAuthorityRequest> for AuthorityRequestKind { + fn from(request: &CreateTransactionAuthorityRequest) -> Self { + match request { + CreateTransactionAuthorityRequest::Product(_) => Self::CreateTransaction, + CreateTransactionAuthorityRequest::LegacyAccount { .. } => { + Self::LegacyCreateTransaction + } + } + } +} + +struct LoginInFlight { + waiters: Vec>>, +} + +/// Remote account authority for a pairing host. +pub(crate) struct PairingHost { + pub(super) platform: Arc, + pub(super) host_config: PairingHostConfig, + pub(super) chain: ChainRuntime, + /// Active inter-host session with a signing host. + session_state: Arc, + session_store_changes: Arc, + pub(super) auth_state: AuthStateMachine, + pub(super) statement_store: StatementStoreRpc, + session_disconnects: Arc, + disconnect_monitor: Mutex>, + login_in_flight: Mutex>, + /// Self-reference captured by the spawned disconnect-monitor task. + weak_self: Weak, + pub(super) spawner: Spawner, +} + +impl PairingHost { + pub(crate) fn new(services: Arc, host_config: PairingHostConfig) -> Arc { + let platform = services.platform.clone(); + let auth_state = AuthStateMachine::new(platform.clone()); + Arc::new_cyclic(|weak_self| Self { + platform, + host_config, + chain: services.chain.clone(), + session_state: SessionState::new(), + session_store_changes: SessionStoreChangeNotifier::new(), + auth_state, + statement_store: services.statement_store.clone(), + session_disconnects: Arc::new(SessionDisconnects::default()), + disconnect_monitor: Mutex::new(None), + login_in_flight: Mutex::new(None), + weak_self: weak_self.clone(), + spawner: services.spawner.clone(), + }) + } + + pub(crate) fn session_state(&self) -> Arc { + self.session_state.clone() + } + + pub(crate) fn notify_session_store_changed(&self) { + self.session_store_changes.notify(); + } + + #[cfg(test)] + pub(crate) fn start_session_store_sync_for_tests(self: Arc, spawner: Spawner) { + self.start_session_store_sync(spawner); + } + + #[cfg(test)] + pub(crate) fn start_session_supervision_for_current_session(&self) { + self.start_remote_monitor_for_current_session(); + } + + fn current_session(&self) -> Option { + self.session_state.current().as_ref().map(authority_session) + } + + #[cfg(test)] + pub(crate) fn start_remote_monitor_for_current_session(&self) { + if let Some(session) = self.session_state.current() { + self.start_disconnect_monitor(&session); + } + } + + #[instrument(skip_all, fields(runtime.method = "session_store.sync"))] + pub(crate) fn start_session_store_sync(self: Arc, spawner: Spawner) { + let pairing_host = Arc::downgrade(&self); + spawner(Box::pin(async move { + let Some(current) = pairing_host.upgrade() else { + return; + }; + let mut ticks = current.session_store_changes.subscribe(); + drop(current); + // Clearing the store can itself notify this subscription; clear at + // most once per read-error streak so a persistently failing read + // cannot spin the loop through its own clear notifications. + let mut cleared_after_read_error = false; + while ticks.next().await.is_some() { + let Some(pairing_host) = pairing_host.upgrade() else { + break; + }; + match pairing_host + .platform + .read_core_storage(CoreStorageKey::AuthSession) + .await + { + Ok(Some(blob)) => { + cleared_after_read_error = false; + match crate::host_logic::session::decode_persisted_session(&blob) { + Ok(session) => { + let resolved = resolve_session_identity_with_chain( + &pairing_host.chain, + pairing_host.host_config.people_chain_genesis_hash, + session, + ) + .await; + if encode_persisted_session(&resolved) != blob { + let _ = pairing_host + .platform + .write_core_storage( + CoreStorageKey::AuthSession, + encode_persisted_session(&resolved), + ) + .await; + } + pairing_host.set_connected_session(resolved); + } + Err(_) => { + pairing_host.clear_disconnected_session(true, false).await; + } + } + } + Ok(None) => { + cleared_after_read_error = false; + pairing_host.clear_disconnected_session(false, false).await; + } + Err(_) => { + pairing_host.clear_disconnected_session(false, false).await; + if !cleared_after_read_error { + cleared_after_read_error = true; + let _ = pairing_host + .platform + .clear_core_storage(CoreStorageKey::AuthSession) + .await; + } + } + } + } + })); + } + + #[instrument(skip_all, fields(runtime.method = "account.request_login", product = %product.product_id))] + async fn request_login( + &self, + product: &ProductContext, + ) -> Result> { + let _ = product; + if let Some(session) = self.session_state.current() { + self.auth_state + .connected(&connected_session_ui_info(&session)); + return Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::AlreadyConnected, + )); + } + + if let Some(waiter) = self.login_waiter() { + match waiter.await { + Ok(Ok(())) => { + return Ok(HostRequestLoginResponse::V1( + if self.session_state.current().is_some() { + v01::HostRequestLoginResponse::AlreadyConnected + } else { + v01::HostRequestLoginResponse::Rejected + }, + )); + } + Ok(Err(reason)) => { + return Err(CallError::Domain(HostRequestLoginError::V1( + v01::HostRequestLoginError::Unknown { reason }, + ))); + } + Err(_) => { + return Err(CallError::Domain(HostRequestLoginError::V1( + v01::HostRequestLoginError::Unknown { + reason: "login waiter dropped".to_string(), + }, + ))); + } + } + } + + let outcome = match SsoPairingFlow::new(self).request_session().await { + Ok(outcome) => outcome, + Err(err) => { + self.finish_login_in_flight(Err(login_error_reason(&err))); + return Err(err); + } + }; + match outcome { + SsoPairingOutcome::Cancelled => { + self.finish_login_in_flight(Ok(())); + if self.session_state.current().is_some() { + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::AlreadyConnected, + )) + } else { + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::Rejected, + )) + } + } + SsoPairingOutcome::Success(session) => { + self.set_connected_session(*session); + self.finish_login_in_flight(Ok(())); + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::Success, + )) + } + } + } + + #[instrument(skip_all, fields(runtime.method = "account.disconnect"))] + async fn disconnect(&self) { + self.cancel_login(); + let session = self.session_state.current(); + self.clear_disconnected_session(true, true).await; + if let Some(session) = session.as_ref() { + let _ = self.submit_disconnected_message(session).await; + } + } + + #[instrument(skip_all, fields(runtime.method = "account.cancel_login"))] + pub(crate) fn cancel_login(&self) { + self.auth_state.login_cancelled(); + } + + fn login_waiter(&self) -> Option>> { + let mut in_flight = self + .login_in_flight + .lock() + .expect("login in-flight mutex poisoned"); + if let Some(in_flight) = in_flight.as_mut() { + let (tx, rx) = oneshot::channel(); + in_flight.waiters.push(tx); + Some(rx) + } else { + *in_flight = Some(LoginInFlight { + waiters: Vec::new(), + }); + None + } + } + + fn finish_login_in_flight(&self, result: Result<(), String>) { + let waiters = self + .login_in_flight + .lock() + .expect("login in-flight mutex poisoned") + .take() + .map(|in_flight| in_flight.waiters) + .unwrap_or_default(); + for waiter in waiters { + let _ = waiter.send(result.clone()); + } + } + + #[instrument(skip_all, fields(runtime.method = "session_store.clear_disconnected"))] + async fn clear_disconnected_session( + &self, + clear_auth_session: bool, + clear_pairing_identity: bool, + ) { + let previous = self.session_state.current(); + self.session_state.clear_session(); + self.stop_session_channel(previous.as_ref()); + if clear_auth_session { + let _ = self + .platform + .clear_core_storage(CoreStorageKey::AuthSession) + .await; + } + self.auth_state.store_disconnected(); + if clear_pairing_identity { + let _ = self + .platform + .clear_core_storage(CoreStorageKey::PairingDeviceIdentity) + .await; + } + } + + fn set_connected_session(&self, session: SessionInfo) { + let previous = self.session_state.current(); + self.session_state.set_session(session.clone()); + if previous.as_ref() != Some(&session) { + self.stop_session_channel(previous.as_ref()); + } + self.start_disconnect_monitor(&session); + self.auth_state + .connected(&connected_session_ui_info(&session)); + } + + /// Single funnel for peer-initiated disconnects. Every detection source + /// (monitor task, request-path error) must route here: it wakes in-flight + /// waiters for `key`, then clears the session when `key` is still current, + /// so stale notifications for replaced sessions only wake their own + /// waiters. + async fn handle_signing_host_disconnected(&self, key: SsoSessionKey) { + self.session_disconnects + .notify_key(key, SSO_PEER_DISCONNECT_REASON); + if !self.current_sso_session_matches(key) { + return; + } + + self.clear_disconnected_session(true, true).await; + } + + fn current_sso_session_matches(&self, key: SsoSessionKey) -> bool { + sso_channel::session_matches_key(&self.session_state, key) + } + + fn current_private_session( + &self, + session: &AuthoritySession, + ) -> Result { + require_current_session(&self.session_state, session) + } + + async fn sign_payload( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignPayloadAuthorityRequest, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_sign_payload(cx, &session, request).await + } + + async fn sign_raw( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignRawAuthorityRequest, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_sign_raw(cx, &session, request).await + } + + async fn create_transaction( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: CreateTransactionAuthorityRequest, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_create_transaction(cx, &session, request).await + } + + async fn account_alias( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_account_id: v01::ProductAccountId, + requesting_product_id: String, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_account_alias(cx, &session, product_account_id, requesting_product_id) + .await + } + + async fn allocate_resources( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + request: v01::HostRequestResourceAllocationRequest, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_allocate_resources(cx, &session, product_id, request) + .await + } + + fn derive_entropy( + &self, + session: &AuthoritySession, + product_id: &str, + context: &[u8], + ) -> Result<[u8; 32], AuthorityError> { + let session = self.current_private_session(session)?; + if session.sso.is_none() { + return Err(AuthorityError::Disconnected); + } + let root_entropy_source = + session + .root_entropy_source + .ok_or_else(|| AuthorityError::Unavailable { + reason: "Session secret missing".to_string(), + })?; + derive_product_entropy_from_source(&root_entropy_source, product_id, context).map_err( + |err| AuthorityError::Unknown { + reason: err.to_string(), + }, + ) + } +} + +fn login_error_reason(err: &CallError) -> String { + match err { + CallError::Domain(HostRequestLoginError::V1(v01::HostRequestLoginError::Unknown { + reason, + })) + | CallError::HostFailure { reason } => reason.clone(), + CallError::Unsupported => "login unsupported".to_string(), + CallError::Denied => "login denied".to_string(), + CallError::MalformedFrame { reason } => reason.clone(), + } +} + +#[async_trait::async_trait] +impl ProductAuthority for PairingHost { + fn current_session(&self) -> Option { + PairingHost::current_session(self) + } + + fn session_state(&self) -> Arc { + PairingHost::session_state(self) + } + + async fn request_login( + &self, + product: &ProductContext, + ) -> Result> { + PairingHost::request_login(self, product).await + } + + async fn disconnect(&self) { + PairingHost::disconnect(self).await; + } + + async fn sign_payload( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignPayloadAuthorityRequest, + ) -> Result { + PairingHost::sign_payload(self, cx, session, request).await + } + + async fn sign_raw( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignRawAuthorityRequest, + ) -> Result { + PairingHost::sign_raw(self, cx, session, request).await + } + + async fn create_transaction( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: CreateTransactionAuthorityRequest, + ) -> Result { + PairingHost::create_transaction(self, cx, session, request).await + } + + async fn account_alias( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_account_id: v01::ProductAccountId, + requesting_product_id: String, + ) -> Result { + PairingHost::account_alias(self, cx, session, product_account_id, requesting_product_id) + .await + } + + async fn allocate_resources( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + request: v01::HostRequestResourceAllocationRequest, + ) -> Result { + PairingHost::allocate_resources(self, cx, session, product_id, request).await + } + + fn derive_entropy( + &self, + session: &AuthoritySession, + product_id: &str, + context: &[u8], + ) -> Result<[u8; 32], AuthorityError> { + PairingHost::derive_entropy(self, session, product_id, context) + } +} diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs new file mode 100644 index 00000000..a790f1f2 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -0,0 +1,488 @@ +//! SSO statement-store channel to the paired remote signing host. +//! +//! The channel half of [`PairingHost`]: message submission and response +//! correlation over the statement store, plus the peer-disconnect monitor. +//! Role policy (session lifecycle, login, revalidation) stays in the parent +//! module. + +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; + +use super::super::authority::{ + AuthorityError, CreateTransactionAuthorityRequest, SignPayloadAuthorityRequest, + SignRawAuthorityRequest, +}; +use super::super::sso_remote::{ + SSO_LOCAL_DISCONNECT_REASON, SSO_PEER_DISCONNECT_REASON, SsoRemoteResponseWait, SsoSessionKey, + fresh_statement_expiry, sso_message_id, statement_subscription_stream, + subscribe_statement_topic, wait_for_sso_remote_response, +}; +use super::super::statement_store_rpc::{self, StatementStoreRpc}; +use super::AuthorityRequestKind; +use super::PairingHost; +use crate::host_logic::session::{SessionInfo, SessionState, SsoSessionInfo}; +use crate::host_logic::sso::messages::{ + OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, RemoteMessageV1, + SsoAllocationOutcome, SsoRemoteResponse, SsoSessionStatement, alias_request_message, + build_outgoing_request_statement, create_transaction_message, decode_sso_session_statement, + resource_allocation_message, sign_payload_message, sign_raw_message, +}; +use crate::host_logic::statement_store::parse_new_statements_result; + +use futures::FutureExt; +use futures::future::{AbortHandle, Abortable}; +use tracing::{debug, instrument, warn}; +use truapi::{CallContext, v01}; + +const DEFAULT_SSO_RESPONSE_TIMEOUT: Duration = Duration::from_secs(180); +const UNEXPECTED_SSO_SIGNING_RESPONSE: &str = "Unexpected SSO response for signing request"; +const UNEXPECTED_SSO_TRANSACTION_RESPONSE: &str = "Unexpected SSO response for transaction request"; + +#[derive(Clone, Copy, Debug, derive_more::Display)] +enum RemoteAction { + #[display("{_0}")] + Signing(AuthorityRequestKind), + #[display("account-alias")] + AccountAlias, + #[display("resource-allocation")] + ResourceAllocation, +} + +/// Active peer-disconnect watcher for one SSO session; aborts on drop. +pub(super) struct SsoDisconnectMonitor { + key: SsoSessionKey, + abort: AbortHandle, +} + +impl Drop for SsoDisconnectMonitor { + fn drop(&mut self) { + self.abort.abort(); + } +} + +impl PairingHost { + async fn submit_sign_request( + &self, + cx: &CallContext, + session: &SessionInfo, + action: AuthorityRequestKind, + message: RemoteMessage, + ) -> Result { + let response = self + .submit_remote_message( + cx, + session, + RemoteAction::Signing(action), + message, + Some(DEFAULT_SSO_RESPONSE_TIMEOUT), + ) + .await?; + let SsoRemoteResponse::Sign(response) = response else { + return Err(UNEXPECTED_SSO_SIGNING_RESPONSE.to_string()); + }; + response + .payload + .map(|payload| v01::HostSignPayloadResponse { + signature: payload.signature, + signed_transaction: payload.signed_transaction, + }) + } + + fn stop_disconnect_monitor(&self) { + self.disconnect_monitor + .lock() + .expect("SSO disconnect monitor mutex poisoned") + .take(); + } + + pub(super) fn start_disconnect_monitor(&self, session: &SessionInfo) { + let Some(sso) = session.sso.clone() else { + self.stop_disconnect_monitor(); + return; + }; + let key = SsoSessionKey::from_session(&sso); + + let (registration, spawner) = { + let mut current = self + .disconnect_monitor + .lock() + .expect("SSO disconnect monitor mutex poisoned"); + if current.as_ref().is_some_and(|active| active.key == key) { + return; + } + let (abort, registration) = AbortHandle::new_pair(); + *current = Some(SsoDisconnectMonitor { key, abort }); + (registration, self.spawner.clone()) + }; + + let statement_store = self.statement_store.clone(); + let pairing_host = self.weak_self.clone(); + let future = async move { + let result = wait_for_sso_peer_disconnect(statement_store, sso).await; + let Some(pairing_host) = pairing_host.upgrade() else { + return; + }; + { + let mut active = pairing_host + .disconnect_monitor + .lock() + .expect("SSO disconnect monitor mutex poisoned"); + if active.as_ref().is_some_and(|active| active.key == key) { + *active = None; + } + } + match result { + Ok(()) => { + pairing_host.handle_signing_host_disconnected(key).await; + } + Err(reason) => { + warn!(%reason, "SSO peer disconnect monitor stopped"); + } + } + }; + spawner(Box::pin(Abortable::new(future, registration).map(|_| ()))); + } + + /// Stop channel work for a cleared session: wake its in-flight waiters + /// with a local disconnect, then drop the peer-disconnect monitor. + pub(super) fn stop_session_channel(&self, session: Option<&SessionInfo>) { + if let Some(sso) = session.and_then(|session| session.sso.as_ref()) { + self.session_disconnects + .notify(sso, SSO_LOCAL_DISCONNECT_REASON); + } + self.stop_disconnect_monitor(); + } + + /// Best-effort `Disconnected` notification to the SSO peer. + #[instrument(skip_all, fields(runtime.method = "sso.disconnect.submit"))] + pub(super) async fn submit_disconnected_message( + &self, + session: &SessionInfo, + ) -> Result<(), String> { + let sso = session + .sso + .as_ref() + .ok_or_else(|| "No SSO session state".to_string())?; + let message_id = "truapi:sso:disconnect".to_string(); + let message = RemoteMessage { + message_id: message_id.clone(), + data: RemoteMessageData::V1(RemoteMessageV1::Disconnected), + }; + let statement = build_outgoing_request_statement( + sso, + message_id, + vec![message], + fresh_statement_expiry(), + )?; + self.statement_store + .submit_fire_and_forget(statement, "SSO statement-store") + .await + .map_err(|err| format!("SSO statement submit failed: {err}"))?; + Ok(()) + } + + /// Submit an SSO remote message and wait for the signing-host response. + /// + /// `timeout = None` is reserved for flows that block on remote user + /// interaction, such as alias approval. + #[instrument(skip_all, fields(runtime.method = "sso.remote_message.submit", action = %action))] + async fn submit_remote_message( + &self, + cx: &CallContext, + session: &SessionInfo, + action: RemoteAction, + message: RemoteMessage, + timeout: Option, + ) -> Result { + let sso = session + .sso + .as_ref() + .ok_or_else(|| "No SSO session state".to_string())?; + let key = SsoSessionKey::from_session(sso); + let (_disconnect_guard, disconnect) = self.session_disconnects.subscribe(sso); + if !session_matches_key(&self.session_state, key) { + return Err(SSO_LOCAL_DISCONNECT_REASON.to_string()); + } + let message_id = sso_message_id(cx, action); + let statement = build_outgoing_request_statement( + sso, + message_id.clone(), + vec![message], + fresh_statement_expiry(), + )?; + let rpc_client = self.statement_store.client("SSO statement-store").await?; + let own_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_own) + .await + .map_err(|err| format!("SSO own statement-store subscribe failed: {err}"))?; + let peer_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_peer) + .await + .map_err(|err| format!("SSO peer statement-store subscribe failed: {err}"))?; + let submit_client = rpc_client.clone(); + let session_state = self.session_state.clone(); + let submit = async move { + if !session_matches_key(&session_state, key) { + return Err(SSO_LOCAL_DISCONNECT_REASON.to_string()); + } + statement_store_rpc::submit(&submit_client, statement) + .await + .map_err(|err| format!("SSO statement submit failed: {err}")) + } + .boxed(); + let action = action.to_string(); + debug!(action, %message_id, "submitted SSO remote message, awaiting response"); + let result = wait_for_sso_remote_response( + statement_subscription_stream(own_subscription, "own"), + statement_subscription_stream(peer_subscription, "peer"), + submit, + SsoRemoteResponseWait { + session: sso, + statement_request_id: &message_id, + remote_message_id: &message_id, + timeout, + disconnect: Some(disconnect), + }, + ) + .await; + match &result { + Ok(_) => debug!(action, %message_id, "SSO remote response received"), + Err(reason) => warn!(action, %message_id, %reason, "SSO remote message failed"), + } + if matches!(&result, Err(reason) if reason == SSO_PEER_DISCONNECT_REASON) { + self.handle_signing_host_disconnected(key).await; + } + result + } + + pub(super) async fn remote_sign_payload( + &self, + cx: &CallContext, + session: &SessionInfo, + request: SignPayloadAuthorityRequest, + ) -> Result { + let action = AuthorityRequestKind::from(&request); + let message_id = sso_message_id(cx, RemoteAction::Signing(action)); + let request = match request { + SignPayloadAuthorityRequest::Product(request) => request, + SignPayloadAuthorityRequest::LegacyAccount { + product_account, + request, + } => v01::HostSignPayloadRequest { + account: product_account, + payload: request.payload, + }, + }; + let message = sign_payload_message(message_id, request); + self.submit_sign_request(cx, session, action, message) + .await + .map_err(remote_authority_error) + } + + pub(super) async fn remote_sign_raw( + &self, + cx: &CallContext, + session: &SessionInfo, + request: SignRawAuthorityRequest, + ) -> Result { + let action = AuthorityRequestKind::from(&request); + let message_id = sso_message_id(cx, RemoteAction::Signing(action)); + let request = match request { + SignRawAuthorityRequest::Product(request) => request, + SignRawAuthorityRequest::LegacyAccount { + product_account, + request, + } => v01::HostSignRawRequest { + account: product_account, + payload: request.payload, + }, + }; + let message = sign_raw_message(message_id, request); + let response = self + .submit_remote_message( + cx, + session, + RemoteAction::Signing(action), + message, + Some(DEFAULT_SSO_RESPONSE_TIMEOUT), + ) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::Sign(response) = response else { + return Err(AuthorityError::Unknown { + reason: UNEXPECTED_SSO_SIGNING_RESPONSE.to_string(), + }); + }; + response + .payload + .map(|payload| v01::HostSignPayloadResponse { + signature: payload.signature, + signed_transaction: payload.signed_transaction, + }) + .map_err(remote_authority_error) + } + + pub(super) async fn remote_create_transaction( + &self, + cx: &CallContext, + session: &SessionInfo, + request: CreateTransactionAuthorityRequest, + ) -> Result { + let action = AuthorityRequestKind::from(&request); + let message_id = sso_message_id(cx, RemoteAction::Signing(action)); + let request = match request { + CreateTransactionAuthorityRequest::Product(request) => request, + CreateTransactionAuthorityRequest::LegacyAccount { + product_account, + request, + } => v01::ProductAccountTxPayload { + signer: product_account, + genesis_hash: request.genesis_hash, + call_data: request.call_data, + extensions: request.extensions, + tx_ext_version: request.tx_ext_version, + }, + }; + let message = create_transaction_message(message_id, request); + let response = self + .submit_remote_message( + cx, + session, + RemoteAction::Signing(action), + message, + Some(DEFAULT_SSO_RESPONSE_TIMEOUT), + ) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::CreateTransaction(response) = response else { + return Err(AuthorityError::Unknown { + reason: UNEXPECTED_SSO_TRANSACTION_RESPONSE.to_string(), + }); + }; + response + .signed_transaction + .map(|transaction| v01::HostCreateTransactionResponse { transaction }) + .map_err(remote_authority_error) + } + + pub(super) async fn remote_account_alias( + &self, + cx: &CallContext, + session: &SessionInfo, + product_account_id: v01::ProductAccountId, + requesting_product_id: String, + ) -> Result { + let message_id = sso_message_id(cx, RemoteAction::AccountAlias); + let message = alias_request_message( + message_id.clone(), + product_account_id, + requesting_product_id, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::AccountAlias, message, None) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::RingVrfAlias(response) = response else { + return Err(AuthorityError::Unknown { + reason: "Unexpected SSO response for account alias request".to_string(), + }); + }; + response.payload.map_err(remote_authority_error) + } + + pub(super) async fn remote_allocate_resources( + &self, + cx: &CallContext, + session: &SessionInfo, + product_id: String, + request: v01::HostRequestResourceAllocationRequest, + ) -> Result { + let message_id = sso_message_id(cx, RemoteAction::ResourceAllocation); + let message = resource_allocation_message( + message_id, + product_id, + request.resources, + OnExistingAllowancePolicy::Increase, + ); + let response = self + .submit_remote_message( + cx, + session, + RemoteAction::ResourceAllocation, + message, + Some(DEFAULT_SSO_RESPONSE_TIMEOUT), + ) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::ResourceAllocation(response) = response else { + return Err(AuthorityError::Unknown { + reason: "Unexpected SSO response for resource allocation request".to_string(), + }); + }; + response + .payload + .map(|outcomes| v01::HostRequestResourceAllocationResponse { + outcomes: outcomes.into_iter().map(Into::into).collect(), + }) + .map_err(remote_authority_error) + } +} + +/// True when the current session's SSO channel matches `key`. +pub(super) fn session_matches_key(session_state: &SessionState, key: SsoSessionKey) -> bool { + session_state.current().as_ref().is_some_and(|current| { + current + .sso + .as_ref() + .is_some_and(|sso| SsoSessionKey::from_session(sso) == key) + }) +} + +fn remote_authority_error(reason: String) -> AuthorityError { + match reason.as_str() { + "Rejected" | "User rejected" => AuthorityError::Rejected, + SSO_LOCAL_DISCONNECT_REASON | SSO_PEER_DISCONNECT_REASON => AuthorityError::Disconnected, + _ => AuthorityError::Unknown { reason }, + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.peer_disconnect.monitor"))] +async fn wait_for_sso_peer_disconnect( + statement_store: StatementStoreRpc, + session: SsoSessionInfo, +) -> Result<(), String> { + let rpc_client = statement_store.client("SSO disconnect monitor").await?; + let mut subscription = + statement_store_rpc::subscribe_match_all(&rpc_client, &[session.session_id_peer]) + .await + .map_err(|err| format!("SSO disconnect monitor subscribe failed: {err}"))?; + while let Some(item) = subscription.next().await { + let value = item.map_err(|err| format!("SSO disconnect monitor item failed: {err}"))?; + let page = parse_new_statements_result("sso-peer-disconnect-monitor".to_string(), &value) + .map_err(|err| err.to_string())?; + for statement in page.statements { + if matches!( + decode_sso_session_statement( + &session, + &statement, + "truapi:sso-peer-disconnect-monitor", + "truapi:sso-peer-disconnect-monitor", + )?, + Some(SsoSessionStatement::Disconnected) + ) { + return Ok(()); + } + } + } + Err("SSO disconnect monitor response stream ended".to_string()) +} + +impl From for v01::AllocationOutcome { + fn from(outcome: SsoAllocationOutcome) -> Self { + match outcome { + SsoAllocationOutcome::Allocated(_) => Self::Allocated, + SsoAllocationOutcome::Rejected => Self::Rejected, + SsoAllocationOutcome::NotAvailable => Self::NotAvailable, + } + } +} diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs new file mode 100644 index 00000000..97511af5 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -0,0 +1,77 @@ +//! Role-neutral runtime services shared by product-facing runtimes. +//! +//! This module owns only infrastructure that is valid for both pairing hosts +//! and signing hosts. Pairing state, signing state, active sessions, and role +//! controls live on the concrete role objects. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::chain_runtime::{ChainRuntime, RuntimeChainProvider, RuntimeFailure}; +use crate::runtime::statement_store_rpc::StatementStoreRpc; +use crate::subscription::Spawner; +use async_trait::async_trait; +use truapi_platform::{JsonRpcConnection, Platform}; + +/// Infrastructure shared by all product runtimes created from one host role. +pub(crate) struct RuntimeServices { + pub(crate) platform: Arc, + pub(crate) chain: ChainRuntime, + pub(crate) statement_store: StatementStoreRpc, + pub(crate) spawner: Spawner, + next_core_instance: AtomicU64, +} + +impl RuntimeServices { + /// Build role-neutral runtime services from the platform and People-chain + /// genesis hash used by statement-store backed protocols. + pub(crate) fn new( + platform: Arc, + people_chain_genesis_hash: [u8; 32], + spawner: Spawner, + ) -> Arc { + let chain_provider = Arc::new(HostChainProvider { + platform: platform.clone(), + }); + let chain = ChainRuntime::new(chain_provider, spawner.clone()); + let statement_store = + StatementStoreRpc::new(platform.clone(), people_chain_genesis_hash, spawner.clone()); + Arc::new(Self { + platform, + chain, + statement_store, + spawner, + next_core_instance: AtomicU64::new(1), + }) + } + + pub(crate) fn next_core_instance(&self) -> u64 { + self.next_core_instance.fetch_add(1, Ordering::Relaxed) + } +} + +/// Adapter from `truapi_platform::ChainProvider` into the +/// [`RuntimeChainProvider`] surface the chain runtime expects. +struct HostChainProvider { + platform: Arc, +} + +#[async_trait] +impl RuntimeChainProvider for HostChainProvider { + async fn connect( + &self, + genesis_hash: Vec, + ) -> Result, RuntimeFailure> { + let genesis_hash: [u8; 32] = genesis_hash.try_into().map_err(|genesis_hash: Vec| { + RuntimeFailure::host_failure( + "remote_chain_connect", + format!("genesis_hash must be 32 bytes, got {}", genesis_hash.len()), + ) + })?; + self.platform + .connect(genesis_hash) + .await + .map(Arc::from) + .map_err(|_| RuntimeFailure::unavailable("remote_chain_connect")) + } +} diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs new file mode 100644 index 00000000..b6734d73 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -0,0 +1,616 @@ +//! Signing-host role for wallet-local account authority. +//! +//! A signing host owns the user's keys and serves authority requests locally, +//! with no pairing flow and no SSO channel. Secret material is provided by the +//! embedding host at unlock through [`LocalActivation::activate_local_session`] +//! (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). + +mod local_activation; + +use std::sync::{Arc, Mutex}; + +pub(crate) use local_activation::LocalActivation; + +use super::authority::{ + AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, authority_session, + require_current_session, +}; +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, +}; +use crate::host_logic::session::SessionState; +use crate::runtime::auth_state::AuthStateMachine; + +use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; +use truapi::{CallContext, CallError, v01}; +use truapi_platform::{Platform, ProductContext, normalize_product_identifier}; +use zeroize::Zeroizing; + +const BYTES_WRAP_PREFIX: &[u8] = b""; +const BYTES_WRAP_SUFFIX: &[u8] = b""; + +/// Wallet-local account authority for a signing host. +pub(crate) struct SigningHost { + session_state: Arc, + auth_state: AuthStateMachine, + /// Root BIP-39 entropy held only while a session is active. + root_entropy: Mutex>>>, +} + +impl SigningHost { + pub(crate) fn new(platform: Arc) -> Arc { + Arc::new(Self { + session_state: SessionState::new(), + auth_state: AuthStateMachine::new(platform), + root_entropy: Mutex::new(None), + }) + } + + pub(super) fn session_state(&self) -> Arc { + self.session_state.clone() + } + + /// Current root entropy, or [`AuthorityError::Disconnected`] when no local + /// session is active. + fn root_entropy(&self) -> Result>, AuthorityError> { + self.root_entropy + .lock() + .expect("signing host entropy mutex poisoned") + .clone() + .ok_or(AuthorityError::Disconnected) + } + + /// Derive the product-account keypair for `account` from the root entropy. + /// + /// 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. + 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 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) + .map_err(product_authority_error) + } +} + +#[async_trait::async_trait] +impl ProductAuthority for SigningHost { + fn current_session(&self) -> Option { + self.session_state.current().as_ref().map(authority_session) + } + + fn session_state(&self) -> Arc { + SigningHost::session_state(self) + } + + async fn request_login( + &self, + _product: &ProductContext, + ) -> Result> { + if let Some(session) = self.session_state.current() { + self.auth_state + .connected(&connected_session_ui_info(&session)); + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::AlreadyConnected, + )) + } else { + // The host activates a local session out of band once the wallet + // is unlocked; there is no in-core login prompt to drive. + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::Rejected, + )) + } + } + + async fn disconnect(&self) { + self.root_entropy + .lock() + .expect("signing host entropy mutex poisoned") + .take(); + self.session_state.clear_session(); + self.auth_state.store_disconnected(); + } + + async fn sign_payload( + &self, + _cx: &CallContext, + _session: &AuthoritySession, + _request: SignPayloadAuthorityRequest, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: extrinsic-payload signing needs chain-metadata payload \ + assembly (not yet implemented)" + .to_string(), + }) + } + + async fn sign_raw( + &self, + _cx: &CallContext, + 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(), + }); + }; + require_current_session(&self.session_state, session)?; + let keypair = self.product_keypair(&request.account)?; + let message = raw_payload_bytes(request.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, + }) + } + + async fn create_transaction( + &self, + _cx: &CallContext, + _session: &AuthoritySession, + _request: CreateTransactionAuthorityRequest, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: transaction construction needs chain metadata (not yet \ + implemented)" + .to_string(), + }) + } + + async fn account_alias( + &self, + _cx: &CallContext, + _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(), + }) + } + + async fn allocate_resources( + &self, + _cx: &CallContext, + _session: &AuthoritySession, + _product_id: String, + _request: v01::HostRequestResourceAllocationRequest, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: on-chain resource allocation not yet implemented".to_string(), + }) + } + + fn derive_entropy( + &self, + session: &AuthoritySession, + product_id: &str, + context: &[u8], + ) -> Result<[u8; 32], AuthorityError> { + require_current_session(&self.session_state, session)?; + let entropy = self.root_entropy()?; + derive_product_entropy(&entropy, product_id, context).map_err(|err| { + AuthorityError::Unknown { + reason: err.to_string(), + } + }) + } +} + +fn product_authority_error(err: ProductAccountError) -> AuthorityError { + AuthorityError::Unavailable { + reason: err.to_string(), + } +} + +/// Wrap raw sign-message bytes in the `` envelope unless +/// already wrapped, matching the polkadot-app raw-signing convention. +/// +/// String payloads follow the polkadot-app `isHex` rule: a `0x`-prefixed, +/// even-length string is decoded from hex, and a corrupt hex body is a hard +/// error (never silently signed as UTF-8); any other string is signed as its +/// UTF-8 bytes. +fn raw_payload_bytes(payload: v01::RawPayload) -> Result, AuthorityError> { + let raw = match payload { + v01::RawPayload::Bytes { bytes } => bytes, + v01::RawPayload::Payload { payload } => decode_payload_string(payload)?, + }; + if raw.starts_with(BYTES_WRAP_PREFIX) && raw.ends_with(BYTES_WRAP_SUFFIX) { + return Ok(raw); + } + let mut wrapped = + Vec::with_capacity(BYTES_WRAP_PREFIX.len() + raw.len() + BYTES_WRAP_SUFFIX.len()); + wrapped.extend_from_slice(BYTES_WRAP_PREFIX); + wrapped.extend_from_slice(&raw); + wrapped.extend_from_slice(BYTES_WRAP_SUFFIX); + Ok(wrapped) +} + +fn decode_payload_string(payload: String) -> Result, AuthorityError> { + // `isHex`: `0x` prefix and even total length. Odd length is not hex and is + // signed as UTF-8, matching polkadot-app. + if let Some(body) = payload + .strip_prefix("0x") + .filter(|_| payload.len().is_multiple_of(2)) + { + return hex::decode(body).map_err(|_| AuthorityError::Unknown { + reason: "raw sign payload is 0x-prefixed but not valid hex".to_string(), + }); + } + Ok(payload.into_bytes()) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + 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::test_support::{StubPlatform, test_spawner}; + use truapi::api::{Account, Entropy, Signing}; + use truapi::versioned::account::{HostAccountGetError, HostAccountGetRequest}; + use truapi::versioned::entropy::HostDeriveEntropyRequest; + use truapi::versioned::signing::{HostSignRawError, HostSignRawRequest, HostSignRawResponse}; + use truapi::{CallContext, CallError, v01}; + use truapi_platform::{HostInfo, PlatformInfo, ProductContext, SigningHostConfig}; + + const ENTROPY: [u8; 16] = [0xAB; 16]; + + fn signing_runtime() -> (Arc, Arc) { + // Auto-confirm raw signing so the role-neutral confirmation gate does + // not reject before reaching the signing authority. + let platform: Arc = Arc::new(StubPlatform { + sign_raw_confirmed: true, + ..StubPlatform::default() + }); + let config = SigningHostConfig::new( + HostInfo { + name: "Polkadot Mobile".to_string(), + icon: None, + version: None, + }, + PlatformInfo::default(), + [0; 32], + ) + .expect("signing host config is valid"); + let services = RuntimeServices::new( + platform.clone(), + config.people_chain_genesis_hash, + test_spawner(), + ); + let signing_host = SigningHostRole::new(platform); + (services, signing_host) + } + + fn product_runtime( + services: Arc, + authority: Arc, + ) -> ProductRuntimeHost { + ProductRuntimeHost::from_services( + services, + authority, + ProductContext::new("myapp.dot".to_string()).expect("valid product id"), + ) + } + + fn product_runtime_for( + services: Arc, + authority: Arc, + product_id: &str, + ) -> ProductRuntimeHost { + ProductRuntimeHost::from_services( + services, + authority, + ProductContext::new(product_id.to_string()).expect("valid product id"), + ) + } + + #[test] + fn activate_then_sign_raw_verifies_against_derived_product_key() { + let (services, activation) = signing_runtime(); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let runtime = product_runtime(services, activation); + let cx = CallContext::new(); + + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: v01::RawPayload::Bytes { + bytes: b"hello world".to_vec(), + }, + }); + let HostSignRawResponse::V1(response) = + 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 signature = + schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); + assert!( + keypair + .public + .verify_simple(b"substrate", b"hello world", &signature) + .is_ok(), + "signature verifies over the -wrapped message", + ); + } + + #[test] + fn sign_raw_requires_active_session() { + let (services, authority) = signing_runtime(); + let runtime = product_runtime(services, authority); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: v01::RawPayload::Bytes { + bytes: vec![1, 2, 3], + }, + }); + let err = + futures::executor::block_on(runtime.sign_raw(&cx, request)).expect_err("no session"); + assert!(matches!(err, CallError::Domain(HostSignRawError::V1(_)))); + } + + #[test] + fn derive_entropy_matches_ios_vector_over_local_session() { + let (services, activation) = signing_runtime(); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let runtime = product_runtime_for(services, activation, "test.product.dot"); + let cx = CallContext::new(); + let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { + context: b"my-key".to_vec(), + }); + let response = + futures::executor::block_on(runtime.derive(&cx, request)).expect("derive ok"); + let truapi::versioned::entropy::HostDeriveEntropyResponse::V1(inner) = response; + assert_eq!( + hex::encode(inner.entropy), + "479d5b9ecce19615397c9f160ee95e2f00c579837a5afb111132dd0da5fd472a", + ); + } + + #[test] + fn get_account_gates_on_local_session() { + let (services, authority) = signing_runtime(); + let runtime = product_runtime(services, authority); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + }); + let err = futures::executor::block_on(runtime.get_account(&cx, request)) + .expect_err("no session yet"); + assert!(matches!( + err, + CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::NotConnected + )) + )); + } + + #[test] + fn raw_payload_bytes_wraps_and_decodes() { + let ok = |p| raw_payload_bytes(p).expect("payload ok"); + // Bytes are -wrapped. + assert_eq!( + ok(v01::RawPayload::Bytes { + bytes: b"hi".to_vec() + }), + b"hi".to_vec(), + ); + // A 0x-hex string payload decodes to bytes before wrapping. + assert_eq!( + ok(v01::RawPayload::Payload { + payload: "0xdeadbeef".to_string(), + }), + [ + BYTES_WRAP_PREFIX, + &[0xde, 0xad, 0xbe, 0xef], + BYTES_WRAP_SUFFIX + ] + .concat(), + ); + // A non-hex string payload is signed as UTF-8. + assert_eq!( + ok(v01::RawPayload::Payload { + payload: "hello".to_string(), + }), + b"hello".to_vec(), + ); + // An odd-length 0x string is not `isHex`, so it is signed as UTF-8. + assert_eq!( + ok(v01::RawPayload::Payload { + payload: "0xabc".to_string(), + }), + b"0xabc".to_vec(), + ); + // Already-wrapped input is left untouched (no double wrapping). + assert_eq!( + ok(v01::RawPayload::Bytes { + bytes: b"hi".to_vec(), + }), + b"hi".to_vec(), + ); + // An even-length 0x string that is not valid hex is a hard error, + // never silently signed as UTF-8 (matches polkadot-app abort). + assert!(matches!( + raw_payload_bytes(v01::RawPayload::Payload { + payload: "0xZZ".to_string(), + }), + Err(AuthorityError::Unknown { .. }), + )); + } + + #[test] + fn sign_raw_leaves_already_wrapped_payload_untouched() { + let (services, activation) = signing_runtime(); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let runtime = product_runtime(services, activation); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: v01::RawPayload::Bytes { + bytes: b"hi".to_vec(), + }, + }); + 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 signature = + schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); + assert!( + keypair + .public + .verify_simple(b"substrate", b"hi", &signature) + .is_ok(), + "signature verifies over the unchanged wrapped message", + ); + assert!( + keypair + .public + .verify_simple( + b"substrate", + b"hi", + &signature + ) + .is_err(), + "payload was not double-wrapped", + ); + } + + #[test] + fn reactivation_invalidates_prior_session_snapshot() { + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("first activation"); + let stale = authority.current_session().expect("snapshot"); + + // Re-activate with different entropy: a fresh public key, hence a + // different validation id. + futures::executor::block_on(authority.activate_local_session([0xCD; 16].to_vec())) + .expect("second activation"); + assert_ne!( + authority.current_session().expect("session").public_key, + stale.public_key, + ); + + let cx = CallContext::new(); + let request = v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: v01::RawPayload::Bytes { + bytes: vec![1, 2, 3], + }, + }; + let err = futures::executor::block_on(authority.sign_raw( + &cx, + &stale, + SignRawAuthorityRequest::Product(request), + )) + .expect_err("stale snapshot rejected"); + assert_eq!(err, AuthorityError::Disconnected); + } + + #[test] + fn disconnect_clears_local_session() { + 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"); + + futures::executor::block_on(authority.disconnect()); + assert!(authority.current_session().is_none()); + + let cx = CallContext::new(); + let request = v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: v01::RawPayload::Bytes { bytes: vec![1] }, + }; + let err = futures::executor::block_on(authority.sign_raw( + &cx, + &session, + SignRawAuthorityRequest::Product(request), + )) + .expect_err("no session after disconnect"); + assert_eq!(err, AuthorityError::Disconnected); + } + + #[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 alias = futures::executor::block_on(authority.account_alias( + &cx, + &session, + v01::ProductAccountId { + dot_ns_identifier: "other.dot".to_string(), + derivation_index: 0, + }, + "myapp.dot".to_string(), + )) + .expect_err("alias deferred"); + assert!(matches!(alias, AuthorityError::Unavailable { .. })); + + let alloc = futures::executor::block_on(authority.allocate_resources( + &cx, + &session, + "myapp.dot".to_string(), + v01::HostRequestResourceAllocationRequest { resources: vec![] }, + )) + .expect_err("allocation deferred"); + assert!(matches!(alloc, AuthorityError::Unavailable { .. })); + } +} 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 new file mode 100644 index 00000000..fa0635e4 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs @@ -0,0 +1,47 @@ +//! Signing-host-only capability: activate a wallet-local session from +//! host-held secret material. Implemented solely by [`SigningHost`]. + +use super::{SigningHost, product_authority_error}; +use crate::host_logic::product_account::derive_root_keypair_from_entropy; +use crate::host_logic::session::SessionInfo; +use crate::runtime::authority::AuthorityError; +use crate::runtime::connected_session_ui_info; + +use zeroize::Zeroizing; + +/// Establish a wallet-local session from host-held secret material. +/// +/// A signing host owns the user's keys, so it establishes sessions directly +/// rather than through the SSO pairing flow. Only [`SigningHost`] implements +/// this; pairing hosts have no local secret to activate. +#[async_trait::async_trait] +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>; +} + +#[async_trait::async_trait] +impl LocalActivation for SigningHost { + async fn activate_local_session(&self, secret: Vec) -> Result<(), AuthorityError> { + let secret = Zeroizing::new(secret); + let root = derive_root_keypair_from_entropy(&secret).map_err(product_authority_error)?; + let public_key = root.public.to_bytes(); + *self + .root_entropy + .lock() + .expect("signing host entropy mutex poisoned") = Some(secret); + let session = SessionInfo { + public_key, + sso: None, + root_entropy_source: None, + identity_account_id: None, + lite_username: None, + full_username: None, + }; + self.session_state.set_session(session.clone()); + self.auth_state + .connected(&connected_session_ui_info(&session)); + Ok(()) + } +} diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index c25c2e9a..8b73b709 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -11,13 +11,15 @@ use std::time::Duration; use web_time::Duration; use super::auth_state::AuthStateMachine; -use super::statement_store_rpc::{self, StatementStoreRpc}; -use super::{PlatformRuntimeHost, connected_session_ui_info}; +use super::identity::resolve_session_identity_with_chain; +use super::pairing_host::PairingHost; +use super::statement_store_rpc; use crate::host_logic::session::{SessionInfo, encode_persisted_session}; use crate::host_logic::sso::pairing::{ - EncryptedHandshakeResponseV2, PairingBootstrap, PairingDeviceIdentity, - VersionedHandshakeResponse, create_pairing_bootstrap_from_identity, decode_app_handshake_data, + PairingBootstrap, PairingDeviceIdentity, VersionedHandshakeResponse, + create_pairing_bootstrap_from_identity, decode_app_handshake_data, decrypt_v2_handshake_response, establish_sso_session_info, generate_pairing_device_identity, + v2, }; use crate::host_logic::statement_store::{ decode_verified_statement_data, parse_new_statements_result, @@ -33,7 +35,9 @@ use subxt_rpcs::client::RpcSubscription; use tracing::{debug, info, instrument}; use truapi::CallError; use truapi::v01; -use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; +use truapi::versioned::account::HostRequestLoginError; +#[cfg(test)] +use truapi::versioned::account::HostRequestLoginResponse; use truapi_platform::{CoreStorage, CoreStorageKey}; #[cfg(not(test))] @@ -45,8 +49,8 @@ const PAIRING_QUERY_TIMEOUT_TICKS: u8 = 15; #[cfg(test)] const PAIRING_QUERY_TIMEOUT_TICKS: u8 = 10; -/// Terminal outcome of [`PlatformRuntimeHost::run_pairing_flow`]. -enum PairingFlowOutcome { +/// Terminal outcome of [`SsoPairingFlow::request_session`]. +pub(super) enum SsoPairingOutcome { /// The login was cancelled (host `cancel_login`, `disconnect`, or a /// cross-tab session win). Cancelled, @@ -60,40 +64,50 @@ enum PairingFlowOutcome { struct AbandonedPairingGuard { auth_state: AuthStateMachine, epoch: u64, + active: bool, +} + +impl AbandonedPairingGuard { + fn disarm(&mut self) { + self.active = false; + } } impl Drop for AbandonedPairingGuard { fn drop(&mut self) { - self.auth_state.reset_abandoned_pairing(self.epoch); + if self.active { + self.auth_state.reset_abandoned_pairing(self.epoch); + } } } -impl PlatformRuntimeHost { - /// `request_login` pairing flow: emits `AuthState::Pairing` for the host +pub(super) struct SsoPairingFlow<'a> { + host: &'a PairingHost, +} + +impl<'a> SsoPairingFlow<'a> { + pub(super) fn new(host: &'a PairingHost) -> Self { + Self { host } + } + + /// `request_session` pairing flow: emits `AuthState::Pairing` for the host /// to present, then races host cancellation against the wallet handshake - /// arriving on the statement store; on success resolves identity and - /// persists the new session. - pub(super) async fn request_login_flow( + /// arriving on the statement store; on success it resolves identity, + /// persists the new session, and returns it to the pairing host. + pub(super) async fn request_session( &self, - ) -> Result> { - if let Some(session) = self.session_state.current() { - debug!("request_login: already connected, returning early"); - self.auth_state - .connected(&connected_session_ui_info(&session)); - return Ok(HostRequestLoginResponse::V1( - v01::HostRequestLoginResponse::AlreadyConnected, - )); - } - - let pairing_identity = create_fresh_pairing_device_identity(self.platform.as_ref()) + ) -> Result> { + let pairing_identity = create_fresh_pairing_device_identity(self.host.platform.as_ref()) .await .map_err(|reason| self.fail_before_pairing(reason))?; let bootstrap = - create_pairing_bootstrap_from_identity(&self.runtime_config, pairing_identity) + create_pairing_bootstrap_from_identity(&self.host.host_config, pairing_identity) .map_err(|err| self.fail_before_pairing(err.to_string()))?; - let Some((cancel_rx, pairing_epoch)) = - self.auth_state.pairing_started(bootstrap.deeplink.clone()) + let Some((cancel_rx, pairing_epoch)) = self + .host + .auth_state + .pairing_started(bootstrap.deeplink.clone()) else { return Err(CallError::Domain(HostRequestLoginError::V1( v01::HostRequestLoginError::Unknown { @@ -102,42 +116,23 @@ impl PlatformRuntimeHost { ))); }; info!("presenting pairing QR, waiting for wallet handshake"); - let _reset_guard = AbandonedPairingGuard { - auth_state: self.auth_state.clone(), + let mut reset_guard = AbandonedPairingGuard { + auth_state: self.host.auth_state.clone(), epoch: pairing_epoch, + active: true, }; match self.run_pairing_flow(&bootstrap, cancel_rx).await { - Ok(PairingFlowOutcome::Cancelled) => { - // `cancel_login` (or the cross-tab `connected` transition) - // already moved the auth state; only the call result is left - // to map. A session appearing mid-flow means another runtime - // won the login. - if self.session_state.current().is_some() { - info!("login cancelled by a cross-runtime session win"); - Ok(HostRequestLoginResponse::V1( - v01::HostRequestLoginResponse::AlreadyConnected, - )) - } else { - info!("login cancelled before handshake, login rejected"); - Ok(HostRequestLoginResponse::V1( - v01::HostRequestLoginResponse::Rejected, - )) - } + Ok(outcome @ SsoPairingOutcome::Cancelled) => { + reset_guard.disarm(); + Ok(outcome) } - Ok(PairingFlowOutcome::Success(session)) => { - let session = *session; - self.auth_state - .connected(&connected_session_ui_info(&session)); - self.session_state.set_session(session.clone()); - self.start_sso_disconnect_monitor(&session); - info!("login succeeded, SSO session established"); - Ok(HostRequestLoginResponse::V1( - v01::HostRequestLoginResponse::Success, - )) + Ok(outcome @ SsoPairingOutcome::Success(_)) => { + reset_guard.disarm(); + Ok(outcome) } Err(reason) => { - self.auth_state.login_failed(reason.clone()); + self.host.auth_state.login_failed(reason.clone()); Err(CallError::HostFailure { reason }) } } @@ -146,7 +141,9 @@ impl PlatformRuntimeHost { /// Emit `LoginFailed` for an error raised before the pairing was entered /// and map it onto the `request_login` error shape. fn fail_before_pairing(&self, reason: String) -> CallError { - self.auth_state.login_failed_before_pairing(reason.clone()); + self.host + .auth_state + .login_failed_before_pairing(reason.clone()); CallError::Domain(HostRequestLoginError::V1( v01::HostRequestLoginError::Unknown { reason }, )) @@ -154,23 +151,19 @@ impl PlatformRuntimeHost { /// Everything between the `Pairing` emission and a terminal outcome. /// Every error returned here maps to `AuthState::LoginFailed` at the - /// single exit in [`Self::request_login_flow`]. + /// single exit in [`Self::request_login`]. async fn run_pairing_flow( &self, bootstrap: &PairingBootstrap, cancel_rx: oneshot::Receiver<()>, - ) -> Result { + ) -> Result { let mut cancel = cancel_rx.fuse(); - let statement_store = StatementStoreRpc::new( - self.platform.clone(), - self.runtime_config.people_chain_genesis_hash, - self.spawner.clone(), - ); + let statement_store = self.host.statement_store.clone(); let statement_store_connect = statement_store.client("pairing statement-store").fuse(); pin_mut!(statement_store_connect); let rpc_client = futures::select! { - _ = cancel => return Ok(PairingFlowOutcome::Cancelled), + _ = cancel => return Ok(SsoPairingOutcome::Cancelled), connect_result = statement_store_connect => connect_result?, }; let subscribe_client = rpc_client.clone(); @@ -179,7 +172,7 @@ impl PlatformRuntimeHost { statement_store_rpc::subscribe_match_all(&subscribe_client, &live_topics).fuse(); pin_mut!(live_subscription); let live_subscription = futures::select! { - _ = cancel => return Ok(PairingFlowOutcome::Cancelled), + _ = cancel => return Ok(SsoPairingOutcome::Cancelled), subscribe_result = live_subscription => subscribe_result .map_err(|err| format!("pairing statement-store subscribe failed: {err}"))?, }; @@ -189,13 +182,13 @@ impl PlatformRuntimeHost { live_subscription, bootstrap.topic, bootstrap.encryption_secret_key, - self.spawner.clone(), + self.host.spawner.clone(), ) .fuse(); pin_mut!(pairing_response); let response = futures::select! { - _ = cancel => return Ok(PairingFlowOutcome::Cancelled), + _ = cancel => return Ok(SsoPairingOutcome::Cancelled), response_result = pairing_response => response_result?, }; let sso = establish_sso_session_info( @@ -211,15 +204,32 @@ impl PlatformRuntimeHost { lite_username: None, full_username: None, }; - let session = self.resolve_session_identity(session).await; - self.platform + let resolve_session = resolve_session_identity_with_chain( + &self.host.chain, + self.host.host_config.people_chain_genesis_hash, + session, + ) + .fuse(); + pin_mut!(resolve_session); + let session = futures::select! { + _ = cancel => return Ok(SsoPairingOutcome::Cancelled), + session = resolve_session => session, + }; + let persist_session = self + .host + .platform .write_core_storage( CoreStorageKey::AuthSession, encode_persisted_session(&session), ) - .await - .map_err(|err| format!("session persist failed: {err:?}"))?; - Ok(PairingFlowOutcome::Success(Box::new(session))) + .fuse(); + pin_mut!(persist_session); + futures::select! { + _ = cancel => return Ok(SsoPairingOutcome::Cancelled), + persist_result = persist_session => persist_result + .map_err(|err| format!("session persist failed: {err:?}"))?, + }; + Ok(SsoPairingOutcome::Success(Box::new(session))) } } @@ -238,7 +248,34 @@ async fn create_fresh_pairing_device_identity( struct PairingSuccess { peer_statement_account_id: [u8; 32], - success: crate::host_logic::sso::pairing::HandshakeSuccessV2, + success: v2::Success, +} + +impl PairingSuccess { + #[instrument(skip_all, fields(runtime.method = "sso.pairing.decode_statement"))] + fn from_v2_statement( + statement: &[u8], + core_encryption_secret_key: [u8; 32], + ) -> Result, String> { + let verified = + decode_verified_statement_data(statement, None).map_err(|err| err.to_string())?; + let VersionedHandshakeResponse::V2 { + encrypted_message, + public_key, + } = decode_app_handshake_data(&verified.data)?; + match decrypt_v2_handshake_response( + core_encryption_secret_key, + public_key, + &encrypted_message, + )? { + v2::EncryptedResponse::Pending(_) => Ok(None), + v2::EncryptedResponse::Failed(reason) => Err(reason), + v2::EncryptedResponse::Success(success) => Ok(Some(Self { + peer_statement_account_id: verified.signer, + success: *success, + })), + } + } } #[instrument(skip_all, fields(runtime.method = "sso.pairing.wait_success"))] @@ -337,7 +374,8 @@ fn handle_v2_pairing_result( let page = parse_new_statements_result("pairing".to_string(), value).map_err(|err| err.to_string())?; for statement in page.statements { - if let Some(success) = decode_v2_pairing_statement(&statement, core_encryption_secret_key)? + if let Some(success) = + PairingSuccess::from_v2_statement(&statement, core_encryption_secret_key)? { return Ok(Some(success)); } @@ -346,30 +384,10 @@ fn handle_v2_pairing_result( Ok(None) } -#[instrument(skip_all, fields(runtime.method = "sso.pairing.decode_statement"))] -fn decode_v2_pairing_statement( - statement: &[u8], - core_encryption_secret_key: [u8; 32], -) -> Result, String> { - let verified = - decode_verified_statement_data(statement, None).map_err(|err| err.to_string())?; - let VersionedHandshakeResponse::V2 { - encrypted_message, - public_key, - } = decode_app_handshake_data(&verified.data)?; - match decrypt_v2_handshake_response(core_encryption_secret_key, public_key, &encrypted_message)? - { - EncryptedHandshakeResponseV2::Pending(_) => Ok(None), - EncryptedHandshakeResponseV2::Failed(reason) => Err(reason), - EncryptedHandshakeResponseV2::Success(success) => Ok(Some(PairingSuccess { - peer_statement_account_id: verified.signer, - success: *success, - })), - } -} - #[cfg(test)] mod tests { + use super::super::connected_session_ui_info; + use super::super::{PairingHostRole, ProductRuntimeHost}; use super::*; use crate::test_support::{ StubPlatform, core_storage_test_key, pairing_device_from_deeplink, peer_statement_keypair, @@ -385,14 +403,13 @@ mod tests { /// Cancel the login as soon as the host observes the `Pairing` state, /// mimicking a user dismissing the pairing UI immediately. - fn cancel_on_pairing(platform: &StubPlatform, host: &Arc) { - let host = host.clone(); + fn cancel_on_pairing(platform: &StubPlatform, pairing_host: Arc) { *platform .on_auth_state .lock() .expect("auth state hook mutex poisoned") = Some(Arc::new(move |state| { if matches!(state, AuthState::Pairing { .. }) { - host.cancel_login(); + pairing_host.cancel_login(); } })); } @@ -400,11 +417,10 @@ mod tests { #[test] fn request_login_presents_pairing_and_rejects_when_cancelled() { let platform = stub_platform(); - let host = Arc::new(PlatformRuntimeHost::new_compat( - platform.clone(), - test_spawner(), - )); - cancel_on_pairing(&platform, &host); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); let cx = CallContext::new(); let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); @@ -440,11 +456,10 @@ mod tests { #[test] fn request_login_rotates_pairing_device_identity_between_attempts() { let platform = stub_platform(); - let host = Arc::new(PlatformRuntimeHost::new_compat( - platform.clone(), - test_spawner(), - )); - cancel_on_pairing(&platform, &host); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); let cx = CallContext::new(); let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); @@ -491,7 +506,7 @@ mod tests { let wallet_ephemeral_public = wallet_ephemeral_secret.public_key().to_encoded_point(false); let mut wallet_ephemeral_public_bytes = [0u8; 65]; wallet_ephemeral_public_bytes.copy_from_slice(wallet_ephemeral_public.as_bytes()); - let handshake = crate::host_logic::sso::pairing::VersionedHandshakeResponse::V2 { + let handshake = VersionedHandshakeResponse::V2 { encrypted_message: vec![0xde, 0xad], public_key: wallet_ephemeral_public_bytes, }; @@ -507,7 +522,7 @@ mod tests { ], ..Default::default() }); - let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); let cx = CallContext::new(); let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); let err = futures::executor::block_on(host.request_login(&cx, request)).unwrap_err(); @@ -546,12 +561,12 @@ mod tests { session_writes: session_writes.clone(), ..Default::default() }); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("myapp.dot"), test_spawner(), ); - let mut statuses = host.session_state().subscribe(); + let mut statuses = host.test_session_state().subscribe(); assert_eq!( futures::executor::block_on(statuses.next()).unwrap(), HostAccountConnectionStatusSubscribeItem::V1( @@ -575,7 +590,7 @@ mod tests { ); let session = host - .session_state() + .test_session_state() .current() .expect("paired session should be active"); assert_eq!(session.public_key, session_info().public_key); @@ -626,6 +641,45 @@ mod tests { ); } + #[test] + fn request_login_connected_callback_can_clear_session_without_reinstalling_it() { + let platform = Arc::new(StubPlatform { + pairing_success_response: true, + ..Default::default() + }); + let host = Arc::new(ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + )); + let disconnect_host = host.clone(); + *platform + .on_auth_state + .lock() + .expect("auth state hook mutex poisoned") = Some(Arc::new(move |state| { + if matches!(state, AuthState::Connected(_)) { + disconnect_host.test_session_state().clear_session(); + } + })); + + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Success) + ); + assert!(host.test_session_state().current().is_none()); + + let auth_states = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"); + assert!(matches!(&auth_states[0], AuthState::Pairing { .. })); + assert!(matches!(&auth_states[1], AuthState::Connected(_))); + } + /// The pairing success must also be reachable through the core's own 2s /// snapshot queries: the live subscription stays silent and the wallet /// statement is delivered only on a query subscription page. @@ -635,7 +689,7 @@ mod tests { pairing_success_via_query: true, ..Default::default() }); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("myapp.dot"), test_spawner(), @@ -649,7 +703,7 @@ mod tests { HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Success) ); assert_eq!( - host.session_state() + host.test_session_state() .current() .map(|session| session.public_key), Some(session_info().public_key) @@ -685,7 +739,7 @@ mod tests { local_storage_error: Some("identity storage unavailable"), ..Default::default() }); - let host = PlatformRuntimeHost::new_compat(platform.clone(), test_spawner()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); let cx = CallContext::new(); let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); let err = futures::executor::block_on(host.request_login(&cx, request)).unwrap_err(); @@ -709,11 +763,10 @@ mod tests { )), ..Default::default() }); - let host = Arc::new(PlatformRuntimeHost::new_compat( - platform.clone(), - test_spawner(), - )); - cancel_on_pairing(&platform, &host); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); let cx = CallContext::new(); let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); @@ -722,7 +775,7 @@ mod tests { response, HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) ); - assert!(host.session_state().current().is_none()); + assert!(host.test_session_state().current().is_none()); } #[test] @@ -733,11 +786,10 @@ mod tests { session_clears: session_clears.clone(), ..Default::default() }); - let host = Arc::new(PlatformRuntimeHost::new_compat( - platform.clone(), - test_spawner(), - )); - cancel_on_pairing(&platform, &host); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); let cx = CallContext::new(); let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); @@ -746,7 +798,7 @@ mod tests { response, HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) ); - assert!(host.session_state().current().is_none()); + assert!(host.test_session_state().current().is_none()); assert_eq!(*session_clears.lock().unwrap(), 0); } @@ -756,11 +808,10 @@ mod tests { session_error: Some("storage failed"), ..Default::default() }); - let host = Arc::new(PlatformRuntimeHost::new_compat( - platform.clone(), - test_spawner(), - )); - cancel_on_pairing(&platform, &host); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); let cx = CallContext::new(); let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); @@ -769,13 +820,13 @@ mod tests { response, HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) ); - assert!(host.session_state().current().is_none()); + assert!(host.test_session_state().current().is_none()); } #[test] fn request_login_returns_already_connected_when_session_exists() { - let host = PlatformRuntimeHost::new_compat(stub_platform(), test_spawner()); - host.session_state().set_session(session_info()); + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); diff --git a/rust/crates/truapi-server/src/runtime/sso_remote.rs b/rust/crates/truapi-server/src/runtime/sso_remote.rs index 68dc3d68..ec728d2d 100644 --- a/rust/crates/truapi-server/src/runtime/sso_remote.rs +++ b/rust/crates/truapi-server/src/runtime/sso_remote.rs @@ -1,8 +1,9 @@ //! SSO remote messaging over the people-chain statement store: submits an -//! encrypted request statement to the paired wallet and waits for the +//! encrypted request statement to the paired signing host and waits for the //! matching response, honoring timeouts and local/peer disconnect signals. use core::mem; +use std::fmt::Display; use std::sync::Mutex; #[cfg(not(target_arch = "wasm32"))] @@ -10,12 +11,10 @@ use std::time::Duration; #[cfg(target_arch = "wasm32")] use web_time::Duration; -use super::PlatformRuntimeHost; use super::statement_store_rpc; -use crate::host_logic::session::{SessionInfo, SsoSessionInfo}; +use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::messages::{ - RemoteMessage, RemoteMessageData, RemoteMessageV1, SsoRemoteResponse, SsoSessionStatement, - build_outgoing_request_statement, decode_sso_session_statement, + SsoRemoteResponse, SsoSessionStatement, decode_sso_session_statement, }; use crate::host_logic::statement_store::{current_unix_secs, parse_new_statements_result}; @@ -26,14 +25,13 @@ use futures::{FutureExt, StreamExt, pin_mut}; use serde_json::Value; use subxt_rpcs::RpcClient; use subxt_rpcs::client::RpcSubscription; -use tracing::{debug, instrument, warn}; +use tracing::instrument; use truapi::CallContext; const DEFAULT_SSO_STATEMENT_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60; -const DEFAULT_SSO_RESPONSE_TIMEOUT: Duration = Duration::from_secs(180); /// Disconnect reason reported when the local session logs out mid-request. pub(super) const SSO_LOCAL_DISCONNECT_REASON: &str = "SSO session disconnected"; -/// Disconnect reason reported when the paired wallet announces a disconnect. +/// Disconnect reason reported when the paired signing host announces a disconnect. pub(super) const SSO_PEER_DISCONNECT_REASON: &str = "SSO peer disconnected"; /// Registry of oneshot waiters resolved when the SSO session disconnects. @@ -45,12 +43,41 @@ pub(super) struct SessionDisconnects { #[derive(Default)] struct SessionDisconnectsInner { next_id: u64, - waiters: Vec<(u64, oneshot::Sender)>, + waiters: Vec<(u64, SsoSessionKey, oneshot::Sender)>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct SsoSessionKey { + own: [u8; 32], + peer: [u8; 32], +} + +impl SsoSessionKey { + pub(super) fn from_session(session: &SsoSessionInfo) -> Self { + Self { + own: session.session_id_own, + peer: session.session_id_peer, + } + } +} + +pub(super) struct SessionDisconnectGuard { + disconnects: std::sync::Arc, + id: u64, +} + +impl Drop for SessionDisconnectGuard { + fn drop(&mut self) { + self.disconnects.unsubscribe(self.id); + } } impl SessionDisconnects { /// Register a waiter; returns its id and the disconnect-reason receiver. - pub(super) fn subscribe(&self) -> (u64, oneshot::Receiver) { + pub(super) fn subscribe( + self: &std::sync::Arc, + session: &SsoSessionInfo, + ) -> (SessionDisconnectGuard, oneshot::Receiver) { let (tx, rx) = oneshot::channel(); let mut inner = self .inner @@ -58,8 +85,16 @@ impl SessionDisconnects { .expect("session disconnect mutex poisoned"); inner.next_id = inner.next_id.wrapping_add(1); let id = inner.next_id; - inner.waiters.push((id, tx)); - (id, rx) + inner + .waiters + .push((id, SsoSessionKey::from_session(session), tx)); + ( + SessionDisconnectGuard { + disconnects: self.clone(), + id, + }, + rx, + ) } fn unsubscribe(&self, id: u64) { @@ -67,162 +102,51 @@ impl SessionDisconnects { .lock() .expect("session disconnect mutex poisoned") .waiters - .retain(|(waiter_id, _)| *waiter_id != id); + .retain(|(waiter_id, _, _)| *waiter_id != id); } - /// Resolve every pending waiter with `reason`. - pub(super) fn notify(&self, reason: &'static str) { + /// Resolve pending waiters for one SSO session with `reason`. + pub(super) fn notify(&self, session: &SsoSessionInfo, reason: &'static str) { + self.notify_key(SsoSessionKey::from_session(session), reason); + } + + pub(super) fn notify_key(&self, key: SsoSessionKey, reason: &'static str) { let waiters = { let mut inner = self .inner .lock() .expect("session disconnect mutex poisoned"); - mem::take(&mut inner.waiters) + let mut matching = Vec::new(); + let mut pending = Vec::with_capacity(inner.waiters.len()); + for waiter in mem::take(&mut inner.waiters) { + if waiter.1 == key { + matching.push(waiter); + } else { + pending.push(waiter); + } + } + inner.waiters = pending; + matching }; - for (_, waiter) in waiters { + for (_, _, waiter) in waiters { let _ = waiter.send(reason.to_string()); } } } -impl PlatformRuntimeHost { - /// Best-effort `Disconnected` notification to the SSO peer. - #[instrument(skip_all, fields(runtime.method = "sso.disconnect.submit"))] - pub(super) async fn submit_sso_disconnected( - &self, - session: &SessionInfo, - ) -> Result<(), String> { - let sso = session - .sso - .as_ref() - .ok_or_else(|| "No SSO session state".to_string())?; - let message_id = "truapi:sso:disconnect".to_string(); - let message = RemoteMessage { - message_id: message_id.clone(), - data: RemoteMessageData::V1(RemoteMessageV1::Disconnected), - }; - let statement = build_outgoing_request_statement( - sso, - message_id.clone(), - vec![message], - fresh_statement_expiry(), - )?; - self.statement_store_rpc() - .submit_fire_and_forget(statement, "SSO statement-store") - .await - .map_err(|err| format!("SSO statement submit failed: {err}"))?; - Ok(()) - } - - /// Submit an SSO remote message and wait for the wallet response with - /// the default timeout. - #[instrument(skip_all, fields(runtime.method = "sso.remote_message.submit", action = action))] - pub(super) async fn submit_sso_remote_message( - &self, - cx: &CallContext, - session: &SessionInfo, - action: &str, - message: RemoteMessage, - ) -> Result { - self.submit_sso_remote_message_with_timeout( - cx, - session, - action, - message, - Some(DEFAULT_SSO_RESPONSE_TIMEOUT), - ) - .await - } - - /// Submit an SSO remote message and wait for the wallet response without - /// a deadline (used for flows that block on user interaction). - #[instrument(skip_all, fields(runtime.method = "sso.remote_message.submit_without_timeout", action = action))] - pub(super) async fn submit_sso_remote_message_without_timeout( - &self, - cx: &CallContext, - session: &SessionInfo, - action: &str, - message: RemoteMessage, - ) -> Result { - self.submit_sso_remote_message_with_timeout(cx, session, action, message, None) - .await - } - - #[instrument(skip_all, fields(runtime.method = "sso.remote_message.submit_with_timeout", action = action))] - async fn submit_sso_remote_message_with_timeout( - &self, - cx: &CallContext, - session: &SessionInfo, - action: &str, - message: RemoteMessage, - timeout: Option, - ) -> Result { - let sso = session - .sso - .as_ref() - .ok_or_else(|| "No SSO session state".to_string())?; - let message_id = sso_message_id(cx, action); - let statement = build_outgoing_request_statement( - sso, - message_id.clone(), - vec![message], - fresh_statement_expiry(), - )?; - let rpc_client = self - .statement_store_rpc() - .client("SSO statement-store") - .await?; - let own_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_own) - .await - .map_err(|err| format!("SSO own statement-store subscribe failed: {err}"))?; - let peer_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_peer) - .await - .map_err(|err| format!("SSO peer statement-store subscribe failed: {err}"))?; - let submit_client = rpc_client.clone(); - let submit = async move { statement_store_rpc::submit(&submit_client, statement).await } - .map(|result| result.map_err(|err| format!("SSO statement submit failed: {err}"))) - .boxed(); - debug!(action, %message_id, "submitted SSO remote message, awaiting response"); - let (disconnect_waiter_id, disconnect) = self.session_disconnects.subscribe(); - let result = wait_for_sso_remote_response( - statement_subscription_stream(own_subscription, "own"), - statement_subscription_stream(peer_subscription, "peer"), - submit, - SsoRemoteResponseWait { - session: sso, - statement_request_id: &message_id, - remote_message_id: &message_id, - timeout, - disconnect: Some(disconnect), - }, - ) - .await; - self.session_disconnects.unsubscribe(disconnect_waiter_id); - match &result { - Ok(_) => debug!(action, %message_id, "SSO remote response received"), - Err(reason) => warn!(action, %message_id, %reason, "SSO remote message failed"), - } - if matches!(&result, Err(reason) if reason == SSO_PEER_DISCONNECT_REASON) { - self.session_disconnects.notify(SSO_PEER_DISCONNECT_REASON); - self.clear_disconnected_session().await; - } - result - } -} - -struct SsoRemoteResponseWait<'a> { - session: &'a SsoSessionInfo, - statement_request_id: &'a str, - remote_message_id: &'a str, - timeout: Option, - disconnect: Option>, +pub(super) struct SsoRemoteResponseWait<'a> { + pub(super) session: &'a SsoSessionInfo, + pub(super) statement_request_id: &'a str, + pub(super) remote_message_id: &'a str, + pub(super) timeout: Option, + pub(super) disconnect: Option>, } -type StatementPageStream = BoxStream<'static, Result>; -type StatementSubmitFuture = BoxFuture<'static, Result<(), String>>; +pub(super) type StatementPageStream = BoxStream<'static, Result>; +pub(super) type StatementSubmitFuture = BoxFuture<'static, Result<(), String>>; #[instrument(skip_all, fields(runtime.method = "sso.remote_response.wait"))] -async fn wait_for_sso_remote_response( +pub(super) async fn wait_for_sso_remote_response( own_statements: StatementPageStream, peer_statements: StatementPageStream, submit: StatementSubmitFuture, @@ -377,14 +301,14 @@ fn handle_sso_remote_statement_page( Ok(None) } -async fn subscribe_statement_topic( +pub(super) async fn subscribe_statement_topic( rpc_client: &RpcClient, topic: [u8; 32], ) -> Result, subxt_rpcs::Error> { statement_store_rpc::subscribe_match_all(rpc_client, &[topic]).await } -fn statement_subscription_stream( +pub(super) fn statement_subscription_stream( subscription: RpcSubscription, label: &'static str, ) -> StatementPageStream { @@ -403,7 +327,7 @@ fn format_timeout_duration(duration: Duration) -> String { /// Stable message id for an SSO request: the wire request id when present, /// otherwise a fixed per-action fallback. -pub(super) fn sso_message_id(cx: &CallContext, action: &str) -> String { +pub(super) fn sso_message_id(cx: &CallContext, action: impl Display) -> String { if cx.request_id().is_empty() { format!("truapi:sso:{action}") } else { @@ -411,7 +335,7 @@ pub(super) fn sso_message_id(cx: &CallContext, action: &str) -> String { } } -fn fresh_statement_expiry() -> u64 { +pub(super) fn fresh_statement_expiry() -> u64 { let timestamp = current_unix_secs().saturating_add(DEFAULT_SSO_STATEMENT_EXPIRY_SECS); timestamp << 32 } diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index c4833559..079c8c82 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -4,7 +4,7 @@ use core::pin::Pin; use core::task::{Context, Poll}; -use super::PlatformRuntimeHost; +use super::ProductRuntimeHost; use super::statement_store_rpc::{self, StatementStoreRpc}; use crate::host_logic::statement_store::{ MAX_MATCH_ALL_TOPICS, MAX_MATCH_ANY_TOPICS, TopicFilterKind, decode_signed_statement, @@ -28,7 +28,7 @@ use truapi::versioned::statement_store::{ }; use truapi::{CallContext, CallError, Subscription}; -impl StatementStore for PlatformRuntimeHost { +impl StatementStore for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "statement_store.subscribe"))] async fn subscribe( &self, @@ -84,7 +84,12 @@ impl StatementStore for PlatformRuntimeHost { CallError, > { let RemoteStatementStoreCreateProofRequest::V1(mut inner) = request; - inner.product_account_id = Self::normalize_product_account_id(inner.product_account_id); + inner.product_account_id = Self::normalize_product_account_id(inner.product_account_id) + .map_err(|()| { + CallError::Domain(RemoteStatementStoreCreateProofError::V1( + v01::RemoteStatementStoreCreateProofError::UnknownAccount, + )) + })?; if !self.is_product_account_valid_for_caller(&inner.product_account_id.dot_ns_identifier) { return Err(CallError::Domain(RemoteStatementStoreCreateProofError::V1( v01::RemoteStatementStoreCreateProofError::UnknownAccount, @@ -159,10 +164,9 @@ fn statement_store_topic_filter( v01::RemoteStatementStoreSubscribeRequest::MatchAny(topics), ) => { if topics.len() > MAX_MATCH_ANY_TOPICS { + let topic_count = topics.len(); return Err(format!( - "MatchAny has {} topics, maximum is {}", - topics.len(), - MAX_MATCH_ANY_TOPICS + "MatchAny has {topic_count} topics, maximum is {MAX_MATCH_ANY_TOPICS}" )); } Ok((TopicFilterKind::MatchAny, topics)) @@ -237,14 +241,10 @@ impl futures::Stream for StatementStoreSubscriptionStream { } } -impl PlatformRuntimeHost { +impl ProductRuntimeHost { /// `StatementStoreRpc` bound to this runtime's people chain. pub(super) fn statement_store_rpc(&self) -> StatementStoreRpc { - StatementStoreRpc::new( - self.platform.clone(), - self.runtime_config.people_chain_genesis_hash, - self.spawner.clone(), - ) + self.services.statement_store.clone() } fn create_statement_proof( @@ -252,7 +252,8 @@ impl PlatformRuntimeHost { statement: v01::Statement, ) -> Result { let session = self - .session_state + .authority + .session_state() .current() .ok_or(StatementProofFailure::NoSession)?; let sso = session @@ -325,10 +326,10 @@ mod tests { #[test] fn statement_store_create_proof_signs_with_session_key() { let host = - PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); let session = sso_session_info(); let expected_signer = session.sso.as_ref().unwrap().ss_public_key; - host.session_state().set_session(session); + host.test_session_state().set_session(session); let cx = CallContext::new(); let request = RemoteStatementStoreCreateProofRequest::V1( v01::RemoteStatementStoreCreateProofRequest { @@ -351,8 +352,8 @@ mod tests { #[test] fn statement_store_create_proof_rejects_wrong_product_account() { let host = - PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); - host.session_state().set_session(sso_session_info()); + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(sso_session_info()); let cx = CallContext::new(); let request = RemoteStatementStoreCreateProofRequest::V1( v01::RemoteStatementStoreCreateProofRequest { @@ -375,10 +376,10 @@ mod tests { #[test] fn statement_store_create_proof_authorized_signs_with_session_key() { let host = - PlatformRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); let session = sso_session_info(); let expected_signer = session.sso.as_ref().unwrap().ss_public_key; - host.session_state().set_session(session); + host.test_session_state().set_session(session); let cx = CallContext::new(); let request = RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement()); @@ -400,7 +401,7 @@ mod tests { rpc_responses: vec![r#"{"jsonrpc":"2.0","id":"truapi:1","result":"0xok"}"#.to_string()], ..Default::default() }); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("myapp.dot"), test_spawner(), @@ -440,7 +441,7 @@ mod tests { ], ..Default::default() }); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("myapp.dot"), test_spawner(), @@ -489,7 +490,7 @@ mod tests { ], ..Default::default() }); - let host = PlatformRuntimeHost::new(platform, runtime_config("myapp.dot"), test_spawner()); + let host = ProductRuntimeHost::new(platform, runtime_config("myapp.dot"), test_spawner()); let cx = CallContext::with_request_id("sub-pre".to_string()); let mut subscription = futures::executor::block_on(StatementStore::subscribe( &host, @@ -524,7 +525,7 @@ mod tests { ], ..Default::default() }); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("myapp.dot"), test_spawner(), @@ -562,7 +563,7 @@ mod tests { ], ..Default::default() }); - let host = PlatformRuntimeHost::new(platform, runtime_config("myapp.dot"), test_spawner()); + let host = ProductRuntimeHost::new(platform, runtime_config("myapp.dot"), test_spawner()); let cx = CallContext::with_request_id("sub-empty-complete".to_string()); let mut subscription = futures::executor::block_on(StatementStore::subscribe( &host, @@ -583,7 +584,7 @@ mod tests { #[test] fn statement_store_subscribe_rejects_topic_limit_violations() { let platform = stub_platform(); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("myapp.dot"), test_spawner(), @@ -622,7 +623,7 @@ mod tests { chain_connect_error: Some("chain unavailable"), ..Default::default() }); - let host = PlatformRuntimeHost::new( + let host = ProductRuntimeHost::new( platform.clone(), runtime_config("myapp.dot"), test_spawner(), 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 268c8129..bec77132 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs @@ -16,7 +16,7 @@ use crate::subscription::Spawner; /// People-chain statement-store RPC client factory. #[derive(Clone)] -pub(super) struct StatementStoreRpc { +pub(crate) struct StatementStoreRpc { platform: Arc, people_chain_genesis_hash: [u8; 32], spawner: Spawner, @@ -64,7 +64,10 @@ impl StatementStoreRpc { ) -> Result<(), String> { let connection = self.connect(label).await?; HostRpcClient::new(connection, self.spawner.clone()) - .send_fire_and_forget(SUBMIT_STATEMENT_METHOD, statement_submit_params(statement)) + .send_fire_and_forget( + SUBMIT_STATEMENT_METHOD, + rpc_params![format!("0x{}", hex::encode(&statement))].build(), + ) .map_err(rpc_error_message) } @@ -105,7 +108,7 @@ pub(super) async fn submit(rpc_client: &RpcClient, statement: Vec) -> Result rpc_client .request::( SUBMIT_STATEMENT_METHOD, - rpc_params![statement_hex(&statement)], + rpc_params![format!("0x{}", hex::encode(&statement))], ) .await .map(|_| ()) @@ -129,11 +132,3 @@ pub(super) fn rpc_error_message(error: subxt_rpcs::Error) -> String { other => other.to_string(), } } - -fn statement_submit_params(statement: Vec) -> Option> { - rpc_params![statement_hex(&statement)].build() -} - -fn statement_hex(statement: &[u8]) -> String { - format!("0x{}", hex::encode(statement)) -} diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index d21ffa80..887e7fc6 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -9,6 +9,7 @@ use std::time::Duration; #[cfg(target_arch = "wasm32")] use web_time::Duration; +use crate::host_logic::sso::pairing; use crate::subscription::Spawner; #[cfg(not(target_arch = "wasm32"))] use crate::subscription::thread_per_subscription_spawner; @@ -30,9 +31,9 @@ use truapi::versioned::resource_allocation::HostRequestResourceAllocationRequest use truapi_platform::{ AuthPresenter, AuthState, ChainProvider, CoreStorage as PlatformCoreStorage, CoreStorageKey, Features as PlatformFeatures, HostInfo, JsonRpcConnection, Navigation as PlatformNavigation, - Notifications as PlatformNotifications, Permissions as PlatformPermissions, PlatformInfo, - PreimageHost, ProductStorage as PlatformProductStorage, RuntimeConfig, ThemeHost, - UserConfirmation, UserConfirmationReview, + Notifications as PlatformNotifications, PairingHostConfig, Permissions as PlatformPermissions, + PlatformInfo, PreimageHost, ProductContext, ProductStorage as PlatformProductStorage, + ThemeHost, UserConfirmation, UserConfirmationReview, }; /// Test spawner that matches the current target. @@ -47,20 +48,21 @@ pub(crate) fn test_spawner() -> Spawner { } } -#[allow(dead_code)] /// Synchronous spawner for tests that should complete work immediately. +#[cfg(target_arch = "wasm32")] pub(crate) fn immediate_spawner() -> Spawner { Arc::new(futures::executor::block_on) } /// Test hook invoked after each recorded auth state. -pub(crate) type AuthStateHook = Arc; +pub type AuthStateHook = Arc; /// Minimal Platform impl that only answers `feature_supported`. Every /// other callback returns a unit value or empty stream, so the runtime /// can exercise its delegation paths without pulling in a real backend. +#[derive(Default)] pub(crate) struct StubPlatform { - pub(crate) remote_permission_granted: bool, + pub(crate) remote_permission_denied: bool, pub(crate) account_alias_confirmed: bool, pub(crate) account_alias_error: Option<&'static str>, pub(crate) sign_payload_confirmed: bool, @@ -99,42 +101,6 @@ pub(crate) struct StubPlatform { pub(crate) local_storage_error: Option<&'static str>, } -impl Default for StubPlatform { - fn default() -> Self { - Self { - remote_permission_granted: true, - account_alias_confirmed: false, - account_alias_error: None, - sign_payload_confirmed: false, - sign_payload_error: None, - sign_raw_confirmed: false, - sign_raw_error: None, - create_transaction_confirmed: false, - create_transaction_error: None, - resource_allocation_confirmed: false, - resource_allocation_error: None, - session_blob: None, - session_error: None, - session_clears: Arc::new(Mutex::new(0)), - session_writes: Arc::new(Mutex::new(Vec::new())), - auth_states: Arc::new(Mutex::new(Vec::new())), - on_auth_state: Arc::new(Mutex::new(None)), - pending_connect_dropped: Arc::new(AtomicBool::new(false)), - pairing_success_response: false, - pairing_success_via_query: false, - notification_id: 0, - pushed_notifications: Arc::new(Mutex::new(Vec::new())), - cancelled_notifications: Arc::new(Mutex::new(Vec::new())), - sent_rpc: Arc::new(Mutex::new(Vec::new())), - rpc_responses: Vec::new(), - chain_connect_error: None, - chain_connect_pending: false, - local_storage: Arc::new(Mutex::new(std::collections::HashMap::new())), - local_storage_error: None, - } - } -} - struct DropFlagGuard(Arc); impl Drop for DropFlagGuard { @@ -161,19 +127,21 @@ pub(crate) fn stub_platform() -> Arc { } /// Runtime configuration used by platform-backed runtime tests. -pub(crate) fn runtime_config(product_id: &str) -> RuntimeConfig { - RuntimeConfig::new( - product_id.to_string(), - HostInfo { - name: "Polkadot Web".to_string(), - icon: Some("https://example.invalid/dotli.png".to_string()), - version: None, - }, - PlatformInfo::default(), - [0; 32], - "polkadotapp".to_string(), +pub(crate) fn runtime_config(product_id: &str) -> (PairingHostConfig, ProductContext) { + ( + PairingHostConfig::new( + HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://example.invalid/dotli.png".to_string()), + version: None, + }, + PlatformInfo::default(), + [0; 32], + "polkadotapp".to_string(), + ) + .expect("test host runtime config is valid"), + ProductContext::new(product_id.to_string()).expect("test product context is valid"), ) - .expect("test runtime config is valid") } /// Basic connected session fixture without SSO channel material. @@ -261,12 +229,9 @@ pub(crate) fn submitted_remote_message( let statement = hex::decode(statement_hex.strip_prefix("0x").unwrap_or(statement_hex)).unwrap(); let encrypted = crate::host_logic::statement_store::decode_statement_data(&statement) .expect("statement data should decode"); - let data = crate::host_logic::sso::pairing::decrypt_session_statement_data( - session.sso.as_ref().unwrap(), - &encrypted, - ) - .expect("statement data should decrypt"); - let crate::host_logic::sso::pairing::SsoStatementData::Request { data, .. } = data else { + let data = pairing::decrypt_session_statement_data(session.sso.as_ref().unwrap(), &encrypted) + .expect("statement data should decrypt"); + let pairing::SsoStatementData::Request { data, .. } = data else { panic!("expected request statement data"); }; crate::host_logic::sso::messages::RemoteMessage::decode(&mut data[0].as_slice()) @@ -309,7 +274,7 @@ pub(crate) fn sso_success_responses( &own_subscription_id, vec![sso_statement( session, - crate::host_logic::sso::pairing::SsoStatementData::Response { + pairing::SsoStatementData::Response { request_id: message_id.to_string(), response_code: 0, }, @@ -320,7 +285,7 @@ pub(crate) fn sso_success_responses( &peer_subscription_id, vec![sso_statement( session, - crate::host_logic::sso::pairing::SsoStatementData::Request { + pairing::SsoStatementData::Request { request_id: format!("wallet-response-{message_id}"), data: vec![response.encode()], }, @@ -345,7 +310,7 @@ pub(crate) fn sso_peer_disconnect_responses( &own_subscription_id, vec![sso_statement( session, - crate::host_logic::sso::pairing::SsoStatementData::Response { + pairing::SsoStatementData::Response { request_id: message_id.to_string(), response_code: 0, }, @@ -356,7 +321,7 @@ pub(crate) fn sso_peer_disconnect_responses( &peer_subscription_id, vec![sso_statement( session, - crate::host_logic::sso::pairing::SsoStatementData::Request { + pairing::SsoStatementData::Request { request_id: format!("wallet-disconnect-{message_id}"), data: vec![ crate::host_logic::sso::messages::RemoteMessage { @@ -385,7 +350,7 @@ pub(crate) fn sso_peer_disconnect_monitor_responses( subscription_id, vec![sso_statement( session, - crate::host_logic::sso::pairing::SsoStatementData::Request { + pairing::SsoStatementData::Request { request_id: "wallet-disconnect-monitor".to_string(), data: vec![ crate::host_logic::sso::messages::RemoteMessage { @@ -447,12 +412,12 @@ pub(crate) fn new_statements_frame(subscription_id: &str, statements: Vec Vec { - let mut nonce = [0; crate::host_logic::sso::pairing::AES_GCM_NONCE_LEN]; + let mut nonce = [0; pairing::AES_GCM_NONCE_LEN]; nonce[0] = nonce_seed; - let encrypted = crate::host_logic::sso::pairing::encrypt_session_statement_data_with_nonce( + let encrypted = pairing::encrypt_session_statement_data_with_nonce( session.sso.as_ref().unwrap(), &data, nonce, @@ -472,11 +437,9 @@ pub(crate) fn pairing_device_from_deeplink(deeplink: &str) -> ([u8; 32], [u8; 65 .nth(1) .expect("pairing deeplink should include handshake"); let handshake = hex::decode(encoded).expect("handshake should be hex"); - let decoded = crate::host_logic::sso::pairing::VersionedHandshakeProposal::decode( - &mut handshake.as_slice(), - ) - .expect("handshake should decode"); - let crate::host_logic::sso::pairing::VersionedHandshakeProposal::V2(proposal) = decoded; + let decoded = pairing::VersionedHandshakeProposal::decode(&mut handshake.as_slice()) + .expect("handshake should decode"); + let pairing::VersionedHandshakeProposal::V2(proposal) = decoded; ( proposal.device.statement_account_id, proposal.device.encryption_public_key, @@ -498,16 +461,14 @@ fn wallet_handshake_statement(deeplink: &str) -> Vec { .as_bytes() .try_into() .unwrap(); - let answer = crate::host_logic::sso::pairing::EncryptedHandshakeResponseV2::Success(Box::new( - crate::host_logic::sso::pairing::HandshakeSuccessV2 { - identity_account_id: peer_statement_keypair().1, - root_account_id: session_info().public_key, - identity_chat_private_key: [0x77; 32], - sso_enc_pub_key: wallet_persistent_public, - device_enc_pub_key: wallet_persistent_public, - root_entropy_source: [0x66; 32], - }, - )); + let answer = pairing::v2::EncryptedResponse::Success(Box::new(pairing::v2::Success { + identity_account_id: peer_statement_keypair().1, + root_account_id: session_info().public_key, + identity_chat_private_key: [0x77; 32], + sso_enc_pub_key: wallet_persistent_public, + device_enc_pub_key: wallet_persistent_public, + root_entropy_source: [0x66; 32], + })); let shared_secret = diffie_hellman( wallet_ephemeral_secret.to_nonzero_scalar(), core_public_key.as_affine(), @@ -515,7 +476,7 @@ fn wallet_handshake_statement(deeplink: &str) -> Vec { let hkdf = Hkdf::::new(None, shared_secret.raw_secret_bytes()); let mut aes_key = [0u8; 32]; hkdf.expand(&[], &mut aes_key).unwrap(); - let nonce = [0x44; crate::host_logic::sso::pairing::AES_GCM_NONCE_LEN]; + let nonce = [0x44; pairing::AES_GCM_NONCE_LEN]; let cipher = Aes256Gcm::new_from_slice(&aes_key).unwrap(); let mut encrypted_message = nonce.to_vec(); encrypted_message.extend( @@ -523,7 +484,7 @@ fn wallet_handshake_statement(deeplink: &str) -> Vec { .encrypt(Nonce::from_slice(&nonce), answer.encode().as_slice()) .unwrap(), ); - let handshake = crate::host_logic::sso::pairing::VersionedHandshakeResponse::V2 { + let handshake = pairing::VersionedHandshakeResponse::V2 { encrypted_message, public_key: wallet_ephemeral_public_bytes, }; @@ -813,7 +774,7 @@ impl PlatformPermissions for StubPlatform { _request: v01::RemotePermissionRequest, ) -> Result { Ok(v01::RemotePermissionResponse { - granted: self.remote_permission_granted, + granted: !self.remote_permission_denied, }) } } diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index e981b352..ff5386b1 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -1,4 +1,4 @@ -//! wasm-bindgen surface. Exposes [`WasmHostCore`] to JavaScript hosts so +//! wasm-bindgen surface. Exposes [`WasmProductRuntime`] to JavaScript hosts so //! they can wire the TrUAPI core into a browser or worker shell. //! //! The browser side hands a `callbacks` object (a `JsBridge`) to the @@ -24,22 +24,25 @@ use send_wrapper::SendWrapper; use truapi::v01; use truapi_platform::{ AuthPresenter, AuthState, ChainProvider, CoreStorage, CoreStorageKey, Features, HostInfo, - JsonRpcConnection, Navigation, Notifications, Permissions, PlatformInfo, PreimageHost, - ProductStorage, RuntimeConfig, RuntimeConfigValidationError, SessionUiInfo, ThemeHost, - UserConfirmation, UserConfirmationReview, + JsonRpcConnection, Navigation, Notifications, PairingHostConfig, Permissions, PlatformInfo, + PreimageHost, ProductContext, ProductStorage, RuntimeConfigValidationError, SessionUiInfo, + ThemeHost, UserConfirmation, UserConfirmationReview, }; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; use crate::subscription::Spawner; -use crate::{FrameSink, HostCore, PermissionAuthorizationRequest, PermissionAuthorizationStatus}; +use crate::{ + FrameSink, PairingHostRuntime, PermissionAuthorizationRequest, PermissionAuthorizationStatus, + 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: Option, + cancel_notification: Function, device_permission: Function, remote_permission: Function, feature_supported: Function, @@ -49,14 +52,16 @@ struct JsBridge { core_storage_read: Function, core_storage_write: Function, core_storage_clear: Function, - confirm_user_action: Option, - submit_preimage: Option, - lookup_preimage: Option, - subscribe_theme: Option, - auth_state_changed: Option, - /// Optional. Hosts that own JSON-RPC connections provide this; otherwise - /// chain calls fail with an "unavailable" reason. - chain_connect: Option, + confirm_user_action: Function, + submit_preimage: Function, + lookup_preimage: Function, + subscribe_theme: Function, + auth_state_changed: Function, + chain_connect: Function, +} + +/// Per-core JS channel: outgoing frames and teardown for one product core. +struct CoreChannel { emit_frame: Function, dispose: Function, } @@ -66,7 +71,7 @@ impl JsBridge { Ok(Self { navigate_to: get_function(callbacks, "navigateTo")?, push_notification: get_function(callbacks, "pushNotification")?, - cancel_notification: get_optional_function(callbacks, "cancelNotification")?, + cancel_notification: get_function(callbacks, "cancelNotification")?, device_permission: get_function(callbacks, "devicePermission")?, remote_permission: get_function(callbacks, "remotePermission")?, feature_supported: get_function(callbacks, "featureSupported")?, @@ -76,12 +81,19 @@ impl JsBridge { 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_optional_function(callbacks, "confirmUserAction")?, - submit_preimage: get_optional_function(callbacks, "submitPreimage")?, - lookup_preimage: get_optional_function(callbacks, "lookupPreimage")?, - subscribe_theme: get_optional_function(callbacks, "subscribeTheme")?, - auth_state_changed: get_optional_function(callbacks, "authStateChanged")?, - chain_connect: get_optional_function(callbacks, "chainConnect")?, + 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 { emit_frame: get_function(callbacks, "emitFrame")?, dispose: get_optional_function(callbacks, "dispose")?.unwrap_or_else(noop_function), }) @@ -89,13 +101,13 @@ impl JsBridge { } struct WasmFrameSink { - bridge: SendWrapper>, + emit_frame: SendWrapper, } impl FrameSink for WasmFrameSink { fn emit_frame(&self, frame: Vec) { let frame = Uint8Array::from(frame.as_slice()); - if let Err(err) = self.bridge.emit_frame.call1(&JsValue::NULL, &frame) { + if let Err(err) = self.emit_frame.call1(&JsValue::NULL, &frame) { web_sys::console::error_1(&err); } } @@ -136,10 +148,9 @@ impl Notifications for WasmPlatform { } async fn cancel_notification(&self, id: v01::NotificationId) -> Result<(), v01::GenericError> { - let Some(fn_) = self.bridge.cancel_notification.as_ref() else { - return Ok(()); - }; - invoke_u32_unit(fn_, id).await.map_err(generic) + invoke_u32_unit(&self.bridge.cancel_notification, id) + .await + .map_err(generic) } } @@ -213,14 +224,7 @@ impl ChainProvider for WasmPlatform { &self, genesis_hash: [u8; 32], ) -> Result, v01::GenericError> { - let chain_connect = match self.bridge.chain_connect.clone() { - Some(f) => f, - None => { - return Err(generic( - "chainConnect callback not provided by host".to_string(), - )); - } - }; + let chain_connect = self.bridge.chain_connect.clone(); let chain_connect = SendWrapper::new(chain_connect); SendWrapper::new(async move { let (response_tx, response_rx) = mpsc::unbounded::(); @@ -273,10 +277,11 @@ impl ChainProvider for WasmPlatform { impl AuthPresenter for WasmPlatform { fn auth_state_changed(&self, state: AuthState) { - let Some(fn_) = self.bridge.auth_state_changed.as_ref() else { - return; - }; - if let Err(err) = fn_.call1(&JsValue::NULL, &auth_state_to_js(&state)) { + if let Err(err) = self + .bridge + .auth_state_changed + .call1(&JsValue::NULL, &auth_state_to_js(&state)) + { web_sys::console::error_1(&err); } } @@ -316,33 +321,22 @@ impl UserConfirmation for WasmPlatform { &self, review: UserConfirmationReview, ) -> Result { - let Some(fn_) = self.bridge.confirm_user_action.as_ref() else { - return Err(generic( - "confirmUserAction callback not provided by host".to_string(), - )); - }; - invoke_bool(fn_, review.encode()).await.map_err(generic) + invoke_bool(&self.bridge.confirm_user_action, review.encode()) + .await + .map_err(generic) } } impl ThemeHost for WasmPlatform { fn subscribe_theme(&self) -> BoxStream<'static, Result> { - let Some(fn_) = self.bridge.subscribe_theme.as_ref() else { - return stream::once(async { Ok(v01::ThemeVariant::Dark) }).boxed(); - }; - invoke_js_subscription(fn_, None, parse_theme_item).boxed() + 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> { - let Some(fn_) = self.bridge.submit_preimage.as_ref() else { - return Err(v01::PreimageSubmitError::Unknown { - reason: "submitPreimage callback not provided by host".to_string(), - }); - }; - invoke_bytes_return(fn_, value) + invoke_bytes_return(&self.bridge.submit_preimage, value) .await .map_err(|reason| v01::PreimageSubmitError::Unknown { reason }) } @@ -351,17 +345,18 @@ impl PreimageHost for WasmPlatform { &self, key: Vec, ) -> BoxStream<'static, Result>, v01::GenericError>> { - let Some(fn_) = self.bridge.lookup_preimage.as_ref() else { - return stream::once(async { Ok(None) }).boxed(); - }; - invoke_js_subscription(fn_, Some(key), parse_preimage_lookup_item).boxed() + 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. Their `truapi::api::*` trait defaults return `Unsupported` -// until those in-core implementations land. The JS bridge only carries -// callbacks for the platform capabilities the core cannot satisfy alone. +// 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. struct JsSubscriptionStream { rx: mpsc::UnboundedReceiver, @@ -702,8 +697,8 @@ fn invoke_local_storage_read( let fn_ = bridge.local_storage_read.clone(); let key = key.to_string(); SendWrapper::new(async move { - let arg = JsValue::from_str(&key); - let returned = fn_.call1(&JsValue::NULL, &arg).map_err(js_to_string)?; + 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); @@ -740,8 +735,8 @@ fn invoke_local_storage_clear( let fn_ = bridge.local_storage_clear.clone(); let key = key.to_string(); SendWrapper::new(async move { - let arg = JsValue::from_str(&key); - let returned = fn_.call1(&JsValue::NULL, &arg).map_err(js_to_string)?; + 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(|_| ()) }) } @@ -779,9 +774,15 @@ fn noop_function() -> Function { Function::new_no_args("") } -fn runtime_config_from_js(value: &JsValue) -> Result { +fn runtime_config_from_js(value: &JsValue) -> Result<(PairingHostConfig, ProductContext), JsValue> { + let host_config = pairing_host_config_from_js(value)?; + let product = product_context_from_js(value)?; + Ok((host_config, product)) +} + +fn pairing_host_config_from_js(value: &JsValue) -> Result { if value.is_null() || value.is_undefined() { - return Err(JsValue::from_str("runtimeConfig is required")); + return Err(JsValue::from_str("hostConfig is required")); } let host = get_required_object(value, "host", "runtimeConfig.host")?; @@ -789,8 +790,7 @@ fn runtime_config_from_js(value: &JsValue) -> Result { let people = get_required_object(value, "people", "runtimeConfig.people")?; let pairing = get_required_object(value, "pairing", "runtimeConfig.pairing")?; - RuntimeConfig::new( - get_required_string_at(value, "productId", "runtimeConfig.productId")?, + PairingHostConfig::new( HostInfo { name: get_required_string_at(&host, "name", "runtimeConfig.host.name")?, icon: get_optional_string_at(&host, "icon", "runtimeConfig.host.icon")?, @@ -818,6 +818,18 @@ fn runtime_config_from_js(value: &JsValue) -> Result { .map_err(runtime_config_validation_to_js) } +fn product_context_from_js(value: &JsValue) -> Result { + if value.is_null() || value.is_undefined() { + return Err(JsValue::from_str("product is required")); + } + ProductContext::new(get_required_string_at( + value, + "productId", + "runtimeConfig.productId", + )?) + .map_err(runtime_config_validation_to_js) +} + fn runtime_config_field_to_js(field: &str) -> &str { match field { "product_id" => "productId", @@ -843,6 +855,11 @@ fn runtime_config_validation_to_js(err: RuntimeConfigValidationError) -> JsValue RuntimeConfigValidationError::InvalidDeeplinkScheme { scheme } => JsValue::from_str( &format!("runtimeConfig.pairing.deeplinkScheme must not include ://, got {scheme:?}"), ), + RuntimeConfigValidationError::InvalidProductId { product_id } => { + JsValue::from_str(&format!( + "runtimeConfig.productId must be a .dot or localhost product identifier, got {product_id:?}" + )) + } } } @@ -986,12 +1003,127 @@ fn generic_error_to_js(err: v01::GenericError) -> JsValue { } struct WasmCoreInner { - core: HostCore, + core: ProductRuntime, dispose_fn: SendWrapper, disposed: Cell, disposing: Cell, } +/// JS-callable handle to a long-lived pairing-host runtime shared by product +/// cores. +#[wasm_bindgen] +pub struct WasmPairingHostRuntime { + runtime: Rc, +} + +#[wasm_bindgen] +impl WasmPairingHostRuntime { + /// Build a shared runtime from host-level platform callbacks and host config. + #[wasm_bindgen(constructor)] + pub fn new( + callbacks: JsValue, + host_config: JsValue, + ) -> Result { + console_error_panic_hook::set_once(); + crate::logging::init(); + let bridge = Arc::new(JsBridge::from_js(&callbacks)?); + let platform = Arc::new(WasmPlatform::new(bridge)); + let spawner: Spawner = Arc::new(|fut| { + wasm_bindgen_futures::spawn_local(fut); + }); + let host_config = pairing_host_config_from_js(&host_config)?; + Ok(Self { + runtime: Rc::new(PairingHostRuntime::new(platform, host_config, spawner)), + }) + } + + /// Build one product-scoped runtime from this pairing host runtime. + #[wasm_bindgen(js_name = productRuntime)] + pub fn product_runtime( + &self, + product: JsValue, + core_callbacks: JsValue, + ) -> Result { + let product = product_context_from_js(&product)?; + let channel = CoreChannel::from_js(&core_callbacks)?; + let sink = Arc::new(WasmFrameSink { + emit_frame: SendWrapper::new(channel.emit_frame), + }); + let runtime = self.runtime.product_runtime(product, sink); + Ok(WasmProductRuntime::from_parts(runtime, channel.dispose)) + } + + /// Disconnect the shared account-authority session. + #[wasm_bindgen(js_name = disconnectSession)] + pub async fn disconnect_session(&self) { + self.runtime.disconnect_session().await; + } + + /// Cancel an in-flight pairing flow. + #[wasm_bindgen(js_name = cancelPairing)] + pub fn cancel_pairing(&self) { + self.runtime.cancel_pairing(); + } + + /// Notify the runtime that the auth session slot may have changed. + #[wasm_bindgen(js_name = notifySessionStoreChanged)] + pub fn notify_session_store_changed(&self) { + self.runtime.notify_session_store_changed(); + } + + /// Read a stored permission authorization status for a product. + #[wasm_bindgen(js_name = permissionAuthorizationStatus)] + pub async fn permission_authorization_status( + &self, + product_id: String, + payload: Vec, + ) -> Result { + let request = decode_permission_authorization_request(&payload)?; + let status = self + .runtime + .permission_authorization_status(&product_id, request) + .await + .map_err(generic_error_to_js)?; + Ok(permission_authorization_status_to_js(status)) + } + + /// Read stored permission authorization statuses for a product. + #[wasm_bindgen(js_name = permissionAuthorizationStatuses)] + pub async fn permission_authorization_statuses( + &self, + product_id: String, + payloads: Array, + ) -> Result { + let requests = decode_permission_authorization_requests(&payloads)?; + let statuses = self + .runtime + .permission_authorization_statuses(&product_id, requests) + .await + .map_err(generic_error_to_js)?; + let values = Array::new(); + for status in statuses { + values.push(&permission_authorization_status_to_js(status)); + } + Ok(values) + } + + /// Update a stored permission authorization status for a product. + #[wasm_bindgen(js_name = setPermissionAuthorizationStatus)] + pub async fn set_permission_authorization_status( + &self, + product_id: String, + payload: Vec, + status: String, + ) -> Result<(), JsValue> { + let request = decode_permission_authorization_request(&payload)?; + let status = permission_authorization_status_from_js(&status)?; + self.runtime + .set_permission_authorization_status(&product_id, request, status) + .await + .map_err(generic_error_to_js) + } +} + /// Set the live log level (`off`/`error`/`warn`/`info`/`debug`/`trace`). /// Hosts may call this during boot, or again at any time to re-tune verbosity. /// Unknown values are parsed as `off`. @@ -1000,20 +1132,33 @@ pub fn set_log_level(level: &str) { crate::logging::set_level_from_str(level); } -/// JS-callable handle to the TrUAPI core. Constructed once per shell boot. +/// JS-callable handle to one product-scoped TrUAPI core. #[wasm_bindgen] -pub struct WasmHostCore { +pub struct WasmProductRuntime { inner: Rc, } +impl WasmProductRuntime { + fn from_parts(core: ProductRuntime, dispose_fn: Function) -> Self { + Self { + inner: Rc::new(WasmCoreInner { + core, + dispose_fn: SendWrapper::new(dispose_fn), + disposed: Cell::new(false), + disposing: Cell::new(false), + }), + } + } +} + #[wasm_bindgen] -impl WasmHostCore { +impl WasmProductRuntime { /// Build the core from a JS callbacks object. The object must define /// every host capability the [`truapi_platform::Platform`] trait set /// requires (camelCase property names; see the source for the full /// list). #[wasm_bindgen(constructor)] - pub fn new(callbacks: JsValue, runtime_config: JsValue) -> Result { + pub fn new(callbacks: JsValue, runtime_config: JsValue) -> Result { // Surface Rust panics to the browser console. A panic mid-dispatch // aborts the call as a wasm trap; the host should treat a thrown error // from `receiveFrame` as a fatal-instance signal and rebuild the @@ -1021,25 +1166,23 @@ impl WasmHostCore { console_error_panic_hook::set_once(); crate::logging::init(); let bridge = Arc::new(JsBridge::from_js(&callbacks)?); + let channel = CoreChannel::from_js(&callbacks)?; let frame_sink = Arc::new(WasmFrameSink { - bridge: SendWrapper::new(bridge.clone()), + emit_frame: SendWrapper::new(channel.emit_frame), }); - let dispose_fn = SendWrapper::new(bridge.dispose.clone()); let platform = Arc::new(WasmPlatform::new(bridge)); let spawner: Spawner = Arc::new(|fut| { wasm_bindgen_futures::spawn_local(fut); }); - let runtime_config = runtime_config_from_js(&runtime_config)?; - let core = - HostCore::from_platform_with_config(platform, runtime_config, spawner, frame_sink); - Ok(Self { - inner: Rc::new(WasmCoreInner { - core, - dispose_fn, - disposed: Cell::new(false), - disposing: Cell::new(false), - }), - }) + let (host_config, product) = runtime_config_from_js(&runtime_config)?; + let core = ProductRuntime::from_platform_with_config( + platform, + host_config, + product, + spawner, + frame_sink, + ); + Ok(Self::from_parts(core, channel.dispose)) } /// Push a SCALE-encoded protocol frame into the dispatcher. Responses @@ -1140,21 +1283,4 @@ impl WasmHostCore { self.inner.core.disconnect_session().await; Ok(()) } - - /// Cancel any in-flight `request_login` pairing. The host receives a - /// `Disconnected` auth state immediately and the pending login resolves - /// to `Rejected`. A no-op when no login is in progress. - #[wasm_bindgen(js_name = cancelPairing)] - pub fn cancel_pairing(&self) { - self.inner.core.cancel_pairing(); - } - - /// Notify the core that the host-global auth session slot may have changed. - #[wasm_bindgen(js_name = notifySessionStoreChanged)] - pub fn notify_session_store_changed(&self) { - if self.inner.disposed.get() { - return; - } - self.inner.core.notify_session_store_changed(); - } } diff --git a/rust/crates/truapi-server/tests/common/mod.rs b/rust/crates/truapi-server/tests/common/mod.rs index 7f6eee17..8d0743cd 100644 --- a/rust/crates/truapi-server/tests/common/mod.rs +++ b/rust/crates/truapi-server/tests/common/mod.rs @@ -7,8 +7,9 @@ use futures::stream::{self, BoxStream}; use truapi::v01; use truapi_platform::{ AuthPresenter, ChainProvider, CoreStorage, CoreStorageKey, Features, HostInfo, - JsonRpcConnection, Navigation, Notifications, Permissions, PlatformInfo, PreimageHost, - ProductStorage, RuntimeConfig, ThemeHost, UserConfirmation, UserConfirmationReview, + JsonRpcConnection, Navigation, Notifications, PairingHostConfig, Permissions, PlatformInfo, + PreimageHost, ProductContext, ProductStorage, ThemeHost, UserConfirmation, + UserConfirmationReview, }; use truapi_server::frame::ProtocolMessage; use truapi_server::transport::Transport; @@ -46,19 +47,21 @@ pub fn test_spawner() -> truapi_server::subscription::Spawner { } /// Runtime configuration shared by integration tests. -pub fn test_runtime_config() -> RuntimeConfig { - RuntimeConfig::new( - "dotli.dot".to_string(), - HostInfo { - name: "Polkadot Web".to_string(), - icon: Some("https://dot.li/dotli.png".to_string()), - version: None, - }, - PlatformInfo::default(), - [0xa2; 32], - "polkadotapp".to_string(), +pub fn test_runtime_config() -> (PairingHostConfig, ProductContext) { + ( + PairingHostConfig::new( + HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://dot.li/dotli.png".to_string()), + version: None, + }, + PlatformInfo::default(), + [0xa2; 32], + "polkadotapp".to_string(), + ) + .expect("test host runtime config is valid"), + ProductContext::new("dotli.dot".to_string()).expect("test product context is valid"), ) - .expect("test runtime config is valid") } pub struct WireShapePlatform; diff --git a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs index df531ba1..80f42931 100644 --- a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs +++ b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs @@ -9,15 +9,14 @@ use p256::elliptic_curve::sec1::ToEncodedPoint; use parity_scale_codec::{Decode, Encode}; use schnorrkel::{ExpansionMode, MiniSecretKey}; use sha2::Sha256; -use truapi_platform::{HostInfo, PlatformInfo, RuntimeConfig}; +use truapi_platform::{HostInfo, PairingHostConfig, PlatformInfo}; use truapi_server::host_logic::entropy::derive_product_entropy; use truapi_server::host_logic::product_account::{ derive_product_public_key, product_public_key_to_address, }; use truapi_server::host_logic::session::SsoSessionInfo; -use truapi_server::host_logic::sso_pairing::{ - AES_GCM_NONCE_LEN, EncryptedHandshakeResponseV2, HandshakeMetadataEntry, HandshakeMetadataKey, - HandshakeSuccessV2, PairingBootstrap, SsoStatementData, VersionedHandshakeProposal, +use truapi_server::host_logic::sso::pairing::{ + self, AES_GCM_NONCE_LEN, PairingBootstrap, SsoStatementData, VersionedHandshakeProposal, VersionedHandshakeResponse, bootstrap_topic, build_pairing_deeplink, decode_app_handshake_data, decrypt_session_statement_data, decrypt_v2_handshake_response, encrypt_session_statement_data_with_nonce, establish_sso_session_info, @@ -49,9 +48,8 @@ fn entropy_secret() -> [u8; 32] { std::array::from_fn(|i| i as u8) } -fn runtime_config() -> RuntimeConfig { - RuntimeConfig::new( - "dotli.dot".to_string(), +fn runtime_config() -> PairingHostConfig { + PairingHostConfig::new( HostInfo { name: "Polkadot Web".to_string(), icon: Some("https://example.invalid/dotli.png".to_string()), @@ -132,17 +130,15 @@ fn pairing_deeplink_topic_and_scale_vectors_match_dotli() { assert!(deeplink.starts_with("polkadotapp://pair?handshake=01")); let encoded = hex::decode(deeplink.split("handshake=").nth(1).unwrap()).unwrap(); let decoded = VersionedHandshakeProposal::decode(&mut &encoded[..]).unwrap(); - let VersionedHandshakeProposal::V2(proposal) = decoded else { - panic!("expected V2 proposal"); - }; + let VersionedHandshakeProposal::V2(proposal) = decoded; assert_eq!(proposal.device.statement_account_id, SS_PUBLIC); assert_eq!(proposal.device.encryption_public_key, ENC_PUBLIC); - assert!(proposal.metadata.contains(&HandshakeMetadataEntry( - HandshakeMetadataKey::HostName, + assert!(proposal.metadata.contains(&pairing::v2::MetadataEntry( + pairing::v2::MetadataKey::HostName, "Polkadot Web".to_string() ))); - assert!(proposal.metadata.contains(&HandshakeMetadataEntry( - HandshakeMetadataKey::HostIcon, + assert!(proposal.metadata.contains(&pairing::v2::MetadataEntry( + pairing::v2::MetadataKey::HostIcon, "https://example.invalid/dotli.png".to_string() ))); assert_eq!( @@ -171,14 +167,14 @@ fn p256_hkdf_aes_gcm_vectors_work_on_wasm() { let mut aes_key = [0u8; 32]; hkdf.expand(&[], &mut aes_key).unwrap(); - let sensitive = EncryptedHandshakeResponseV2::Success(HandshakeSuccessV2 { + let sensitive = pairing::v2::EncryptedResponse::Success(Box::new(pairing::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 nonce = [9u8; AES_GCM_NONCE_LEN]; let cipher = Aes256Gcm::new_from_slice(&aes_key).unwrap(); let mut encrypted = nonce.to_vec(); diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs index ba9debc1..16deb1f1 100644 --- a/rust/crates/truapi-server/tests/wire_result_shape.rs +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -35,8 +35,7 @@ const PAYMENTS_NOT_IMPLEMENTED: &str = "Payments are not supported in dot.li"; fn dispatch(core: &TrUApiCore, frame: ProtocolMessage) -> ProtocolMessage { let encoded = frame.encode(); - let response_bytes = core - .receive_from_product(&encoded) + let response_bytes = futures::executor::block_on(core.receive_from_product(&encoded)) .expect("dispatcher emitted a response frame"); ProtocolMessage::decode(&mut &response_bytes[..]).expect("decode response") } @@ -336,9 +335,11 @@ fn malformed_result_subscription_start_interrupts_with_malformed_frame() { } fn make_core() -> TrUApiCore { + let (host_config, product) = test_runtime_config(); TrUApiCore::from_platform_with_config( Arc::new(WireShapePlatform), - test_runtime_config(), + host_config, + product, test_spawner(), ) } @@ -351,16 +352,14 @@ fn malformed_frames_are_dropped_without_panic() { let core = make_core(); // Empty input and arbitrary garbage. - assert!(core.receive_from_product(&[]).is_none()); + assert!(futures::executor::block_on(core.receive_from_product(&[])).is_none()); assert!( - core.receive_from_product(&[0xff, 0xff, 0xff, 0xff]) - .is_none() + futures::executor::block_on(core.receive_from_product(&[0xff, 0xff, 0xff, 0xff])).is_none() ); // A truncated SCALE string header (claims length but no body). assert!( - core.receive_from_product(&[200u8 << 2, 0x61, 0x62]) - .is_none() + futures::executor::block_on(core.receive_from_product(&[200u8 << 2, 0x61, 0x62])).is_none() ); // A well-formed requestId envelope carrying an unknown wire discriminant. @@ -368,7 +367,7 @@ fn malformed_frames_are_dropped_without_panic() { "p:1".to_string().encode_to(&mut unknown_disc); unknown_disc.push(0xFA); unknown_disc.extend_from_slice(&[0u8; 4]); - assert!(core.receive_from_product(&unknown_disc).is_none()); + assert!(futures::executor::block_on(core.receive_from_product(&unknown_disc)).is_none()); } /// Drive a subscription through the encoded-frame boundary: `_start` yields diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index b4baf520..c37854e9 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -24,13 +24,17 @@ pub mod latest { pub use crate::v01::{ AccountId, AllocatableResource, GenericError, HostSignPayloadData, NotificationId, - ProductAccountId, RawPayload, RemotePermission, ThemeVariant, + OperationStartedResult, ProductAccountId, RawPayload, RemotePermission, RuntimeApi, + RuntimeSpec, RuntimeType, StorageQueryItem, StorageQueryType, StorageResultItem, + ThemeVariant, }; pub type LatestOf = ::Latest; pub type HostAccountGetAliasResponse = LatestOf; + pub type HostCreateTransactionResponse = + LatestOf; pub type HostDevicePermissionRequest = LatestOf; pub type HostDevicePermissionResponse = @@ -45,9 +49,14 @@ pub mod latest { LatestOf; pub type HostPushNotificationResponse = LatestOf; + pub type HostRequestLoginError = LatestOf; + pub type HostRequestLoginResponse = LatestOf; pub type HostRequestResourceAllocationRequest = LatestOf; + pub type HostRequestResourceAllocationResponse = + LatestOf; pub type HostSignPayloadRequest = LatestOf; + pub type HostSignPayloadResponse = LatestOf; pub type HostSignPayloadWithLegacyAccountRequest = LatestOf; pub type HostSignRawRequest = LatestOf; @@ -57,6 +66,13 @@ pub mod latest { LatestOf; pub type PreimageSubmitError = LatestOf; pub type ProductAccountTxPayload = LatestOf; + pub type RemoteChainHeadFollowItem = LatestOf; + pub type RemoteChainHeadFollowRequest = + LatestOf; + pub type RemoteChainHeadStorageRequest = + LatestOf; + pub type RemoteChainHeadStorageResponse = + LatestOf; pub type RemotePermissionRequest = LatestOf; pub type RemotePermissionResponse = LatestOf; } From e4d9a0dfae26845c2a21cebc34e5ed3c97e8cd57 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 17:21:10 +0200 Subject: [PATCH 26/56] fixup! feat(truapi-server): add wire and chain infrastructure --- .../crates/truapi-server/src/chain_runtime.rs | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 373dfbe8..af38a348 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -138,8 +138,8 @@ impl RuntimeFailure { } /// Method tag the failure originated from. - #[allow(dead_code)] - pub fn method(&self) -> &'static str { + #[cfg(test)] + fn method(&self) -> &'static str { self.method } @@ -161,8 +161,7 @@ impl RuntimeFailure { } /// Provider of `JsonRpcConnection` instances keyed by chain genesis hash. -/// The default [`UnavailableChainProvider`] makes every call fail; real -/// hosts plug in the platform-side `ChainProvider`. +/// Hosts plug in the platform-side `ChainProvider`. #[async_trait::async_trait] pub trait RuntimeChainProvider: Send + Sync { /// Open or reuse a JSON-RPC connection for the chain identified by @@ -173,22 +172,6 @@ pub trait RuntimeChainProvider: Send + Sync { ) -> Result, RuntimeFailure>; } -/// Default provider: every `connect` call fails with `Unavailable`, so each -/// chain RPC surfaces a typed "unavailable" error to the product. -#[allow(dead_code)] -#[derive(Default)] -pub struct UnavailableChainProvider; - -#[async_trait::async_trait] -impl RuntimeChainProvider for UnavailableChainProvider { - async fn connect( - &self, - _genesis_hash: Vec, - ) -> Result, RuntimeFailure> { - Err(RuntimeFailure::unavailable("remote_chain_connect")) - } -} - /// chainHead-v1 state machine on top of a [`RuntimeChainProvider`]. /// /// Each method maps a typed v01 chain request to one or more json-rpc calls, @@ -1148,6 +1131,19 @@ mod tests { } } + #[derive(Default)] + struct UnavailableChainProvider; + + #[async_trait] + impl RuntimeChainProvider for UnavailableChainProvider { + async fn connect( + &self, + _genesis_hash: Vec, + ) -> Result, RuntimeFailure> { + Err(RuntimeFailure::unavailable("remote_chain_connect")) + } + } + /// Provider that echoes a canned response for every request it sees, /// driven by a `respond` closure. The closure receives each json-rpc /// request string and returns the response string the test wants the From 7ac562a9239b449e6848651b8c075cb5dbdc8f86 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 17:26:34 +0200 Subject: [PATCH 27/56] fixup! feat(truapi-server): add platform runtime and host bridge --- rust/crates/truapi-server/src/runtime.rs | 36 +++++++++---------- .../src/runtime/pairing_host/sso_channel.rs | 8 ++--- rust/crates/truapi-server/src/test_support.rs | 6 ++-- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 3517be62..be7f2d7a 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -1653,7 +1653,7 @@ impl Notifications for ProductRuntimeHost { #[cfg(test)] mod tests { use super::*; - use crate::host_logic::sso::messages::{RemoteMessageData, RemoteMessageV1}; + use crate::host_logic::sso::messages::{RemoteMessageData, v1}; use crate::test_support::*; use std::sync::Mutex; use truapi_platform::{AuthState, CoreStorageKey}; @@ -1914,7 +1914,7 @@ mod tests { crate::host_logic::sso::messages::RemoteMessage { message_id: "wallet-alias-1".to_string(), data: crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::RingVrfAliasResponse( + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasResponse( crate::host_logic::sso::messages::RingVrfAliasResponse { responding_to: "alias-1".to_string(), payload: Ok(v01::HostAccountGetAliasResponse { @@ -1944,7 +1944,7 @@ mod tests { assert_eq!(inner.alias, vec![1, 2, 3]); let message = submitted_remote_message(&platform, &session); let crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::RingVrfAliasRequest(request), + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasRequest(request), ) = message.data else { panic!("expected ring VRF alias request"); @@ -1963,7 +1963,7 @@ mod tests { crate::host_logic::sso::messages::RemoteMessage { message_id: "wallet-alias-1".to_string(), data: crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::RingVrfAliasResponse( + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasResponse( crate::host_logic::sso::messages::RingVrfAliasResponse { responding_to: "alias-1".to_string(), payload: Ok(v01::HostAccountGetAliasResponse { @@ -1990,7 +1990,7 @@ mod tests { .unwrap(); let message = submitted_remote_message(&platform, &session); let crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::RingVrfAliasRequest(request), + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasRequest(request), ) = message.data else { panic!("expected ring VRF alias request"); @@ -2049,7 +2049,7 @@ mod tests { crate::host_logic::sso::messages::RemoteMessage { message_id: "wallet-alias-2".to_string(), data: crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::RingVrfAliasResponse( + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasResponse( crate::host_logic::sso::messages::RingVrfAliasResponse { responding_to: "alias-2".to_string(), payload: Ok(v01::HostAccountGetAliasResponse { @@ -2081,7 +2081,7 @@ mod tests { assert!(matches!( message.data, crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::RingVrfAliasRequest(_) + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasRequest(_) ) )); } @@ -2353,7 +2353,7 @@ mod tests { assert!(matches!( &message.data, crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::SignRequest(request) + crate::host_logic::sso::messages::v1::RemoteMessage::SignRequest(request) ) if matches!( request.as_ref(), crate::host_logic::sso::messages::SigningRequest::Raw(_) @@ -2573,7 +2573,7 @@ mod tests { assert!(matches!( &message.data, crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::SignRequest(request) + crate::host_logic::sso::messages::v1::RemoteMessage::SignRequest(request) ) if matches!( request.as_ref(), crate::host_logic::sso::messages::SigningRequest::Payload(_) @@ -2592,7 +2592,7 @@ mod tests { crate::host_logic::sso::messages::RemoteMessage { message_id: "wallet-create-tx-1".to_string(), data: crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::CreateTransactionResponse( + crate::host_logic::sso::messages::v1::RemoteMessage::CreateTransactionResponse( crate::host_logic::sso::messages::CreateTransactionResponse { responding_to: "create-tx-1".to_string(), signed_transaction: Ok(vec![0xca, 0xfe]), @@ -2618,7 +2618,7 @@ mod tests { assert!(matches!( message.data, crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::CreateTransactionRequest(_) + crate::host_logic::sso::messages::v1::RemoteMessage::CreateTransactionRequest(_) ) )); } @@ -2702,7 +2702,7 @@ mod tests { assert_eq!(inner.signed_transaction, None); let message = submitted_remote_message(&platform, &session); let crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::SignRequest(request), + crate::host_logic::sso::messages::v1::RemoteMessage::SignRequest(request), ) = message.data else { panic!("expected product raw signing request"); @@ -2756,7 +2756,7 @@ mod tests { let message = submitted_remote_message(&platform, &session); let crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::SignRequest(request), + crate::host_logic::sso::messages::v1::RemoteMessage::SignRequest(request), ) = message.data else { panic!("expected product raw signing request"); @@ -2810,7 +2810,7 @@ mod tests { crate::host_logic::sso::messages::RemoteMessage { message_id: "wallet-legacy-create-tx-1".to_string(), data: crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::CreateTransactionResponse( + crate::host_logic::sso::messages::v1::RemoteMessage::CreateTransactionResponse( crate::host_logic::sso::messages::CreateTransactionResponse { responding_to: "legacy-create-tx-1".to_string(), signed_transaction: Ok(vec![0xca, 0xfe]), @@ -2845,7 +2845,7 @@ mod tests { assert_eq!(inner.transaction, vec![0xca, 0xfe]); let message = submitted_remote_message(&platform, &session); let crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::CreateTransactionRequest(request), + crate::host_logic::sso::messages::v1::RemoteMessage::CreateTransactionRequest(request), ) = message.data else { panic!("expected product transaction request"); @@ -2947,7 +2947,7 @@ mod tests { crate::host_logic::sso::messages::RemoteMessage { message_id: "wallet-alloc-1".to_string(), data: crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::ResourceAllocationResponse( + crate::host_logic::sso::messages::v1::RemoteMessage::ResourceAllocationResponse( crate::host_logic::sso::messages::ResourceAllocationResponse { responding_to: "alloc-1".to_string(), payload: Ok(vec![ @@ -2988,7 +2988,7 @@ mod tests { assert!(matches!( message.data, crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::ResourceAllocationRequest(_) + crate::host_logic::sso::messages::v1::RemoteMessage::ResourceAllocationRequest(_) ) )); } @@ -3156,7 +3156,7 @@ mod tests { assert_eq!(message.message_id, "truapi:sso:disconnect"); assert!(matches!( message.data, - RemoteMessageData::V1(RemoteMessageV1::Disconnected) + RemoteMessageData::V1(v1::RemoteMessage::Disconnected) )); } diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index a790f1f2..28d1801c 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -24,10 +24,10 @@ use super::AuthorityRequestKind; use super::PairingHost; use crate::host_logic::session::{SessionInfo, SessionState, SsoSessionInfo}; use crate::host_logic::sso::messages::{ - OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, RemoteMessageV1, - SsoAllocationOutcome, SsoRemoteResponse, SsoSessionStatement, alias_request_message, + OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, SsoAllocationOutcome, + SsoRemoteResponse, SsoSessionStatement, alias_request_message, build_outgoing_request_statement, create_transaction_message, decode_sso_session_statement, - resource_allocation_message, sign_payload_message, sign_raw_message, + resource_allocation_message, sign_payload_message, sign_raw_message, v1, }; use crate::host_logic::statement_store::parse_new_statements_result; @@ -168,7 +168,7 @@ impl PairingHost { let message_id = "truapi:sso:disconnect".to_string(); let message = RemoteMessage { message_id: message_id.clone(), - data: RemoteMessageData::V1(RemoteMessageV1::Disconnected), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), }; let statement = build_outgoing_request_statement( sso, diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 887e7fc6..9d258ee1 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -327,7 +327,7 @@ pub(crate) fn sso_peer_disconnect_responses( crate::host_logic::sso::messages::RemoteMessage { message_id: format!("wallet-disconnect-{message_id}"), data: crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::Disconnected, + crate::host_logic::sso::messages::v1::RemoteMessage::Disconnected, ), } .encode(), @@ -356,7 +356,7 @@ pub(crate) fn sso_peer_disconnect_monitor_responses( crate::host_logic::sso::messages::RemoteMessage { message_id: "wallet-disconnect-monitor".to_string(), data: crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::Disconnected, + crate::host_logic::sso::messages::v1::RemoteMessage::Disconnected, ), } .encode(), @@ -501,7 +501,7 @@ pub(crate) fn sign_response_message( crate::host_logic::sso::messages::RemoteMessage { message_id: format!("wallet-{message_id}"), data: crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::RemoteMessageV1::SignResponse( + crate::host_logic::sso::messages::v1::RemoteMessage::SignResponse( crate::host_logic::sso::messages::SigningResponse { responding_to: message_id.to_string(), payload: Ok( From 54f0b20bd6135614baac537a993d4a4b08489d7c Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 18:23:22 +0200 Subject: [PATCH 28/56] docs(host-logic): link host-spec sections --- .../truapi-server/src/host_logic/entropy.rs | 2 ++ .../truapi-server/src/host_logic/identity.rs | 2 ++ .../src/host_logic/product_account.rs | 6 ++++++ .../truapi-server/src/host_logic/session.rs | 7 +++++++ .../truapi-server/src/host_logic/sso/messages.rs | 5 ++++- .../src/host_logic/sso/messages/v1.rs | 7 +++++++ .../truapi-server/src/host_logic/sso/pairing.rs | 15 +++++++++++++++ .../src/host_logic/sso/pairing/v2.rs | 4 ++++ .../src/host_logic/statement_store.rs | 2 ++ .../src/host_logic/statement_store/rpc.rs | 3 +++ .../src/host_logic/statement_store/statement.rs | 9 +++++++++ 11 files changed, 61 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi-server/src/host_logic/entropy.rs b/rust/crates/truapi-server/src/host_logic/entropy.rs index 1e9b4193..cbdd3fe2 100644 --- a/rust/crates/truapi-server/src/host_logic/entropy.rs +++ b/rust/crates/truapi-server/src/host_logic/entropy.rs @@ -2,6 +2,8 @@ //! //! Matches dotli's product entropy contract: three keyed BLAKE2b-256 layers //! over the session secret, product id, and caller key. +//! Host-spec C.8 defines the RFC-0007 product entropy algorithm: +//! use blake2_rfc::blake2b::blake2b; use thiserror::Error; diff --git a/rust/crates/truapi-server/src/host_logic/identity.rs b/rust/crates/truapi-server/src/host_logic/identity.rs index 0fbac76f..4f798825 100644 --- a/rust/crates/truapi-server/src/host_logic/identity.rs +++ b/rust/crates/truapi-server/src/host_logic/identity.rs @@ -5,6 +5,8 @@ //! it builds that storage key and decodes the leading username fields from the //! SCALE value. The record begins with a fixed identifier public key; credibility //! and statement-store slots are intentionally ignored. +//! Host-spec G defines the cross-host identity model and lookup behavior: +//! use parity_scale_codec::Decode; use sp_crypto_hashing::{blake2_128, twox_128}; 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 8298907c..04298daa 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -2,6 +2,9 @@ //! //! Mirrors dotli's `packages/auth/src/account.ts`: derive an sr25519 public //! key through soft HDKD junctions `["product", product_id, derivation_index]`. +//! Host-spec C.5-C.7 define the product-account derivation, SS58 address, and +//! `ProductAccountId` shape: +//! use blake2_rfc::blake2b::blake2b; use parity_scale_codec::Encode; @@ -30,6 +33,9 @@ pub enum ProductAccountError { /// Derive the root sr25519 keypair from raw BIP-39 entropy. /// +/// Host-spec C.1 defines the BIP-39 entropy to sr25519 mini-secret path: +/// +/// /// Matches the Substrate mini-secret scheme (`sp_core::sr25519::Pair::from_entropy`) /// used by polkadot-app-ios-v2: PBKDF2 over the entropy to a 32-byte mini /// secret, then Ed25519-mode expansion. The public key of this keypair is the diff --git a/rust/crates/truapi-server/src/host_logic/session.rs b/rust/crates/truapi-server/src/host_logic/session.rs index 261c110a..3eb51ff8 100644 --- a/rust/crates/truapi-server/src/host_logic/session.rs +++ b/rust/crates/truapi-server/src/host_logic/session.rs @@ -2,6 +2,13 @@ //! unpairing with a signing host changes the inter-host session, and //! account-management methods read it instead of round-tripping host callbacks //! on every product call. +//! +//! Host-spec B.1.5 and B.3.1 define the remote account keys and session topics: +//! +//! +//! The persisted blob is core-owned and host-local; storage.md captures current +//! cross-host persistence status quo: +//! use futures::channel::mpsc; use futures::stream::{self, BoxStream, StreamExt}; 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 b4ece9a0..f801abae 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -6,7 +6,10 @@ //! allocation, and transaction requests to the paired signing host, then //! decodes the signing host's responses while waiting on the SSO //! statement-store channels. -//! The envelope and baseline message catalog are specified in host-spec: +//! The encrypted statement envelope and message identifiers are specified in +//! host-spec: +//! +//! The baseline remote message catalog is specified in host-spec: //! //! Deployed extension variants are tracked as a host-spec divergence: //! diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs index 9e2b9cbd..cbd0204a 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs @@ -1,3 +1,10 @@ +//! V1 application messages exchanged on the encrypted SSO channel. +//! +//! Baseline variants are specified in host-spec B.5: +//! +//! Additional deployed variants are tracked as divergence D-B.5.6: +//! + use parity_scale_codec::{Decode, Encode}; use super::{ 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 93231638..eff47058 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -2,6 +2,12 @@ //! //! This module owns the byte shape of the QR/deeplink payload described in //! `docs/design/host-contract-and-core-impl/H - sso-pairing-protocol.md`. +//! The inter-host pairing flow is specified in host-spec B.1: +//! +//! The deeplink route and hex construction rules are specified in host-spec L: +//! +//! Session crypto and statement-store framing are specified in host-spec B.2-B.4: +//! //! The SCALE handshake codecs are kept wire-compatible with host-papp's v2 //! handshake codec: //! @@ -62,6 +68,9 @@ pub enum PairingBootstrapError { } /// Versioned SCALE payload embedded in the pairing deeplink. +/// +/// Host-spec B.1.1 defines the deeplink as lowercase hex of this SCALE payload: +/// #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum VersionedHandshakeProposal { #[codec(index = 1)] @@ -69,6 +78,9 @@ pub enum VersionedHandshakeProposal { } /// Versioned encrypted response posted by the wallet to the pairing topic. +/// +/// Host-spec B.1.4 defines the encrypted answer statement and matching topic: +/// #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum VersionedHandshakeResponse { #[codec(index = 1)] @@ -83,6 +95,9 @@ pub mod v2; /// Encrypted statement-channel envelope shared with the wallet. /// +/// Host-spec B.4.1 defines this request/response wrapper: +/// +/// /// Mirrors `@novasamatech/statement-store` session statement data: /// #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs index 43f3f277..4118a8c7 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs @@ -1,4 +1,8 @@ //! Host-papp v2 handshake wire types. +//! +//! Host-spec B.1 defines the pairing handshake shape that this deployed v2 +//! codec implements as a wire-compatible host-papp dialect: +//! use parity_scale_codec::{Decode, Encode}; diff --git a/rust/crates/truapi-server/src/host_logic/statement_store.rs b/rust/crates/truapi-server/src/host_logic/statement_store.rs index 06980545..3068285b 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store.rs @@ -4,6 +4,8 @@ //! `ChainProvider` JSON-RPC connection. Transport mechanics live in //! `HostRpcClient`; this module owns statement-store payload encoding, //! proof verification, and subscription-result parsing. +//! Host-spec N.5 names the statement-store JSON-RPC methods hosts use: +//! use thiserror::Error; diff --git a/rust/crates/truapi-server/src/host_logic/statement_store/rpc.rs b/rust/crates/truapi-server/src/host_logic/statement_store/rpc.rs index bedbce5d..28e71554 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store/rpc.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store/rpc.rs @@ -1,5 +1,8 @@ //! Statement-store JSON-RPC shapes mirrored from `sp_statement_store`. //! +//! Host-spec N.5 lists the statement-store methods hosts must use: +//! +//! //! See the upstream RPC methods plus `TopicFilter` / `StatementEvent` types: //! //! diff --git a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs index faef35c1..c90bb848 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs @@ -7,6 +7,10 @@ use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; use crate::host_logic::session::SsoSessionInfo; /// Verified statement payload plus the sr25519 signer recovered from proof. +/// +/// SSO receivers verify incoming statement proofs against the paired +/// `identityAccountId` as required by host-spec B.3.4: +/// #[derive(Debug, Clone, PartialEq, Eq)] pub struct VerifiedStatementData { /// Raw statement data field. @@ -120,6 +124,11 @@ pub fn decode_signed_statement( } /// Build a signed statement on the active SSO request channel. +/// +/// Host-spec B.3.1 defines directional session topics; B.4.1 defines the +/// encrypted statement `data` wrapper carried by this statement: +/// +/// pub fn build_signed_session_request_statement( session: &SsoSessionInfo, encrypted_data: Vec, From ac7992f00238f43f8465d07d9252dd5ac7cab52e Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 18:24:01 +0200 Subject: [PATCH 29/56] docs(runtime): link host-spec sections --- rust/crates/truapi-platform/src/lib.rs | 16 ++++++++++++++++ .../src/runtime/pairing_host/sso_channel.rs | 3 +++ .../truapi-server/src/runtime/sso_remote.rs | 2 ++ 3 files changed, 21 insertions(+) diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 9b8ccac5..392b6df2 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -42,10 +42,17 @@ pub struct HostRuntimeConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct PairingHostConfig { /// Host identity shown to the signing host during pairing. + /// + /// Host-spec B.1.3 defines the host metadata consumed by the signing host: + /// pub host: HostRuntimeConfig, /// People-chain genesis hash used for statement-store SSO. pub people_chain_genesis_hash: [u8; 32], /// Deeplink URI scheme used in pairing QR payloads, without `://`. + /// + /// Host-spec L.2-L.3 define the `polkadotapp://pair` route and construction + /// rules: + /// pub pairing_deeplink_scheme: String, } @@ -70,6 +77,9 @@ pub struct SigningHostConfig { pub struct ProductContext { /// Product identifier used for account derivation and product-scoped /// storage/permission namespaces. + /// + /// Host-spec C.7 defines accepted product id forms: + /// pub product_id: String, } @@ -382,6 +392,9 @@ pub trait Features: Send + Sync { /// /// The platform provides a way to get a JSON-RPC connection for a given chain. /// The server runtime manages the chainHead v1 state machine on top of this. +/// Host-spec N.6 requires products to access chains through host-mediated +/// providers: +/// #[async_trait] pub trait ChainProvider: Send + Sync { /// Open a JSON-RPC connection for the chain identified by `genesis_hash`. @@ -409,6 +422,9 @@ pub trait JsonRpcConnection: Send + Sync { /// Core-owned host-private storage slots. Products never address these slots; /// the host chooses the backing store for each slot. +/// +/// Storage is host-local; `storage.md` records the current status quo: +/// #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum CoreStorageKey { /// Opaque SSO/auth session blob. diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index 28d1801c..6a9acd05 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -36,6 +36,9 @@ use futures::future::{AbortHandle, Abortable}; use tracing::{debug, instrument, warn}; use truapi::{CallContext, v01}; +/// Host-spec B.6.2 recommends timing out unanswered SSO application requests +/// after 180 seconds: +/// const DEFAULT_SSO_RESPONSE_TIMEOUT: Duration = Duration::from_secs(180); const UNEXPECTED_SSO_SIGNING_RESPONSE: &str = "Unexpected SSO response for signing request"; const UNEXPECTED_SSO_TRANSACTION_RESPONSE: &str = "Unexpected SSO response for transaction request"; diff --git a/rust/crates/truapi-server/src/runtime/sso_remote.rs b/rust/crates/truapi-server/src/runtime/sso_remote.rs index ec728d2d..6d0d383d 100644 --- a/rust/crates/truapi-server/src/runtime/sso_remote.rs +++ b/rust/crates/truapi-server/src/runtime/sso_remote.rs @@ -28,6 +28,8 @@ use subxt_rpcs::client::RpcSubscription; use tracing::instrument; use truapi::CallContext; +/// Host-spec B.3.3 recommends seven-day statement expiry for session traffic: +/// const DEFAULT_SSO_STATEMENT_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60; /// Disconnect reason reported when the local session logs out mid-request. pub(super) const SSO_LOCAL_DISCONNECT_REASON: &str = "SSO session disconnected"; From cda27e185af8708bb516c0550b76a5cdcd8cb012 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 21:52:30 +0200 Subject: [PATCH 30/56] fixup! feat(truapi-server): add wire and chain infrastructure --- rust/crates/truapi-server/src/chain_runtime.rs | 3 +++ rust/crates/truapi-server/src/host_rpc_client.rs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index af38a348..e77afbdd 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -12,6 +12,9 @@ //! (`Unsupported`, `HostFailure`, ...). This avoids leaking json-rpc plumbing //! into the public API. +// Temporary for this stack layer: runtime wiring lands in the next child PR. +#![allow(dead_code)] + use core::pin::Pin; use core::task::{Context, Poll}; use std::collections::HashMap; diff --git a/rust/crates/truapi-server/src/host_rpc_client.rs b/rust/crates/truapi-server/src/host_rpc_client.rs index 0d46e586..ec959b14 100644 --- a/rust/crates/truapi-server/src/host_rpc_client.rs +++ b/rust/crates/truapi-server/src/host_rpc_client.rs @@ -5,6 +5,9 @@ //! [`subxt_rpcs::RpcClientT`]: request correlation, subscription routing, and //! best-effort unsubscribe on subscription drop. +// Temporary for this stack layer: runtime wiring lands in the next child PR. +#![allow(dead_code)] + use core::fmt; use core::mem; use core::pin::Pin; From 8bd9085c82764fccbc33b3e2c2afd2b6caacd38f Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 21:53:51 +0200 Subject: [PATCH 31/56] fixup! feat(truapi-server): add platform runtime and host bridge --- .../crates/truapi-codegen/tests/golden/host-callbacks.ts | 9 +++++++++ rust/crates/truapi-server/src/chain_runtime.rs | 3 --- rust/crates/truapi-server/src/host_rpc_client.rs | 3 --- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 407e2050..71ffdb2b 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -74,6 +74,9 @@ export type AuthState = /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. + * + * Storage is host-local; `storage.md` records the current status quo: + * */ export type CoreStorageKey = /** @@ -223,6 +226,9 @@ export const AccountAliasReview: S.Codec = S.lazy((): S.Code /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. + * + * Storage is host-local; `storage.md` records the current status quo: + * */ export const CoreStorageKey: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({AuthSession: S._void, PairingDeviceIdentity: S._void, PermissionAuthorization: S.Struct({productId: S.str, request: PermissionAuthorizationRequest}) as S.Codec<{ productId: string; request: PermissionAuthorizationRequest }>})); @@ -282,6 +288,9 @@ export interface AuthPresenter { * * The platform provides a way to get a JSON-RPC connection for a given chain. * The server runtime manages the chainHead v1 state machine on top of this. + * Host-spec N.6 requires products to access chains through host-mediated + * providers: + * */ export interface ChainProvider { /** diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index e77afbdd..af38a348 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -12,9 +12,6 @@ //! (`Unsupported`, `HostFailure`, ...). This avoids leaking json-rpc plumbing //! into the public API. -// Temporary for this stack layer: runtime wiring lands in the next child PR. -#![allow(dead_code)] - use core::pin::Pin; use core::task::{Context, Poll}; use std::collections::HashMap; diff --git a/rust/crates/truapi-server/src/host_rpc_client.rs b/rust/crates/truapi-server/src/host_rpc_client.rs index ec959b14..0d46e586 100644 --- a/rust/crates/truapi-server/src/host_rpc_client.rs +++ b/rust/crates/truapi-server/src/host_rpc_client.rs @@ -5,9 +5,6 @@ //! [`subxt_rpcs::RpcClientT`]: request correlation, subscription routing, and //! best-effort unsubscribe on subscription drop. -// Temporary for this stack layer: runtime wiring lands in the next child PR. -#![allow(dead_code)] - use core::fmt; use core::mem; use core::pin::Pin; From b804d043f8bbb06a05a980c7483f261de17a2a2b Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 08:05:41 +0200 Subject: [PATCH 32/56] fix(server): enforce runtime permissions Gate identity disclosure and remote submit paths through product-scoped permission state, and cancel pending lifecycle work on abort/dispose. --- .../tests/golden/host-callbacks.ts | 165 +++-------- rust/crates/truapi-platform/src/lib.rs | 15 +- rust/crates/truapi-server/src/core.rs | 5 + rust/crates/truapi-server/src/dispatcher.rs | 5 + rust/crates/truapi-server/src/host_core.rs | 72 ++++- .../src/host_logic/permissions.rs | 54 ++++ rust/crates/truapi-server/src/runtime.rs | 269 +++++++++++++++++- .../truapi-server/src/runtime/pairing_host.rs | 34 ++- .../truapi-server/src/runtime/sso_pairing.rs | 54 ++++ .../src/runtime/statement_store.rs | 35 ++- rust/crates/truapi-server/src/subscription.rs | 68 +++++ rust/crates/truapi-server/src/test_support.rs | 50 +++- 12 files changed, 685 insertions(+), 141 deletions(-) diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 417d5bcd..4321e226 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -90,10 +90,7 @@ export type CoreStorageKey = /** * Persisted authorization for one product-scoped permission request. */ - | { - tag: "PermissionAuthorization"; - value: { productId: string; request: PermissionAuthorizationRequest }; - }; + | { tag: "PermissionAuthorization"; value: { productId: string; request: PermissionAuthorizationRequest } }; /** * Review shown before a transaction-creation request is sent to the paired wallet. @@ -108,6 +105,16 @@ export type CreateTransactionReview = */ | { tag: "LegacyAccount"; value: LegacyAccountTxPayload }; +/** + * Review shown before a product learns the user's primary identity. + */ +export interface IdentityDisclosureReview { + /** + * Product currently handling the request. + */ + productId: string; +} + /** * Permission request whose authorization status can be inspected or updated * by host administration UI. @@ -120,7 +127,11 @@ export type PermissionAuthorizationRequest = /** * Remote/product-scoped permission such as chain submit or HTTP access. */ - | { tag: "Remote"; value: RemotePermissionRequest }; + | { tag: "Remote"; value: RemotePermissionRequest } + /** + * Product-scoped permission to disclose the user's primary identity. + */ + | { tag: "IdentityDisclosure"; value?: undefined }; /** * Authorization status for a permission request. @@ -128,10 +139,7 @@ export type PermissionAuthorizationRequest = * `NotDetermined` means the core has no persisted answer and will prompt the * host the next time the product requests this permission. */ -export type PermissionAuthorizationStatus = - | "NotDetermined" - | "Denied" - | "Authorized"; +export type PermissionAuthorizationStatus = "NotDetermined" | "Denied" | "Authorized"; /** * Review shown before a preimage is submitted. @@ -215,6 +223,10 @@ export type UserConfirmationReview = * Allow a product to request another product account alias. */ | { tag: "AccountAlias"; value: AccountAliasReview } + /** + * Allow a product to learn the user's primary identity. + */ + | { tag: "IdentityDisclosure"; value: IdentityDisclosureReview } /** * Allocate resources for the requesting product. */ @@ -227,13 +239,7 @@ export type UserConfirmationReview = /** * Review shown before a product asks to alias another product account. */ -export const AccountAliasReview: S.Codec = S.lazy( - (): S.Codec => - S.Struct({ - requestingProductId: S.str, - targetProductId: S.str, - }) as S.Codec, -); +export const AccountAliasReview: S.Codec = S.lazy((): S.Codec => S.Struct({requestingProductId: S.str, targetProductId: S.str}) as S.Codec); /** * Core-owned host-private storage slots. Products never address these slots; @@ -242,44 +248,23 @@ export const AccountAliasReview: S.Codec = S.lazy( * Storage is host-local; `storage.md` records the current status quo: * */ -export const CoreStorageKey: S.Codec = S.lazy( - (): S.Codec => - S.TaggedUnion({ - AuthSession: S._void, - PairingDeviceIdentity: S._void, - PermissionAuthorization: S.Struct({ - productId: S.str, - request: PermissionAuthorizationRequest, - }) as S.Codec<{ - productId: string; - request: PermissionAuthorizationRequest; - }>, - }), -); +export const CoreStorageKey: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({AuthSession: S._void, PairingDeviceIdentity: S._void, PermissionAuthorization: S.Struct({productId: S.str, request: PermissionAuthorizationRequest}) as S.Codec<{ productId: string; request: PermissionAuthorizationRequest }>})); /** * Review shown before a transaction-creation request is sent to the paired wallet. */ -export const CreateTransactionReview: S.Codec = S.lazy( - (): S.Codec => - S.TaggedUnion({ - Product: ProductAccountTxPayload, - LegacyAccount: LegacyAccountTxPayload, - }), -); +export const CreateTransactionReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Product: ProductAccountTxPayload, LegacyAccount: LegacyAccountTxPayload})); + +/** + * Review shown before a product learns the user's primary identity. + */ +export const IdentityDisclosureReview: S.Codec = S.lazy((): S.Codec => S.Struct({productId: S.str}) as S.Codec); /** * Permission request whose authorization status can be inspected or updated * by host administration UI. */ -export const PermissionAuthorizationRequest: S.Codec = - S.lazy( - (): S.Codec => - S.TaggedUnion({ - Device: HostDevicePermissionRequest, - Remote: RemotePermissionRequest, - }), - ); +export const PermissionAuthorizationRequest: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Device: HostDevicePermissionRequest, Remote: RemotePermissionRequest, IdentityDisclosure: S._void})); /** * Authorization status for a permission request. @@ -287,56 +272,27 @@ export const PermissionAuthorizationRequest: S.Codec = - S.lazy( - (): S.Codec => - S.Status("NotDetermined", "Denied", "Authorized"), - ); +export const PermissionAuthorizationStatus: S.Codec = S.lazy((): S.Codec => S.Status("NotDetermined", "Denied", "Authorized")); /** * Review shown before a preimage is submitted. */ -export const PreimageSubmitReview: S.Codec = S.lazy( - (): S.Codec => - S.Struct({ size: S.u64 }) as S.Codec, -); +export const PreimageSubmitReview: S.Codec = S.lazy((): S.Codec => S.Struct({size: S.u64}) as S.Codec); /** * Review shown before a sign-payload request is sent to the paired wallet. */ -export const SignPayloadReview: S.Codec = S.lazy( - (): S.Codec => - S.TaggedUnion({ - Product: HostSignPayloadRequest, - LegacyAccount: HostSignPayloadWithLegacyAccountRequest, - }), -); +export const SignPayloadReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Product: HostSignPayloadRequest, LegacyAccount: HostSignPayloadWithLegacyAccountRequest})); /** * Review shown before a sign-raw request is sent to the paired wallet. */ -export const SignRawReview: S.Codec = S.lazy( - (): S.Codec => - S.TaggedUnion({ - Product: HostSignRawRequest, - LegacyAccount: HostSignRawWithLegacyAccountRequest, - }), -); +export const SignRawReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Product: HostSignRawRequest, LegacyAccount: HostSignRawWithLegacyAccountRequest})); /** * Review shown before a user-confirmed core action continues. */ -export const UserConfirmationReview: S.Codec = S.lazy( - (): S.Codec => - S.TaggedUnion({ - SignPayload: SignPayloadReview, - SignRaw: SignRawReview, - CreateTransaction: CreateTransactionReview, - AccountAlias: AccountAliasReview, - ResourceAllocation: HostRequestResourceAllocationRequest, - PreimageSubmit: PreimageSubmitReview, - }), -); +export const UserConfirmationReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({SignPayload: SignPayloadReview, SignRaw: SignRawReview, CreateTransaction: CreateTransactionReview, AccountAlias: AccountAliasReview, IdentityDisclosure: IdentityDisclosureReview, ResourceAllocation: HostRequestResourceAllocationRequest, PreimageSubmit: PreimageSubmitReview})); /** * Host auth UI driven by core-owned `AuthState` transitions. @@ -383,27 +339,20 @@ export interface CoreAdmin { /** * Read a stored permission authorization status without prompting. */ - getPermissionAuthorizationStatus( - request: PermissionAuthorizationRequest, - ): Promise; + getPermissionAuthorizationStatus(request: PermissionAuthorizationRequest): Promise; /** * Read stored permission authorization statuses without prompting. * * Results are returned in the same order as `requests`. */ - getPermissionAuthorizationStatuses( - requests: Array, - ): Promise>; + getPermissionAuthorizationStatuses(requests: Array): Promise>; /** * Update a stored permission authorization status. `NotDetermined` clears * the stored value so the next product request prompts again. */ - setPermissionAuthorizationStatus( - request: PermissionAuthorizationRequest, - status: PermissionAuthorizationStatus, - ): Promise; + setPermissionAuthorizationStatus(request: PermissionAuthorizationRequest, status: PermissionAuthorizationStatus): Promise; } /** @@ -434,9 +383,7 @@ export interface Features { /** * Report whether the requested feature is supported. */ - featureSupported( - request: HostFeatureSupportedRequest, - ): Promise; + featureSupported(request: HostFeatureSupportedRequest): Promise; } /** @@ -482,9 +429,7 @@ export interface Notifications { * Schedule or immediately display the given notification and return the * host-assigned id. */ - pushNotification( - notification: HostPushNotificationRequest, - ): Promise; + pushNotification(notification: HostPushNotificationRequest): Promise; /** * Cancel a notification by id. Idempotent: cancelling an already-fired or @@ -520,16 +465,12 @@ export interface Permissions { /** * Prompt the user for a device-level permission. */ - devicePermission( - request: HostDevicePermissionRequest, - ): Promise; + devicePermission(request: HostDevicePermissionRequest): Promise; /** * Prompt the user for a remote (product-scoped) permission bundle. */ - remotePermission( - request: RemotePermissionRequest, - ): Promise; + remotePermission(request: RemotePermissionRequest): Promise; } /** @@ -545,9 +486,7 @@ export interface PreimageHost { /** * Emits current value/miss immediately, then future updates. */ - lookupPreimage( - key: Uint8Array, - ): AsyncIterable>; + lookupPreimage(key: Uint8Array): AsyncIterable>; } /** @@ -584,11 +523,11 @@ export interface ThemeHost { } /** - * Local user confirmation UI for session-channel operations. + * Local user confirmation UI for sensitive core-owned operations. */ export interface UserConfirmation { /** - * Confirm a reviewed action before the core asks the SSO peer. + * Confirm a reviewed action before the core continues. */ confirmUserAction(review: UserConfirmationReview): Promise; } @@ -596,16 +535,4 @@ export interface UserConfirmation { /** * 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 extends Navigation, Notifications, Permissions, Features, ProductStorage, CoreStorage, ChainProvider, AuthPresenter, UserConfirmation, ThemeHost, PreimageHost {} diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 392b6df2..940d2ee2 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -315,6 +315,8 @@ pub enum PermissionAuthorizationRequest { Device(HostDevicePermissionRequest), /// Remote/product-scoped permission such as chain submit or HTTP access. Remote(RemotePermissionRequest), + /// Product-scoped permission to disclose the user's primary identity. + IdentityDisclosure, } /// Authorization status for a permission request. @@ -542,6 +544,13 @@ pub struct AccountAliasReview { pub target_product_id: String, } +/// Review shown before a product learns the user's primary identity. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct IdentityDisclosureReview { + /// Product currently handling the request. + pub product_id: String, +} + /// Review shown before a preimage is submitted. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct PreimageSubmitReview { @@ -561,16 +570,18 @@ pub enum UserConfirmationReview { CreateTransaction(CreateTransactionReview), /// Allow a product to request another product account alias. AccountAlias(AccountAliasReview), + /// Allow a product to learn the user's primary identity. + IdentityDisclosure(IdentityDisclosureReview), /// Allocate resources for the requesting product. ResourceAllocation(HostRequestResourceAllocationRequest), /// Submit a preimage to the host-selected backend. PreimageSubmit(PreimageSubmitReview), } -/// Local user confirmation UI for session-channel operations. +/// Local user confirmation UI for sensitive core-owned operations. #[async_trait] pub trait UserConfirmation: Send + Sync { - /// Confirm a reviewed action before the core asks the SSO peer. + /// Confirm a reviewed action before the core continues. async fn confirm_user_action( &self, review: UserConfirmationReview, diff --git a/rust/crates/truapi-server/src/core.rs b/rust/crates/truapi-server/src/core.rs index 85ff7ea3..d3e7a437 100644 --- a/rust/crates/truapi-server/src/core.rs +++ b/rust/crates/truapi-server/src/core.rs @@ -123,6 +123,11 @@ impl TrUApiCore { pub async fn dispatch(&self, message: ProtocolMessage, transport: Arc) { self.dispatcher.dispatch(message, transport).await; } + + /// Cancel all active and pending subscriptions owned by this core. + pub fn cancel_subscriptions(&self) { + self.dispatcher.cancel_subscriptions(); + } } /// Single-slot transport that captures the next response the dispatcher diff --git a/rust/crates/truapi-server/src/dispatcher.rs b/rust/crates/truapi-server/src/dispatcher.rs index da27c8c0..e0c3db60 100644 --- a/rust/crates/truapi-server/src/dispatcher.rs +++ b/rust/crates/truapi-server/src/dispatcher.rs @@ -162,6 +162,11 @@ impl Dispatcher { // Unknown discriminant: drop. Response / receive / interrupt frames are // handled by the client side and never registered here. } + + /// Cancel every subscription currently owned by this dispatcher. + pub fn cancel_subscriptions(&self) { + self.subscriptions.cancel_all(); + } } #[cfg(test)] diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 37e5ce7e..8478467c 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -438,6 +438,9 @@ impl ProductRuntime { .lock() .expect("host core in-flight dispatch mutex poisoned") .remove(&dispatch_id); + if self.disposed.load(Ordering::Acquire) { + self.core.cancel_subscriptions(); + } Ok(()) } @@ -482,9 +485,8 @@ impl ProductRuntime { /// Dispose this host core. Idempotent. /// - /// Disposal suppresses future outgoing frames and aborts in-flight dispatch - /// futures. Adapter-specific resource cleanup remains the adapter's - /// responsibility. + /// Disposal suppresses future outgoing frames, aborts in-flight dispatch + /// futures, and cancels active subscriptions. #[instrument(skip_all, fields(runtime.method = "product_runtime.dispose"))] pub fn dispose(&self) { if self.disposed.swap(true, Ordering::AcqRel) { @@ -498,6 +500,7 @@ impl ProductRuntime { { handle.abort(); } + self.core.cancel_subscriptions(); } } @@ -521,3 +524,66 @@ impl Transport for SinkTransport { Box::new(|| {}) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::frame::{Payload, ProtocolMessage, subscription_ids}; + use crate::test_support::{StubPlatform, runtime_config, test_spawner}; + use parity_scale_codec::Encode; + use std::sync::atomic::Ordering; + + #[derive(Default)] + struct RecordingSink { + frames: Mutex>>, + } + + impl FrameSink for RecordingSink { + fn emit_frame(&self, frame: Vec) { + self.frames + .lock() + .expect("recording sink mutex poisoned") + .push(frame); + } + } + + #[test] + fn dispose_cancels_active_subscriptions() { + let theme_stream_dropped = Arc::new(AtomicBool::new(false)); + let platform = Arc::new(StubPlatform { + theme_stream_pending: true, + theme_stream_dropped: theme_stream_dropped.clone(), + ..Default::default() + }); + let sink = Arc::new(RecordingSink::default()); + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + platform, + host_config, + product, + test_spawner(), + sink, + ); + + let ids = subscription_ids("theme_subscribe").expect("known subscription"); + let frame = ProtocolMessage { + request_id: "theme:1".to_string(), + payload: Payload { + id: ids.start_id, + value: Vec::new(), + }, + }; + futures::executor::block_on(runtime.receive_frame(frame.encode())).unwrap(); + + runtime.dispose(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while !theme_stream_dropped.load(Ordering::SeqCst) { + assert!( + std::time::Instant::now() < deadline, + "dispose did not drop the active theme subscription stream" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } +} diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs index b1189689..d1414096 100644 --- a/rust/crates/truapi-server/src/host_logic/permissions.rs +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -7,6 +7,9 @@ //! The cache layer is shared but keys are typed so a device grant cannot //! authorize a remote operation by accident. Keys are also scoped by product id //! so one product's authorization never grants another product's request. +//! Identity disclosure is also represented as a product-scoped authorization, +//! but the prompt itself is handled by the account runtime because it uses the +//! richer user-confirmation surface rather than the device/remote callbacks. use parity_scale_codec::{Decode, Encode}; @@ -104,6 +107,13 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a self.peek_device(permission).await } PermissionAuthorizationRequest::Remote(request) => self.peek_remote(request).await, + PermissionAuthorizationRequest::IdentityDisclosure => { + authorization_status( + self.storage, + identity_disclosure_core_storage_key(self.product_id), + ) + .await + } } } @@ -136,6 +146,9 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a PermissionAuthorizationRequest::Remote(request) => { remote_core_storage_key(self.product_id, request) } + PermissionAuthorizationRequest::IdentityDisclosure => { + identity_disclosure_core_storage_key(self.product_id) + } }; set_authorization_status(self.storage, key, status).await } @@ -249,6 +262,13 @@ fn remote_core_storage_key(product_id: &str, request: &RemotePermissionRequest) } } +fn identity_disclosure_core_storage_key(product_id: &str) -> CoreStorageKey { + CoreStorageKey::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::IdentityDisclosure, + } +} + fn canonical_remote_request(request: &RemotePermissionRequest) -> RemotePermissionRequest { let permission = match &request.permission { RemotePermission::Remote { domains } => { @@ -366,9 +386,14 @@ mod tests { permission: RemotePermission::ChainSubmit, }, ); + let identity = identity_disclosure_core_storage_key("product.dot"); + let other_product_identity = identity_disclosure_core_storage_key("other.dot"); assert_ne!(camera, other_product); assert_ne!(camera, remote); + assert_ne!(camera, identity); + assert_ne!(remote, identity); + assert_ne!(identity, other_product_identity); } #[test] @@ -585,6 +610,35 @@ mod tests { ); } + #[test] + fn identity_disclosure_authorization_round_trips() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + let request = PermissionAuthorizationRequest::IdentityDisclosure; + + assert_eq!( + futures::executor::block_on(service.authorization_status(&request)).unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + + futures::executor::block_on( + service.set_authorization_status(&request, PermissionAuthorizationStatus::Authorized), + ) + .unwrap(); + assert_eq!( + futures::executor::block_on(service.authorization_status(&request)).unwrap(), + PermissionAuthorizationStatus::Authorized + ); + + let other_product_service = PermissionsService::new(&storage, &prompt, "other.dot"); + assert_eq!( + futures::executor::block_on(other_product_service.authorization_status(&request)) + .unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + } + /// Prompt callback that always errors, to exercise the transient-failure /// path (fail closed for the current call, but do not persist the error). struct FailingPrompt; diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index be7f2d7a..185d9bb6 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -132,11 +132,14 @@ use truapi::{CallContext, CallError, Subscription}; #[cfg(test)] use truapi_platform::Platform; use truapi_platform::{ - AccountAliasReview, CreateTransactionReview, PermissionAuthorizationRequest, - PermissionAuthorizationStatus, PreimageSubmitReview, ProductContext, SessionUiInfo, - SignPayloadReview, SignRawReview, UserConfirmationReview, normalize_product_identifier, + AccountAliasReview, CreateTransactionReview, IdentityDisclosureReview, + PermissionAuthorizationRequest, PermissionAuthorizationStatus, PreimageSubmitReview, + ProductContext, SessionUiInfo, SignPayloadReview, SignRawReview, UserConfirmationReview, + normalize_product_identifier, }; +pub(super) const REMOTE_PERMISSION_DENIED_REASON: &str = "Permission denied"; + /// Product-scoped adapter that exposes a long-lived host runtime through the /// `truapi::api::*` trait set the generated dispatcher routes to. pub struct ProductRuntimeHost { @@ -330,8 +333,11 @@ impl ProductRuntimeHost { service.set_authorization_status(&request, status).await } - #[instrument(skip_all, fields(runtime.method = "permissions.chain_submit_authorization"))] - async fn chain_submit_authorization(&self) -> Result { + #[instrument(skip_all, fields(runtime.method = "permissions.remote_authorization"))] + async fn remote_permission_authorization( + &self, + permission: v01::RemotePermission, + ) -> Result { let product_id = self.product_id(); let service = PermissionsService::new( self.services.platform.as_ref(), @@ -339,15 +345,17 @@ impl ProductRuntimeHost { &product_id, ); service - .check_or_prompt_remote(v01::RemotePermissionRequest { - permission: v01::RemotePermission::ChainSubmit, - }) + .check_or_prompt_remote(v01::RemotePermissionRequest { permission }) .await .map_err(|err| format!("permission storage failed: {err:?}")) } - async fn require_chain_submit(&self, denied_error: E) -> Result<(), CallError> { - match self.chain_submit_authorization().await { + pub(super) async fn require_remote_permission( + &self, + permission: v01::RemotePermission, + denied_error: E, + ) -> Result<(), CallError> { + match self.remote_permission_authorization(permission).await { Ok(PermissionAuthorizationStatus::Authorized) => Ok(()), Ok( PermissionAuthorizationStatus::Denied @@ -357,6 +365,52 @@ impl ProductRuntimeHost { } } + async fn require_chain_submit(&self, denied_error: E) -> Result<(), CallError> { + self.require_remote_permission(v01::RemotePermission::ChainSubmit, denied_error) + .await + } + + #[instrument(skip_all, fields(runtime.method = "permissions.identity_disclosure_authorization"))] + async fn identity_disclosure_authorization( + &self, + ) -> Result { + let product_id = self.product_id(); + let request = PermissionAuthorizationRequest::IdentityDisclosure; + let service = PermissionsService::new( + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, + ); + let cached = service + .authorization_status(&request) + .await + .map_err(|err| format!("permission storage failed: {err:?}"))?; + if cached != PermissionAuthorizationStatus::NotDetermined { + return Ok(cached); + } + + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::IdentityDisclosure( + IdentityDisclosureReview { + product_id: product_id.clone(), + }, + )) + .await + .map_err(|err| format!("identity disclosure confirmation failed: {err:?}"))?; + let status = if confirmed { + PermissionAuthorizationStatus::Authorized + } else { + PermissionAuthorizationStatus::Denied + }; + service + .set_authorization_status(&request, status) + .await + .map_err(|err| format!("permission storage failed: {err:?}"))?; + Ok(status) + } + fn validate_legacy_address_signer( &self, session: &AuthoritySession, @@ -749,6 +803,19 @@ impl Account for ProductRuntimeHost { })) })?; + match self.identity_disclosure_authorization().await { + Ok(PermissionAuthorizationStatus::Authorized) => {} + Ok( + PermissionAuthorizationStatus::Denied + | PermissionAuthorizationStatus::NotDetermined, + ) => { + return Err(CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied, + ))); + } + Err(reason) => return Err(CallError::HostFailure { reason }), + } + Ok(HostGetUserIdResponse::V1(v01::HostGetUserIdResponse { primary_username, })) @@ -1341,6 +1408,10 @@ impl Chain for ProductRuntimeHost { CallError, > { let RemoteChainTransactionBroadcastRequest::V1(inner) = request; + self.require_chain_submit(RemoteChainTransactionBroadcastError::V1(v01::GenericError { + reason: REMOTE_PERMISSION_DENIED_REASON.to_string(), + })) + .await?; self.services .chain .remote_chain_transaction_broadcast(inner) @@ -1553,6 +1624,13 @@ impl Preimage for ProductRuntimeHost { request: RemotePreimageSubmitRequest, ) -> Result> { let RemotePreimageSubmitRequest::V1(value) = request; + self.require_remote_permission( + v01::RemotePermission::PreimageSubmit, + RemotePreimageSubmitError::V1(v01::PreimageSubmitError::Unknown { + reason: REMOTE_PERMISSION_DENIED_REASON.to_string(), + }), + ) + .await?; let confirmed = self .services .platform @@ -1655,8 +1733,9 @@ mod tests { use super::*; use crate::host_logic::sso::messages::{RemoteMessageData, v1}; use crate::test_support::*; + use std::sync::atomic::Ordering; use std::sync::Mutex; - use truapi_platform::{AuthState, CoreStorageKey}; + use truapi_platform::{AuthState, CoreStorageKey, PermissionAuthorizationRequest}; fn wait_until(mut condition: impl FnMut() -> bool, message: &str) { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); @@ -2122,13 +2201,124 @@ mod tests { #[test] fn get_user_id_returns_primary_username() { - let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let platform = Arc::new(StubPlatform { + identity_disclosure_confirmed: true, + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat( + platform.clone(), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let response = + futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)).unwrap(); + let HostGetUserIdResponse::V1(inner) = response; + assert_eq!(inner.primary_username, "Alice Smith"); + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn get_user_id_caches_identity_disclosure_grant() { + let platform = Arc::new(StubPlatform { + identity_disclosure_confirmed: true, + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat( + platform.clone(), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + + futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)).unwrap(); + futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)).unwrap(); + + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 1); + let status = futures::executor::block_on( + host.permission_authorization_status(PermissionAuthorizationRequest::IdentityDisclosure), + ) + .unwrap(); + assert_eq!(status, PermissionAuthorizationStatus::Authorized); + } + + #[test] + fn get_user_id_caches_identity_disclosure_denial() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new_compat( + platform.clone(), + test_spawner(), + ); host.test_session_state().set_session(session_info()); let cx = CallContext::new(); + + let first = + futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); + let second = + futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); + + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 1); + assert!(matches!( + first, + CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied + )) + )); + assert!(matches!( + second, + CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied + )) + )); + let status = futures::executor::block_on( + host.permission_authorization_status(PermissionAuthorizationRequest::IdentityDisclosure), + ) + .unwrap(); + assert_eq!(status, PermissionAuthorizationStatus::Denied); + } + + #[test] + fn get_user_id_respects_pre_authorized_identity_disclosure() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new_compat( + Arc::new(StubPlatform { + identity_disclosure_confirmed: true, + ..Default::default() + }), + test_spawner(), + ); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + futures::executor::block_on(host.set_permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + PermissionAuthorizationStatus::Authorized, + )) + .unwrap(); + let response = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)).unwrap(); let HostGetUserIdResponse::V1(inner) = response; assert_eq!(inner.primary_username, "Alice Smith"); + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn get_user_id_requires_identity_disclosure_confirmation() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let err = + futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied + )) + )); } #[test] @@ -2212,6 +2402,61 @@ mod tests { assert_eq!(response, RemotePreimageSubmitResponse::V1(vec![1, 2, 3])); } + #[test] + fn preimage_submit_requires_remote_permission_before_backend_call() { + let preimage_submits = Arc::new(Mutex::new(Vec::new())); + let host = ProductRuntimeHost::new_compat( + Arc::new(StubPlatform { + remote_permission_denied: true, + preimage_submits: preimage_submits.clone(), + ..Default::default() + }), + test_spawner(), + ); + let cx = CallContext::new(); + let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); + let err = + futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap_err(); + match err { + CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { reason }, + )) => assert_eq!(reason, REMOTE_PERMISSION_DENIED_REASON), + other => panic!("expected preimage permission denial, got {other:?}"), + } + assert!( + preimage_submits + .lock() + .expect("preimage submit list mutex poisoned") + .is_empty() + ); + } + + #[test] + fn chain_broadcast_requires_remote_permission_before_backend_call() { + let platform = Arc::new(StubPlatform { + remote_permission_denied: true, + ..Default::default() + }); + let host = ProductRuntimeHost::new(platform.clone(), runtime_config("myapp.dot"), test_spawner()); + let cx = CallContext::new(); + let request = RemoteChainTransactionBroadcastRequest::V1( + v01::RemoteChainTransactionBroadcastRequest { + genesis_hash: vec![0; 32], + transaction: vec![1, 2, 3], + }, + ); + let err = + futures::executor::block_on(Chain::broadcast_transaction(&host, &cx, request)) + .unwrap_err(); + match err { + CallError::Domain(RemoteChainTransactionBroadcastError::V1(v01::GenericError { + reason, + })) => assert_eq!(reason, REMOTE_PERMISSION_DENIED_REASON), + other => panic!("expected chain broadcast permission denial, got {other:?}"), + } + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } + #[test] fn preimage_lookup_subscribe_maps_platform_values() { let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index 65969b14..666c14a6 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -85,6 +85,33 @@ struct LoginInFlight { waiters: Vec>>, } +struct LoginInFlightOwner<'a> { + host: &'a PairingHost, + active: bool, +} + +impl<'a> LoginInFlightOwner<'a> { + fn new(host: &'a PairingHost) -> Self { + Self { host, active: true } + } + + fn finish(&mut self, result: Result<(), String>) { + if self.active { + self.active = false; + self.host.finish_login_in_flight(result); + } + } +} + +impl Drop for LoginInFlightOwner<'_> { + fn drop(&mut self) { + if self.active { + self.host + .finish_login_in_flight(Err("login request aborted".to_string())); + } + } +} + /// Remote account authority for a pairing host. pub(crate) struct PairingHost { pub(super) platform: Arc, @@ -259,16 +286,17 @@ impl PairingHost { } } + let mut login_owner = LoginInFlightOwner::new(self); let outcome = match SsoPairingFlow::new(self).request_session().await { Ok(outcome) => outcome, Err(err) => { - self.finish_login_in_flight(Err(login_error_reason(&err))); + login_owner.finish(Err(login_error_reason(&err))); return Err(err); } }; match outcome { SsoPairingOutcome::Cancelled => { - self.finish_login_in_flight(Ok(())); + login_owner.finish(Ok(())); if self.session_state.current().is_some() { Ok(HostRequestLoginResponse::V1( v01::HostRequestLoginResponse::AlreadyConnected, @@ -281,7 +309,7 @@ impl PairingHost { } SsoPairingOutcome::Success(session) => { self.set_connected_session(*session); - self.finish_login_in_flight(Ok(())); + login_owner.finish(Ok(())); Ok(HostRequestLoginResponse::V1( v01::HostRequestLoginResponse::Success, )) diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index 8b73b709..66b34721 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -754,6 +754,60 @@ mod tests { if reason.contains("identity storage unavailable"))); } + #[test] + fn dropped_request_login_clears_single_flight_for_next_attempt() { + use std::future::Future; + use std::task::{Context, Poll}; + + let platform = Arc::new(StubPlatform { + chain_connect_pending: true, + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let cx = CallContext::new(); + let mut first_login = Box::pin(host.request_login(&cx, request.clone())); + let waker = futures::task::noop_waker(); + let mut task_cx = Context::from_waker(&waker); + + match first_login.as_mut().poll(&mut task_cx) { + Poll::Pending => {} + Poll::Ready(result) => panic!("first login should be pending, got {result:?}"), + } + assert!( + platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .iter() + .any(|state| matches!(state, AuthState::Pairing { .. })), + "first login did not enter pairing state" + ); + + drop(first_login); + + assert!( + platform + .pending_connect_dropped + .load(std::sync::atomic::Ordering::SeqCst), + "dropping the login future should drop the pending statement-store connect" + ); + + cancel_on_pairing(&platform, pairing_host); + let second_cx = CallContext::new(); + let mut second_login = Box::pin(host.request_login(&second_cx, request)); + let second = match second_login.as_mut().poll(&mut task_cx) { + Poll::Ready(result) => result.expect("second login should complete after cancellation"), + Poll::Pending => panic!("second login stayed pending behind stale single-flight state"), + }; + assert_eq!( + second, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + } + #[test] fn request_login_does_not_restore_persisted_session_before_pairing() { let stored = session_info(); diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index 079c8c82..1baa282c 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -4,7 +4,7 @@ use core::pin::Pin; use core::task::{Context, Poll}; -use super::ProductRuntimeHost; +use super::{ProductRuntimeHost, REMOTE_PERMISSION_DENIED_REASON}; use super::statement_store_rpc::{self, StatementStoreRpc}; use crate::host_logic::statement_store::{ MAX_MATCH_ALL_TOPICS, MAX_MATCH_ANY_TOPICS, TopicFilterKind, decode_signed_statement, @@ -128,6 +128,13 @@ impl StatementStore for ProductRuntimeHost { request: RemoteStatementStoreSubmitRequest, ) -> Result<(), CallError> { let RemoteStatementStoreSubmitRequest::V1(statement) = request; + self.require_remote_permission( + v01::RemotePermission::StatementSubmit, + RemoteStatementStoreSubmitError::V1(v01::GenericError { + reason: REMOTE_PERMISSION_DENIED_REASON.to_string(), + }), + ) + .await?; let statement = signed_statement_to_scale(statement).map_err(|reason| { CallError::Domain(RemoteStatementStoreSubmitError::V1(v01::GenericError { reason, @@ -424,6 +431,32 @@ mod tests { ); } + #[test] + fn statement_store_submit_requires_remote_permission_before_rpc() { + let platform = Arc::new(StubPlatform { + remote_permission_denied: true, + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("submit-1".to_string()); + let request = RemoteStatementStoreSubmitRequest::V1(signed_statement([7; 32])); + + let err = + futures::executor::block_on(StatementStore::submit(&host, &cx, request)).unwrap_err(); + + match err { + CallError::Domain(RemoteStatementStoreSubmitError::V1(v01::GenericError { + reason, + })) => assert_eq!(reason, REMOTE_PERMISSION_DENIED_REASON), + other => panic!("expected statement-store permission denial, got {other:?}"), + } + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } + #[test] fn statement_store_subscribe_maps_signed_pages() { let signed = crate::host_logic::statement_store::signed_statement_to_scale( diff --git a/rust/crates/truapi-server/src/subscription.rs b/rust/crates/truapi-server/src/subscription.rs index 8b24fd98..b6766d16 100644 --- a/rust/crates/truapi-server/src/subscription.rs +++ b/rust/crates/truapi-server/src/subscription.rs @@ -284,6 +284,25 @@ impl SubscriptionManager { None => {} } } + + /// Cancel and forget every pending or live subscription owned by this + /// manager. Used when the product runtime is disposed, where no further + /// frames should be emitted and platform resources must be released. + pub fn cancel_all(&self) { + let cancellations = { + let mut active = self.active.lock().unwrap(); + active + .drain() + .filter_map(|(_, slot)| match slot { + Slot::Pending { .. } => None, + Slot::Live { cancel, .. } => Some(cancel), + }) + .collect::>() + }; + for cancel in cancellations { + cancel(); + } + } } #[cfg(all(test, not(target_arch = "wasm32")))] @@ -291,6 +310,7 @@ mod tests { use super::*; use futures::stream; use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::{Context, Poll}; /// Transport that records every frame and notifies waiters when it /// reaches a target count. Used to wait for the subscription's @@ -347,6 +367,27 @@ mod tests { )) } + struct PendingDropStream { + dropped: Arc, + } + + impl Drop for PendingDropStream { + fn drop(&mut self) { + self.dropped.store(true, Ordering::SeqCst); + } + } + + impl futures::Stream for PendingDropStream { + type Item = SubscriptionOutput; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Pending + } + } + /// Register a never-ending stream then immediately stop it. The /// stream's first poll must observe cancellation and exit without /// having pushed any frame. @@ -492,4 +533,31 @@ mod tests { "no leaked frames from the superseded stream" ); } + + #[test] + fn cancel_all_stops_live_subscription_streams() { + let transport_typed = Arc::new(RecordingTransport::new()); + let transport_dyn: Arc = transport_typed.clone(); + let manager = SubscriptionManager::new(thread_per_subscription_spawner()); + let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stream: SubscriptionStream = Box::pin(PendingDropStream { + dropped: dropped.clone(), + }); + + manager.register("p:1".to_string(), 99, 98, stream, transport_dyn); + manager.cancel_all(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while !dropped.load(Ordering::SeqCst) { + assert!( + std::time::Instant::now() < deadline, + "cancel_all did not drop the live subscription stream" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!( + transport_typed.sent().is_empty(), + "runtime disposal cancellation must not emit an interrupt frame" + ); + } } diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 9d258ee1..2b5586f0 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -1,8 +1,9 @@ //! Shared fixtures for the runtime test modules: a stub platform, a //! recording json-rpc connection, and SSO statement/frame builders. -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; #[cfg(not(target_arch = "wasm32"))] use std::time::Duration; @@ -17,6 +18,7 @@ use crate::subscription::thread_per_subscription_spawner; use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Nonce}; use futures::stream::{self, BoxStream}; +use futures::Stream; use hkdf::Hkdf; use p256::PublicKey as P256PublicKey; use p256::SecretKey as P256SecretKey; @@ -65,6 +67,9 @@ pub(crate) struct StubPlatform { pub(crate) remote_permission_denied: bool, pub(crate) account_alias_confirmed: bool, pub(crate) account_alias_error: Option<&'static str>, + pub(crate) identity_disclosure_confirmed: bool, + pub(crate) identity_disclosure_error: Option<&'static str>, + pub(crate) identity_disclosure_calls: Arc, pub(crate) sign_payload_confirmed: bool, pub(crate) sign_payload_error: Option<&'static str>, pub(crate) sign_raw_confirmed: bool, @@ -85,6 +90,10 @@ pub(crate) struct StubPlatform { /// Set when a `chain_connect_pending` connect future is dropped, which is /// how a dropped login flow manifests on the stub. pub(crate) pending_connect_dropped: Arc, + /// When true, `subscribe_theme` returns a never-ending stream. + pub(crate) theme_stream_pending: bool, + /// Set when the pending theme stream is dropped. + pub(crate) theme_stream_dropped: Arc, pub(crate) pairing_success_response: bool, /// Deliver the pairing success statement only through a snapshot /// query page; the live subscription stays silent. @@ -96,6 +105,7 @@ pub(crate) struct StubPlatform { pub(crate) rpc_responses: Vec, pub(crate) chain_connect_error: Option<&'static str>, pub(crate) chain_connect_pending: bool, + pub(crate) preimage_submits: Arc>>>, pub(crate) local_storage: Arc>>>, /// When set, product/core storage reads fail with this reason. pub(crate) local_storage_error: Option<&'static str>, @@ -109,6 +119,27 @@ impl Drop for DropFlagGuard { } } +struct PendingThemeStream { + dropped: Arc, +} + +impl Drop for PendingThemeStream { + fn drop(&mut self) { + self.dropped.store(true, Ordering::SeqCst); + } +} + +impl Stream for PendingThemeStream { + type Item = Result; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Pending + } +} + /// First `Pairing` deeplink recorded on `auth_states`, if any. pub(crate) fn first_pairing_deeplink(auth_states: &Mutex>) -> Option { auth_states @@ -1002,6 +1033,14 @@ impl UserConfirmation for StubPlatform { UserConfirmationReview::AccountAlias(_) => { (self.account_alias_error, self.account_alias_confirmed) } + UserConfirmationReview::IdentityDisclosure(_) => { + self.identity_disclosure_calls + .fetch_add(1, Ordering::SeqCst); + ( + self.identity_disclosure_error, + self.identity_disclosure_confirmed, + ) + } UserConfirmationReview::ResourceAllocation(_) => ( self.resource_allocation_error, self.resource_allocation_confirmed, @@ -1019,6 +1058,11 @@ impl UserConfirmation for StubPlatform { impl ThemeHost for StubPlatform { fn subscribe_theme(&self) -> BoxStream<'static, Result> { + if self.theme_stream_pending { + return Box::pin(PendingThemeStream { + dropped: self.theme_stream_dropped.clone(), + }); + } Box::pin(stream::once(async { Ok(v01::ThemeVariant::Dark) })) } } @@ -1026,6 +1070,10 @@ impl ThemeHost for StubPlatform { #[truapi_platform::async_trait] impl PreimageHost for StubPlatform { async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + self.preimage_submits + .lock() + .expect("preimage submit list mutex poisoned") + .push(value.clone()); Ok(value) } fn lookup_preimage( From f22b4b5cf2a84028de54535032dc6a4052807152 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 08:07:32 +0200 Subject: [PATCH 33/56] feat(codegen): generate wasm bridge callbacks Derive namespaced host callback adapters and the Rust wasm bridge from platform trait metadata instead of hardcoding the existing traits. --- 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 + 14 files changed, 2092 insertions(+), 607 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/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 5de806e2c0d19c6a93c4df9727969b9087a97cb7 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 08:12:24 +0200 Subject: [PATCH 34/56] fix(server): include runtime crate in workspace --- Cargo.toml | 1 - rust/crates/truapi-server/src/runtime.rs | 72 +++++++++---------- .../src/runtime/statement_store.rs | 2 +- rust/crates/truapi-server/src/test_support.rs | 2 +- 4 files changed, 35 insertions(+), 42 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d657d267..042aff82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] resolver = "2" members = ["rust/crates/*"] -exclude = ["rust/crates/truapi-server"] [workspace.package] edition = "2024" diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 185d9bb6..e1f4408c 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -1408,9 +1408,11 @@ impl Chain for ProductRuntimeHost { CallError, > { let RemoteChainTransactionBroadcastRequest::V1(inner) = request; - self.require_chain_submit(RemoteChainTransactionBroadcastError::V1(v01::GenericError { - reason: REMOTE_PERMISSION_DENIED_REASON.to_string(), - })) + self.require_chain_submit(RemoteChainTransactionBroadcastError::V1( + v01::GenericError { + reason: REMOTE_PERMISSION_DENIED_REASON.to_string(), + }, + )) .await?; self.services .chain @@ -1733,8 +1735,8 @@ mod tests { use super::*; use crate::host_logic::sso::messages::{RemoteMessageData, v1}; use crate::test_support::*; - use std::sync::atomic::Ordering; use std::sync::Mutex; + use std::sync::atomic::Ordering; use truapi_platform::{AuthState, CoreStorageKey, PermissionAuthorizationRequest}; fn wait_until(mut condition: impl FnMut() -> bool, message: &str) { @@ -2205,10 +2207,7 @@ mod tests { identity_disclosure_confirmed: true, ..Default::default() }); - let host = ProductRuntimeHost::new_compat( - platform.clone(), - test_spawner(), - ); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let response = @@ -2224,10 +2223,7 @@ mod tests { identity_disclosure_confirmed: true, ..Default::default() }); - let host = ProductRuntimeHost::new_compat( - platform.clone(), - test_spawner(), - ); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); host.test_session_state().set_session(session_info()); let cx = CallContext::new(); @@ -2235,29 +2231,25 @@ mod tests { futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)).unwrap(); assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 1); - let status = futures::executor::block_on( - host.permission_authorization_status(PermissionAuthorizationRequest::IdentityDisclosure), - ) - .unwrap(); + let status = + futures::executor::block_on(host.permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + )) + .unwrap(); assert_eq!(status, PermissionAuthorizationStatus::Authorized); } #[test] fn get_user_id_caches_identity_disclosure_denial() { let platform = Arc::new(StubPlatform::default()); - let host = ProductRuntimeHost::new_compat( - platform.clone(), - test_spawner(), - ); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); host.test_session_state().set_session(session_info()); let cx = CallContext::new(); - let first = - futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) - .unwrap_err(); - let second = - futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) - .unwrap_err(); + let first = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); + let second = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 1); assert!(matches!( @@ -2272,10 +2264,11 @@ mod tests { v01::HostGetUserIdError::PermissionDenied )) )); - let status = futures::executor::block_on( - host.permission_authorization_status(PermissionAuthorizationRequest::IdentityDisclosure), - ) - .unwrap(); + let status = + futures::executor::block_on(host.permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + )) + .unwrap(); assert_eq!(status, PermissionAuthorizationStatus::Denied); } @@ -2310,9 +2303,8 @@ mod tests { let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); host.test_session_state().set_session(session_info()); let cx = CallContext::new(); - let err = - futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) - .unwrap_err(); + let err = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); assert!(matches!( err, CallError::Domain(HostGetUserIdError::V1( @@ -2415,8 +2407,7 @@ mod tests { ); let cx = CallContext::new(); let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); - let err = - futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap_err(); + let err = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap_err(); match err { CallError::Domain(RemotePreimageSubmitError::V1( v01::PreimageSubmitError::Unknown { reason }, @@ -2437,7 +2428,11 @@ mod tests { remote_permission_denied: true, ..Default::default() }); - let host = ProductRuntimeHost::new(platform.clone(), runtime_config("myapp.dot"), test_spawner()); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); let cx = CallContext::new(); let request = RemoteChainTransactionBroadcastRequest::V1( v01::RemoteChainTransactionBroadcastRequest { @@ -2445,9 +2440,8 @@ mod tests { transaction: vec![1, 2, 3], }, ); - let err = - futures::executor::block_on(Chain::broadcast_transaction(&host, &cx, request)) - .unwrap_err(); + let err = futures::executor::block_on(Chain::broadcast_transaction(&host, &cx, request)) + .unwrap_err(); match err { CallError::Domain(RemoteChainTransactionBroadcastError::V1(v01::GenericError { reason, diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index 1baa282c..2d7b42d3 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -4,8 +4,8 @@ use core::pin::Pin; use core::task::{Context, Poll}; -use super::{ProductRuntimeHost, REMOTE_PERMISSION_DENIED_REASON}; use super::statement_store_rpc::{self, StatementStoreRpc}; +use super::{ProductRuntimeHost, REMOTE_PERMISSION_DENIED_REASON}; use crate::host_logic::statement_store::{ MAX_MATCH_ALL_TOPICS, MAX_MATCH_ANY_TOPICS, TopicFilterKind, decode_signed_statement, parse_new_statements_result, sign_statement_fields, signed_statement_to_scale, diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 2b5586f0..0636eb8b 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -17,8 +17,8 @@ use crate::subscription::thread_per_subscription_spawner; use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Nonce}; -use futures::stream::{self, BoxStream}; use futures::Stream; +use futures::stream::{self, BoxStream}; use hkdf::Hkdf; use p256::PublicKey as P256PublicKey; use p256::SecretKey as P256SecretKey; From 0d304f807f05d8b0775245cadacd22ce59b75fef Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 08:15:15 +0200 Subject: [PATCH 35/56] docs(server): describe permission flow --- rust/crates/truapi-server/README.md | 78 ++++++++++++++++++++++++ rust/crates/truapi-server/src/runtime.rs | 22 ------- 2 files changed, 78 insertions(+), 22 deletions(-) diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index a81f6e4b..6676fe82 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -88,6 +88,84 @@ the wallet-authority tail (`sign_*`, `create_transaction`, `account_alias`, handle with an `AuthoritySession` snapshot the role revalidates before touching key material. +### Permission flow + +Permission grants are scoped by product id and typed request, so a grant for +one product never authorizes another product or another permission class. + +```text +Product app +(product_id = "my-product") + | + | host API call + | e.g. getUserId(), chain submit, camera, remote fetch + v +Generated product client / host callback bridge + | + v +ProductRuntime + | + | attaches current ProductContext.product_id + v +PermissionsService(storage, platform, product_id) + | + | builds storage key: + | + | CoreStorageKey::PermissionAuthorization { + | product_id: "my-product", + | request: PermissionAuthorizationRequest::... + | } + v +CoreStorage lookup + | + +-- Authorized ---------------> allow protected host/backend call + | + +-- Denied -------------------> return PermissionDenied / deny call + | + +-- NotDetermined / missing ---+ + | + v + Platform prompt callback + | + +-------------------+-------------------+ + | | | + v v v + device_permission() remote_permission() confirm_user_action() + camera/mic/etc chain/preimage/etc identity disclosure + | | | + +-------------------+-------------------+ + | + v + user chooses Allow / Deny + | + v + write Authorized / Denied to CoreStorage + under the same product-scoped key + | + +-------------+-------------+ + | | + v v + Authorized Denied + allow call deny call +``` + +Permission administration uses the same key without prompting: + +```text +Product UI + | + | permission_authorization_status(request) + | set_permission_authorization_status(request, status) + v +HostAdmin / ProductRuntime + | + v +PermissionsService + | + v +CoreStorageKey::PermissionAuthorization { product_id, request } +``` + The embedder builds a role handle, `PairingHostRuntime::new(...)` or `SigningHostRuntime::new(...)`, then calls `product_runtime(product, sink)` for each product connection. Role-specific operations live only on the matching handle: diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index e1f4408c..6c120a33 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -2275,13 +2275,6 @@ mod tests { #[test] fn get_user_id_respects_pre_authorized_identity_disclosure() { let platform = Arc::new(StubPlatform::default()); - let host = ProductRuntimeHost::new_compat( - Arc::new(StubPlatform { - identity_disclosure_confirmed: true, - ..Default::default() - }), - test_spawner(), - ); let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); host.test_session_state().set_session(session_info()); let cx = CallContext::new(); @@ -2298,21 +2291,6 @@ mod tests { assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 0); } - #[test] - fn get_user_id_requires_identity_disclosure_confirmation() { - let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); - host.test_session_state().set_session(session_info()); - let cx = CallContext::new(); - let err = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) - .unwrap_err(); - assert!(matches!( - err, - CallError::Domain(HostGetUserIdError::V1( - v01::HostGetUserIdError::PermissionDenied - )) - )); - } - #[test] fn derive_entropy_matches_dotli_vector() { let host = From a2538742709b9db562033f0fbc0d0281a18a24a6 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 09:06:25 +0200 Subject: [PATCH 36/56] fix(codegen): satisfy wasm bridge clippy --- rust/crates/truapi-codegen/src/rust/wasm_bridge.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index 88c05435..d4c18d72 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -365,7 +365,7 @@ fn emit_stream_method( "invoke_js_subscription", &method.name, &payload, - &[parser.to_string()], + std::slice::from_ref(&parser), ) }; Ok(format!( From 1735b8ad2b18d416d4f30ae6599d7343d7e82dac Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 09:28:20 +0200 Subject: [PATCH 37/56] test(server): wait for auth sync callback --- rust/crates/truapi-server/src/runtime.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 6c120a33..3df0527a 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -3231,12 +3231,23 @@ mod tests { ); assert_eq!(host.test_session_state().current(), Some(stored.clone())); + let expected_auth_states = vec![AuthState::Connected(connected_session_ui_info(&stored))]; + wait_until( + || { + *platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + == expected_auth_states + }, + "session store sync did not broadcast connected auth state", + ); assert_eq!( *platform .auth_states .lock() .expect("auth state list mutex poisoned"), - vec![AuthState::Connected(connected_session_ui_info(&stored))] + expected_auth_states ); } From f4212587397cfb34b6654e74491d1a83ad874b79 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 09:38:23 +0200 Subject: [PATCH 38/56] test(server): make pairing snapshot test deterministic --- .../truapi-server/src/runtime/sso_pairing.rs | 67 +++++++++---------- rust/crates/truapi-server/src/test_support.rs | 2 +- 2 files changed, 33 insertions(+), 36 deletions(-) diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index 66b34721..66a135ff 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -389,9 +389,11 @@ mod tests { use super::super::connected_session_ui_info; use super::super::{PairingHostRole, ProductRuntimeHost}; use super::*; + use crate::host_rpc_client::HostRpcClient; use crate::test_support::{ StubPlatform, core_storage_test_key, pairing_device_from_deeplink, peer_statement_keypair, - runtime_config, session_info, signed_test_statement, stub_platform, test_spawner, + runtime_config, session_info, signed_test_statement, stub_platform, subscribe_ack_frame, + test_spawner, wallet_handshake_statement, }; use p256::elliptic_curve::sec1::ToEncodedPoint; use truapi::CallContext; @@ -399,7 +401,7 @@ mod tests { use truapi::versioned::account::{ HostAccountConnectionStatusSubscribeItem, HostRequestLoginRequest, }; - use truapi_platform::{AuthState, CoreStorageKey}; + use truapi_platform::{AuthState, ChainProvider, CoreStorageKey}; /// Cancel the login as soon as the host observes the `Pairing` state, /// mimicking a user dismissing the pairing UI immediately. @@ -680,34 +682,39 @@ mod tests { assert!(matches!(&auth_states[1], AuthState::Connected(_))); } - /// The pairing success must also be reachable through the core's own 2s - /// snapshot queries: the live subscription stays silent and the wallet - /// statement is delivered only on a query subscription page. + /// Pairing success must also be decoded from a snapshot query page, not only + /// from the live pairing subscription. #[test] fn request_login_accepts_pairing_statement_from_snapshot_query_page() { + let (host_config, _) = runtime_config("myapp.dot"); + let pairing_identity = generate_pairing_device_identity().unwrap(); + let bootstrap = + create_pairing_bootstrap_from_identity(&host_config, pairing_identity).unwrap(); + let statement = wallet_handshake_statement(&bootstrap.deeplink); let platform = Arc::new(StubPlatform { - pairing_success_via_query: true, + rpc_responses: vec![ + subscribe_ack_frame("truapi:1", "query-sub"), + crate::test_support::new_statements_frame("query-sub", vec![statement]), + ], ..Default::default() }); - let host = ProductRuntimeHost::new( - platform.clone(), - runtime_config("myapp.dot"), - test_spawner(), - ); - let cx = CallContext::new(); - let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); - let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + let connection = + futures::executor::block_on(platform.connect(host_config.people_chain_genesis_hash)) + .unwrap(); + let rpc_client = RpcClient::new(HostRpcClient::new(Arc::from(connection), test_spawner())); + let success = futures::executor::block_on(run_pairing_snapshot_query( + rpc_client, + bootstrap.topic, + bootstrap.encryption_secret_key, + )) + .unwrap() + .expect("snapshot query should return pairing success"); assert_eq!( - response, - HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Success) - ); - assert_eq!( - host.test_session_state() - .current() - .map(|session| session.public_key), - Some(session_info().public_key) + success.peer_statement_account_id, + peer_statement_keypair().1 ); + assert_eq!(success.success.root_account_id, session_info().public_key); let methods = platform .sent_rpc @@ -717,19 +724,9 @@ mod tests { .map(|request| serde_json::from_str::(request).unwrap()) .map(|request| request["method"].as_str().unwrap().to_string()) .collect::>(); - assert!( - methods - .iter() - .filter(|method| method.as_str() == "statement_subscribeStatement") - .count() - >= 2, - "core should issue snapshot queries while pairing: {methods:?}" - ); - assert!( - methods - .iter() - .any(|method| method == "statement_unsubscribeStatement"), - "drained query subscription should be cleaned up: {methods:?}" + assert_eq!( + methods.first().map(String::as_str), + Some("statement_subscribeStatement") ); } diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 0636eb8b..62f2816f 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -477,7 +477,7 @@ pub(crate) fn pairing_device_from_deeplink(deeplink: &str) -> ([u8; 32], [u8; 65 ) } -fn wallet_handshake_statement(deeplink: &str) -> Vec { +pub(crate) fn wallet_handshake_statement(deeplink: &str) -> Vec { let core_public_key = P256PublicKey::from_sec1_bytes(&core_encryption_public_key_from_deeplink(deeplink)) .expect("core encryption public key should decode"); From 71905527147d1161b93849831114f4add33edef8 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 10:03:04 +0200 Subject: [PATCH 39/56] chore(stack): move wasm bridge out of runtime layer --- .../truapi-codegen/tests/golden_rust_emit.rs | 54 - rust/crates/truapi-platform/src/lib.rs | 4 +- rust/crates/truapi-server/README.md | 2 +- rust/crates/truapi-server/src/lib.rs | 9 - rust/crates/truapi-server/src/wasm.rs | 1286 ----------------- 5 files changed, 3 insertions(+), 1352 deletions(-) delete mode 100644 rust/crates/truapi-server/src/wasm.rs diff --git a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs index b26d9e6e..8fc74fde 100644 --- a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -10,48 +10,6 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; -fn quoted_strings_in_const_array(src: &str, const_name: &str) -> Vec { - let marker = format!("export const {const_name} = ["); - let start = src - .find(&marker) - .unwrap_or_else(|| panic!("missing {const_name}")); - let rest = &src[start + marker.len()..]; - let end = rest - .find("] as const") - .unwrap_or_else(|| panic!("unterminated {const_name}")); - rest[..end] - .lines() - .filter_map(|line| { - let trimmed = line.trim().trim_end_matches(','); - trimmed - .strip_prefix('"') - .and_then(|s| s.strip_suffix('"')) - .map(str::to_string) - }) - .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 @@ -268,16 +226,4 @@ fn golden_host_callbacks_ts() { !worker_actual.contains("OPTIONAL_CALLBACK_NAMES"), "worker callback generation should not expose an optional callback manifest" ); - let mut generated_names = quoted_strings_in_const_array(&worker_actual, "CALLBACK_NAMES"); - generated_names.extend(quoted_strings_in_const_array( - &worker_actual, - "SUBSCRIPTION_NAMES", - )); - let wasm_optional = wasm_optional_callback_names(&workspace); - for name in wasm_optional { - assert!( - generated_names.contains(&name), - "generated worker names must include JsBridge optional 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/README.md b/rust/crates/truapi-server/README.md index 6676fe82..a69dca86 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -35,7 +35,7 @@ stage in the frame path; the host's `Platform` impl is the syscall floor. SCALE frames │ │ MessageChannel · loopback both directions ▼ │ WS ┌───────────────────────────────────────────────────────┐ - │ binding layer : wasm.rs ( browser / node ) │ + │ binding layer : host shell / transport adapter │ │ thin byte bridge · no protocol logic │ └───────────────────────────────────────────────────────┘ diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index e83b3d26..8fa63e23 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -4,9 +4,6 @@ //! implementation, then create product-scoped [`ProductRuntime`] endpoints that //! expose the stable byte-frame API used from WASM, native mobile, or desktop //! shells. -//! -//! Host-facing bridges: -//! - [`wasm`] (wasm32 only): wasm-bindgen surface exposing `WasmProductRuntime`. pub(crate) mod chain_runtime; pub mod core; @@ -25,9 +22,6 @@ pub(crate) mod test_support; pub mod generated; -#[cfg(target_arch = "wasm32")] -pub mod wasm; - pub use host_core::{ FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeError, SigningHostRuntime, @@ -36,6 +30,3 @@ pub use truapi_platform::{ HostRuntimeConfig, PairingHostConfig, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, ProductContext, SigningHostConfig, }; - -#[cfg(target_arch = "wasm32")] -pub use wasm::*; diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs deleted file mode 100644 index ff5386b1..00000000 --- a/rust/crates/truapi-server/src/wasm.rs +++ /dev/null @@ -1,1286 +0,0 @@ -//! wasm-bindgen surface. Exposes [`WasmProductRuntime`] to JavaScript hosts so -//! they can wire the TrUAPI core into a browser or worker shell. -//! -//! The browser side hands a `callbacks` object (a `JsBridge`) to the -//! constructor. The bridge implements every host-side capability the -//! [`truapi_platform::Platform`] trait set requires. Internally the bridge -//! is wrapped in a [`SendWrapper`] so it satisfies the `Send` bound the -//! platform trait set imposes; sound on wasm32 because the runtime is -//! single-threaded. - -use core::cell::Cell; -use core::future::Future; -use core::pin::Pin; -use core::task::{Context, Poll}; -use std::rc::Rc; -use std::sync::Arc; -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 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, -}; -use wasm_bindgen::JsCast; -use wasm_bindgen::prelude::*; - -use crate::subscription::Spawner; -use crate::{ - FrameSink, PairingHostRuntime, PermissionAuthorizationRequest, PermissionAuthorizationStatus, - 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, -} - -/// Per-core JS channel: outgoing frames and teardown for one product core. -struct CoreChannel { - emit_frame: Function, - 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 { - emit_frame: get_function(callbacks, "emitFrame")?, - dispose: get_optional_function(callbacks, "dispose")?.unwrap_or_else(noop_function), - }) - } -} - -struct WasmFrameSink { - emit_frame: SendWrapper, -} - -impl FrameSink for WasmFrameSink { - fn emit_frame(&self, frame: Vec) { - let frame = Uint8Array::from(frame.as_slice()); - if let Err(err) = self.emit_frame.call1(&JsValue::NULL, &frame) { - web_sys::console::error_1(&err); - } - } -} - -struct WasmPlatform { - bridge: SendWrapper>, -} - -impl WasmPlatform { - fn new(bridge: Arc) -> Self { - Self { - bridge: SendWrapper::new(bridge), - } - } -} - -#[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( - &self, - genesis_hash: [u8; 32], - ) -> Result, v01::GenericError> { - let chain_connect = self.bridge.chain_connect.clone(); - let chain_connect = SendWrapper::new(chain_connect); - SendWrapper::new(async move { - let (response_tx, response_rx) = mpsc::unbounded::(); - let on_response = Closure::wrap(Box::new(move |json: JsValue| { - // The host must hand back JSON-RPC frames as strings. Drop (and - // log) non-string values rather than forwarding an empty frame - // that would desync request/response correlation. - match json.as_string() { - Some(s) => { - let _ = response_tx.unbounded_send(s); - } - None => web_sys::console::error_1(&JsValue::from_str( - "chainConnect onResponse expected a JSON string; dropping non-string value", - )), - } - }) as Box); - - let genesis_arg = JsValue::from_str(&format!("0x{}", hex::encode(&genesis_hash))); - let returned = chain_connect - .call2( - &JsValue::NULL, - &genesis_arg, - on_response.as_ref().unchecked_ref(), - ) - .map_err(|err| generic(js_to_string(err)))?; - let resolved = await_optional_promise(returned).await.map_err(generic)?; - if resolved.is_null() || resolved.is_undefined() { - return Err(generic("chainConnect returned no connection".into())); - } - let send_fn = Reflect::get(&resolved, &JsValue::from_str("send")) - .map_err(|_| generic("chainConnect must return { send, close }".into()))? - .dyn_into::() - .map_err(|_| generic("chainConnect.send must be a function".into()))?; - let close_fn = Reflect::get(&resolved, &JsValue::from_str("close")) - .map_err(|_| generic("chainConnect.close must be a function".into()))? - .dyn_into::() - .map_err(|_| generic("chainConnect.close must be a function".into()))?; - - Ok(Box::new(JsCallbackJsonRpcConnection { - send_fn: SendWrapper::new(send_fn), - close_fn: SendWrapper::new(close_fn), - closed: AtomicBool::new(false), - _on_response: SendWrapper::new(on_response), - response_rx: std::sync::Mutex::new(Some(response_rx)), - }) as Box) - }) - .await - } -} - -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. - -struct JsSubscriptionStream { - rx: mpsc::UnboundedReceiver, - _send_item: SendWrapper>, - dispose: Option>, -} - -impl Stream for JsSubscriptionStream { - type Item = T; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.rx).poll_next(cx) - } -} - -impl Drop for JsSubscriptionStream { - fn drop(&mut self) { - if let Some(dispose) = self.dispose.take() { - let _ = dispose.call0(&JsValue::NULL); - } - } -} - -fn invoke_js_subscription( - fn_: &Function, - payload: Option>, - parse_item: fn(JsValue) -> Result, -) -> BoxStream<'static, Result> -where - T: Send + 'static, -{ - let (tx, rx) = mpsc::unbounded::>(); - let send_item = Closure::wrap(Box::new(move |value: JsValue| { - let item = parse_item(value).map_err(generic); - let _ = tx.unbounded_send(item); - }) as Box); - - let call_result = match payload { - Some(payload) => { - let arg = Uint8Array::from(payload.as_slice()); - fn_.call2(&JsValue::NULL, &arg, send_item.as_ref().unchecked_ref()) - } - None => fn_.call1(&JsValue::NULL, send_item.as_ref().unchecked_ref()), - }; - - let dispose = match call_result { - Ok(value) if value.is_null() || value.is_undefined() => None, - Ok(value) => match value.dyn_into::() { - Ok(dispose) => Some(SendWrapper::new(dispose)), - Err(_) => { - return stream::once(async { - Err(generic( - "subscription callback must return a dispose function, null, or undefined" - .to_string(), - )) - }) - .boxed(); - } - }, - Err(err) => return stream::once(async { Err(generic(js_to_string(err))) }).boxed(), - }; - - Box::pin(JsSubscriptionStream { - rx, - _send_item: SendWrapper::new(send_item), - dispose, - }) -} - -struct JsCallbackJsonRpcConnection { - send_fn: SendWrapper, - close_fn: SendWrapper, - closed: AtomicBool, - /// Closure must outlive the connection so JS keeps a live ref to the - /// response sink. Dropped together with the rest of the struct. - _on_response: SendWrapper>, - response_rx: std::sync::Mutex>>, -} - -impl JsonRpcConnection for JsCallbackJsonRpcConnection { - fn send(&self, request: String) { - let arg = JsValue::from_str(&request); - if let Err(err) = self.send_fn.call1(&JsValue::NULL, &arg) { - web_sys::console::error_1(&err); - } - } - - /// Single-take: the response receiver is handed out exactly once. A second - /// call yields an empty stream (and logs), since the channel has one - /// consumer. - fn responses(&self) -> BoxStream<'static, String> { - let mut guard = self.response_rx.lock().unwrap(); - match guard.take() { - Some(rx) => rx.boxed(), - None => { - web_sys::console::error_1(&JsValue::from_str( - "JsCallbackJsonRpcConnection::responses() called more than once", - )); - futures::stream::empty().boxed() - } - } - } - - fn close(&self) { - if self.closed.swap(true, Ordering::AcqRel) { - return; - } - let _ = self.close_fn.call0(&JsValue::NULL); - } -} - -impl Drop for JsCallbackJsonRpcConnection { - fn drop(&mut self) { - self.close(); - } -} - -fn generic(reason: String) -> v01::GenericError { - v01::GenericError { reason } -} - -/// Await the JS callback's return value if it's a Promise; pass other -/// values through unchanged. Every host callback resolves through this so -/// the JS side is free to be sync or async. -async fn await_optional_promise(returned: JsValue) -> Result { - if returned.is_instance_of::() { - let promise = returned.unchecked_into::(); - wasm_bindgen_futures::JsFuture::from(promise) - .await - .map_err(js_to_string) - } else { - Ok(returned) - } -} - -fn invoke_navigate_to( - bridge: &JsBridge, - url: &str, -) -> impl Future> + Send { - let fn_ = bridge.navigate_to.clone(); - let url = url.to_string(); - SendWrapper::new(async move { - let arg = JsValue::from_str(&url); - let returned = fn_.call1(&JsValue::NULL, &arg).map_err(js_to_string)?; - await_optional_promise(returned).await.map(|_| ()) - }) -} - -fn invoke_bool( - fn_: &Function, - payload: 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 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 - // denial / unsupported and hide the host bug). - resolved - .as_bool() - .ok_or_else(|| "callback must resolve to a boolean".to_string()) - }) -} - -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, -) -> 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 resolved = await_optional_promise(returned).await?; - resolved - .dyn_into::() - .map(|array| array.to_vec()) - .map_err(|_| "callback must resolve to Uint8Array".to_string()) - }) -} - -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, -) -> impl Future>, String>> + Send { - let fn_ = bridge.core_storage_read.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 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(|_| ()) - }) -} - -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 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 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 js_to_string(value: JsValue) -> String { - value - .as_string() - .or_else(|| { - value - .dyn_ref::() - .map(|err| err.message().into()) - }) - .unwrap_or_else(|| format!("{value:?}")) -} - -fn get_function(callbacks: &JsValue, name: &str) -> Result { - let value = Reflect::get(callbacks, &JsValue::from_str(name))?; - value - .dyn_into::() - .map_err(|_| JsValue::from_str(&format!("callbacks.{name} must be a function"))) -} - -fn get_optional_function(callbacks: &JsValue, name: &str) -> Result, JsValue> { - let value = Reflect::get(callbacks, &JsValue::from_str(name))?; - if value.is_null() || value.is_undefined() { - return Ok(None); - } - value - .dyn_into::() - .map(Some) - .map_err(|_| JsValue::from_str(&format!("callbacks.{name} must be a function"))) -} - -fn noop_function() -> Function { - Function::new_no_args("") -} - -fn runtime_config_from_js(value: &JsValue) -> Result<(PairingHostConfig, ProductContext), JsValue> { - let host_config = pairing_host_config_from_js(value)?; - let product = product_context_from_js(value)?; - Ok((host_config, product)) -} - -fn pairing_host_config_from_js(value: &JsValue) -> Result { - if value.is_null() || value.is_undefined() { - return Err(JsValue::from_str("hostConfig is required")); - } - - let host = get_required_object(value, "host", "runtimeConfig.host")?; - let platform = get_optional_object(value, "platform", "runtimeConfig.platform")?; - let people = get_required_object(value, "people", "runtimeConfig.people")?; - let pairing = get_required_object(value, "pairing", "runtimeConfig.pairing")?; - - PairingHostConfig::new( - HostInfo { - name: get_required_string_at(&host, "name", "runtimeConfig.host.name")?, - icon: get_optional_string_at(&host, "icon", "runtimeConfig.host.icon")?, - version: get_optional_string_at(&host, "version", "runtimeConfig.host.version")?, - }, - PlatformInfo { - kind: platform - .as_ref() - .map(|p| get_optional_string_at(p, "type", "runtimeConfig.platform.type")) - .transpose()? - .flatten(), - version: platform - .as_ref() - .map(|p| get_optional_string_at(p, "version", "runtimeConfig.platform.version")) - .transpose()? - .flatten(), - }, - get_required_bytes32_at(&people, "genesisHash", "runtimeConfig.people.genesisHash")?, - get_required_string_at( - &pairing, - "deeplinkScheme", - "runtimeConfig.pairing.deeplinkScheme", - )?, - ) - .map_err(runtime_config_validation_to_js) -} - -fn product_context_from_js(value: &JsValue) -> Result { - if value.is_null() || value.is_undefined() { - return Err(JsValue::from_str("product is required")); - } - ProductContext::new(get_required_string_at( - value, - "productId", - "runtimeConfig.productId", - )?) - .map_err(runtime_config_validation_to_js) -} - -fn runtime_config_field_to_js(field: &str) -> &str { - match field { - "product_id" => "productId", - "host_info.name" => "host.name", - "pairing_deeplink_scheme" => "pairing.deeplinkScheme", - "people_chain_genesis_hash" => "people.genesisHash", - other => other, - } -} - -fn runtime_config_validation_to_js(err: RuntimeConfigValidationError) -> JsValue { - match err { - RuntimeConfigValidationError::EmptyField { field } => JsValue::from_str(&format!( - "runtimeConfig.{} must not be empty", - runtime_config_field_to_js(field) - )), - RuntimeConfigValidationError::InvalidHostIcon { reason } => JsValue::from_str(&format!( - "runtimeConfig.host.icon must be an absolute HTTPS URL: {reason}" - )), - RuntimeConfigValidationError::InsecureHostIcon { scheme } => JsValue::from_str(&format!( - "runtimeConfig.host.icon must use https scheme, got {scheme:?}" - )), - RuntimeConfigValidationError::InvalidDeeplinkScheme { scheme } => JsValue::from_str( - &format!("runtimeConfig.pairing.deeplinkScheme must not include ://, got {scheme:?}"), - ), - RuntimeConfigValidationError::InvalidProductId { product_id } => { - JsValue::from_str(&format!( - "runtimeConfig.productId must be a .dot or localhost product identifier, got {product_id:?}" - )) - } - } -} - -fn get_required_object(value: &JsValue, name: &str, path: &str) -> Result { - let property = Reflect::get(value, &JsValue::from_str(name))?; - if property.is_null() || property.is_undefined() { - return Err(JsValue::from_str(&format!("{path} is required"))); - } - if !property.is_object() { - return Err(JsValue::from_str(&format!("{path} must be an object"))); - } - Ok(property) -} - -fn get_optional_object( - value: &JsValue, - name: &str, - path: &str, -) -> Result, JsValue> { - let property = Reflect::get(value, &JsValue::from_str(name))?; - if property.is_null() || property.is_undefined() { - return Ok(None); - } - if !property.is_object() { - return Err(JsValue::from_str(&format!("{path} must be an object"))); - } - Ok(Some(property)) -} - -fn get_optional_string_at( - value: &JsValue, - name: &str, - path: &str, -) -> Result, JsValue> { - let property = Reflect::get(value, &JsValue::from_str(name))?; - if property.is_null() || property.is_undefined() { - return Ok(None); - } - property - .as_string() - .map(Some) - .ok_or_else(|| JsValue::from_str(&format!("{path} must be a string"))) -} - -fn get_required_string_at(value: &JsValue, name: &str, path: &str) -> Result { - get_optional_string_at(value, name, path)? - .ok_or_else(|| JsValue::from_str(&format!("{path} is required"))) -} - -fn get_optional_bytes32_at( - value: &JsValue, - name: &str, - path: &str, -) -> Result, JsValue> { - let property = Reflect::get(value, &JsValue::from_str(name))?; - if property.is_null() || property.is_undefined() { - return Ok(None); - } - if let Some(hex) = property.as_string() { - return parse_hex32(&hex) - .map(Some) - .map_err(|reason| JsValue::from_str(&format!("{path}: {reason}"))); - } - let array = property - .dyn_into::() - .map_err(|_| JsValue::from_str(&format!("{path} must be hex or Uint8Array")))?; - let bytes = array.to_vec(); - bytes.try_into().map(Some).map_err(|bytes: Vec| { - JsValue::from_str(&format!( - "{path} must be exactly 32 bytes, got {}", - bytes.len() - )) - }) -} - -fn get_required_bytes32_at(value: &JsValue, name: &str, path: &str) -> Result<[u8; 32], JsValue> { - get_optional_bytes32_at(value, name, path)? - .ok_or_else(|| JsValue::from_str(&format!("{path} is required"))) -} - -fn parse_hex32(value: &str) -> Result<[u8; 32], String> { - let raw = value.strip_prefix("0x").unwrap_or(value); - if raw.len() != 64 { - return Err(format!( - "expected 32-byte hex string, got {} hex chars", - raw.len() - )); - } - let bytes = hex::decode(raw).map_err(|_| "invalid hex".to_string())?; - bytes - .try_into() - .map_err(|bytes: Vec| format!("expected 32 bytes, got {}", bytes.len())) -} - -fn decode_permission_authorization_request( - payload: &[u8], -) -> Result { - PermissionAuthorizationRequest::decode(&mut &*payload).map_err(|err| { - JsValue::from_str(&format!( - "permission authorization request did not decode: {err}" - )) - }) -} - -fn decode_permission_authorization_requests( - payloads: &Array, -) -> Result, JsValue> { - let mut requests = Vec::with_capacity(payloads.length() as usize); - for payload in payloads.iter() { - let payload = payload - .dyn_into::() - .map_err(|_| JsValue::from_str("permission authorization request must be bytes"))?; - requests.push(decode_permission_authorization_request(&payload.to_vec())?); - } - Ok(requests) -} - -fn permission_authorization_status_to_js(status: PermissionAuthorizationStatus) -> JsValue { - JsValue::from_str(match status { - PermissionAuthorizationStatus::NotDetermined => "NotDetermined", - PermissionAuthorizationStatus::Denied => "Denied", - PermissionAuthorizationStatus::Authorized => "Authorized", - }) -} - -fn permission_authorization_status_from_js( - status: &str, -) -> Result { - match status { - "NotDetermined" => Ok(PermissionAuthorizationStatus::NotDetermined), - "Denied" => Ok(PermissionAuthorizationStatus::Denied), - "Authorized" => Ok(PermissionAuthorizationStatus::Authorized), - other => Err(JsValue::from_str(&format!( - "unknown permission authorization status: {other}" - ))), - } -} - -fn generic_error_to_js(err: v01::GenericError) -> JsValue { - JsValue::from_str(&err.reason) -} - -struct WasmCoreInner { - core: ProductRuntime, - dispose_fn: SendWrapper, - disposed: Cell, - disposing: Cell, -} - -/// JS-callable handle to a long-lived pairing-host runtime shared by product -/// cores. -#[wasm_bindgen] -pub struct WasmPairingHostRuntime { - runtime: Rc, -} - -#[wasm_bindgen] -impl WasmPairingHostRuntime { - /// Build a shared runtime from host-level platform callbacks and host config. - #[wasm_bindgen(constructor)] - pub fn new( - callbacks: JsValue, - host_config: JsValue, - ) -> Result { - console_error_panic_hook::set_once(); - crate::logging::init(); - let bridge = Arc::new(JsBridge::from_js(&callbacks)?); - let platform = Arc::new(WasmPlatform::new(bridge)); - let spawner: Spawner = Arc::new(|fut| { - wasm_bindgen_futures::spawn_local(fut); - }); - let host_config = pairing_host_config_from_js(&host_config)?; - Ok(Self { - runtime: Rc::new(PairingHostRuntime::new(platform, host_config, spawner)), - }) - } - - /// Build one product-scoped runtime from this pairing host runtime. - #[wasm_bindgen(js_name = productRuntime)] - pub fn product_runtime( - &self, - product: JsValue, - core_callbacks: JsValue, - ) -> Result { - let product = product_context_from_js(&product)?; - let channel = CoreChannel::from_js(&core_callbacks)?; - let sink = Arc::new(WasmFrameSink { - emit_frame: SendWrapper::new(channel.emit_frame), - }); - let runtime = self.runtime.product_runtime(product, sink); - Ok(WasmProductRuntime::from_parts(runtime, channel.dispose)) - } - - /// Disconnect the shared account-authority session. - #[wasm_bindgen(js_name = disconnectSession)] - pub async fn disconnect_session(&self) { - self.runtime.disconnect_session().await; - } - - /// Cancel an in-flight pairing flow. - #[wasm_bindgen(js_name = cancelPairing)] - pub fn cancel_pairing(&self) { - self.runtime.cancel_pairing(); - } - - /// Notify the runtime that the auth session slot may have changed. - #[wasm_bindgen(js_name = notifySessionStoreChanged)] - pub fn notify_session_store_changed(&self) { - self.runtime.notify_session_store_changed(); - } - - /// Read a stored permission authorization status for a product. - #[wasm_bindgen(js_name = permissionAuthorizationStatus)] - pub async fn permission_authorization_status( - &self, - product_id: String, - payload: Vec, - ) -> Result { - let request = decode_permission_authorization_request(&payload)?; - let status = self - .runtime - .permission_authorization_status(&product_id, request) - .await - .map_err(generic_error_to_js)?; - Ok(permission_authorization_status_to_js(status)) - } - - /// Read stored permission authorization statuses for a product. - #[wasm_bindgen(js_name = permissionAuthorizationStatuses)] - pub async fn permission_authorization_statuses( - &self, - product_id: String, - payloads: Array, - ) -> Result { - let requests = decode_permission_authorization_requests(&payloads)?; - let statuses = self - .runtime - .permission_authorization_statuses(&product_id, requests) - .await - .map_err(generic_error_to_js)?; - let values = Array::new(); - for status in statuses { - values.push(&permission_authorization_status_to_js(status)); - } - Ok(values) - } - - /// Update a stored permission authorization status for a product. - #[wasm_bindgen(js_name = setPermissionAuthorizationStatus)] - pub async fn set_permission_authorization_status( - &self, - product_id: String, - payload: Vec, - status: String, - ) -> Result<(), JsValue> { - let request = decode_permission_authorization_request(&payload)?; - let status = permission_authorization_status_from_js(&status)?; - self.runtime - .set_permission_authorization_status(&product_id, request, status) - .await - .map_err(generic_error_to_js) - } -} - -/// Set the live log level (`off`/`error`/`warn`/`info`/`debug`/`trace`). -/// Hosts may call this during boot, or again at any time to re-tune verbosity. -/// Unknown values are parsed as `off`. -#[wasm_bindgen(js_name = setLogLevel)] -pub fn set_log_level(level: &str) { - crate::logging::set_level_from_str(level); -} - -/// JS-callable handle to one product-scoped TrUAPI core. -#[wasm_bindgen] -pub struct WasmProductRuntime { - inner: Rc, -} - -impl WasmProductRuntime { - fn from_parts(core: ProductRuntime, dispose_fn: Function) -> Self { - Self { - inner: Rc::new(WasmCoreInner { - core, - dispose_fn: SendWrapper::new(dispose_fn), - disposed: Cell::new(false), - disposing: Cell::new(false), - }), - } - } -} - -#[wasm_bindgen] -impl WasmProductRuntime { - /// Build the core from a JS callbacks object. The object must define - /// every host capability the [`truapi_platform::Platform`] trait set - /// requires (camelCase property names; see the source for the full - /// list). - #[wasm_bindgen(constructor)] - pub fn new(callbacks: JsValue, runtime_config: JsValue) -> Result { - // Surface Rust panics to the browser console. A panic mid-dispatch - // aborts the call as a wasm trap; the host should treat a thrown error - // from `receiveFrame` as a fatal-instance signal and rebuild the - // core rather than continue using it. - console_error_panic_hook::set_once(); - crate::logging::init(); - let bridge = Arc::new(JsBridge::from_js(&callbacks)?); - let channel = CoreChannel::from_js(&callbacks)?; - let frame_sink = Arc::new(WasmFrameSink { - emit_frame: SendWrapper::new(channel.emit_frame), - }); - let platform = Arc::new(WasmPlatform::new(bridge)); - let spawner: Spawner = Arc::new(|fut| { - wasm_bindgen_futures::spawn_local(fut); - }); - let (host_config, product) = runtime_config_from_js(&runtime_config)?; - let core = ProductRuntime::from_platform_with_config( - platform, - host_config, - product, - spawner, - frame_sink, - ); - Ok(Self::from_parts(core, channel.dispose)) - } - - /// Push a SCALE-encoded protocol frame into the dispatcher. Responses - /// (and subscription items) flow back through the `emitFrame` - /// callback. - #[wasm_bindgen(js_name = receiveFrame)] - pub async fn receive_frame(&self, frame: Vec) -> Result<(), JsValue> { - self.inner - .core - .receive_frame(frame) - .await - .map_err(|err| JsValue::from_str(&err.to_string())) - } - - /// Read a stored permission authorization status without prompting. - /// - /// `payload` is a SCALE-encoded `PermissionAuthorizationRequest`. - #[wasm_bindgen(js_name = permissionAuthorizationStatus)] - pub async fn permission_authorization_status( - &self, - payload: Vec, - ) -> Result { - let request = decode_permission_authorization_request(&payload)?; - let status = self - .inner - .core - .permission_authorization_status(request) - .await - .map_err(generic_error_to_js)?; - Ok(permission_authorization_status_to_js(status)) - } - - /// Read stored permission authorization statuses without prompting. - /// - /// `payloads` is an array of SCALE-encoded - /// `PermissionAuthorizationRequest` values. Results follow the same order. - #[wasm_bindgen(js_name = permissionAuthorizationStatuses)] - pub async fn permission_authorization_statuses( - &self, - payloads: Array, - ) -> Result { - let requests = decode_permission_authorization_requests(&payloads)?; - let statuses = self - .inner - .core - .permission_authorization_statuses(requests) - .await - .map_err(generic_error_to_js)?; - let values = Array::new(); - for status in statuses { - values.push(&permission_authorization_status_to_js(status)); - } - Ok(values) - } - - /// Update a stored permission authorization status. Passing - /// `"NotDetermined"` clears the stored value so the next product request - /// prompts again. - #[wasm_bindgen(js_name = setPermissionAuthorizationStatus)] - pub async fn set_permission_authorization_status( - &self, - payload: Vec, - status: String, - ) -> Result<(), JsValue> { - let request = decode_permission_authorization_request(&payload)?; - let status = permission_authorization_status_from_js(&status)?; - self.inner - .core - .set_permission_authorization_status(request, status) - .await - .map_err(generic_error_to_js) - } - - /// Tear down the bridge. Invokes the JS-side `dispose` callback so the - /// host can drop its end of the wiring. - pub fn dispose(&self) -> Result<(), JsValue> { - if self.inner.disposed.get() { - return Ok(()); - } - if self.inner.disposing.replace(true) { - return Ok(()); - } - - self.inner.core.dispose(); - - let result = self.inner.dispose_fn.call0(&JsValue::NULL).map(|_| ()); - - self.inner.disposed.set(true); - self.inner.disposing.set(false); - result - } - - /// Core-owned logout/disconnect. Best-effort notifies the SSO peer when - /// the session has channel material, then clears in-memory and persisted - /// session state. - #[wasm_bindgen(js_name = disconnectSession)] - pub async fn disconnect_session(&self) -> Result<(), JsValue> { - self.inner.core.disconnect_session().await; - Ok(()) - } -} From 5fbf0b30f40d9c25c7fe15b57a79d77e7bb9f531 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 12:09:07 +0200 Subject: [PATCH 40/56] fixup! feat(truapi-server): add platform runtime and host bridge --- .../crates/truapi-server/src/chain_runtime.rs | 182 +++++++++- .../truapi-server/src/host_logic/session.rs | 44 +++ rust/crates/truapi-server/src/runtime.rs | 336 ++++++++++++++---- .../truapi-server/src/runtime/authority.rs | 57 ++- .../truapi-server/src/runtime/identity.rs | 229 ++---------- .../src/runtime/pairing_host/sso_channel.rs | 154 ++++---- .../truapi-server/src/runtime/sso_remote.rs | 266 +++++++++----- rust/crates/truapi/src/lib.rs | 207 ++++++++++- 8 files changed, 1037 insertions(+), 438 deletions(-) diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index af38a348..8b31bc16 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -17,13 +17,17 @@ use core::task::{Context, Poll}; use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; use futures::FutureExt; use futures::channel::mpsc; use futures::future::{AbortHandle, Abortable}; use futures::future::{BoxFuture, Shared}; use futures::stream::BoxStream; -use futures::{Stream, StreamExt}; +use futures::{Stream, StreamExt, pin_mut}; use parity_scale_codec::{Decode, Error as ScaleError, Input}; use primitive_types::H256; use serde::de::{Deserializer, Error as DeError}; @@ -1107,6 +1111,129 @@ pub(crate) fn encode_hex(value: &[u8]) -> String { format!("0x{}", hex::encode(value)) } +/// Wait for a usable best block hash from a `chainHead_v1_follow` stream. +pub(crate) async fn wait_for_chain_head_best_hash( + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + label: &'static str, + initialization_timeout: Duration, + best_hash_timeout: Duration, +) -> Result, String> { + let timeout = futures_timer::Delay::new(initialization_timeout).fuse(); + pin_mut!(timeout); + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::Initialized { finalized_block_hashes, .. }) => { + let fallback = finalized_block_hashes.last().cloned(); + return wait_for_chain_head_best_hash_after_initialization( + follow, + label, + fallback, + best_hash_timeout, + ) + .await; + } + Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { + return Ok(best_block_hash); + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return Err(format!("{label} follow stopped before initialization")); + } + _ => {} + }, + () = timeout => return Err(format!("{label} follow initialization timed out")), + } + } +} + +async fn wait_for_chain_head_best_hash_after_initialization( + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + label: &'static str, + fallback: Option>, + timeout: Duration, +) -> Result, String> { + let timeout = futures_timer::Delay::new(timeout).fuse(); + pin_mut!(timeout); + let mut candidate = fallback; + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { + return Ok(best_block_hash); + } + Some(RemoteChainHeadFollowItem::NewBlock { block_hash, .. }) => { + candidate = Some(block_hash); + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return candidate.ok_or_else(|| { + format!("{label} follow stopped before best block") + }); + } + _ => {} + }, + () = timeout => { + return candidate.ok_or_else(|| { + format!("{label} follow best block timed out") + }); + }, + } + } +} + +/// Wait for one storage operation's value from a `chainHead_v1_follow` stream. +pub(crate) async fn wait_for_chain_head_storage_value( + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + operation_id: &str, + key: &[u8], + label: &'static str, + timeout: Duration, +) -> Result>, String> { + let timeout = futures_timer::Delay::new(timeout).fuse(); + pin_mut!(timeout); + let mut value = None; + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::OperationStorageItems { operation_id: item_operation_id, items }) + if item_operation_id == operation_id => + { + for item in items { + if item.key == key { + value = item.value; + } + } + } + Some(RemoteChainHeadFollowItem::OperationStorageDone { operation_id: item_operation_id }) + if item_operation_id == operation_id => + { + return Ok(value); + } + Some(RemoteChainHeadFollowItem::OperationInaccessible { operation_id: item_operation_id }) + if item_operation_id == operation_id => + { + return Ok(None); + } + Some(RemoteChainHeadFollowItem::OperationError { operation_id: item_operation_id, error }) + if item_operation_id == operation_id => + { + return Err(error); + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return Err(format!("{label} follow stopped during storage lookup")); + } + _ => {} + }, + () = timeout => return Err(format!("{label} storage lookup timed out")), + } + } +} + #[cfg(test)] fn decode_hex(value: &str) -> Result, String> { hex::decode(value.strip_prefix("0x").unwrap_or(value)).map_err(|_| "invalid hex".to_string()) @@ -1117,7 +1244,7 @@ mod tests { use super::*; use async_trait::async_trait; use futures::channel::mpsc as fut_mpsc; - use futures::stream::BoxStream; + use futures::stream::{self, BoxStream}; use std::sync::atomic::{AtomicUsize, Ordering}; fn spawner_for_tests() -> Spawner { @@ -1131,6 +1258,57 @@ mod tests { } } + #[test] + fn chain_head_best_hash_prefers_best_block_after_initialization() { + let mut follow = stream::iter(vec![ + RemoteChainHeadFollowItem::Initialized { + finalized_block_hashes: vec![vec![0x01]], + finalized_block_runtime: None, + }, + RemoteChainHeadFollowItem::BestBlockChanged { + best_block_hash: vec![0x02], + }, + ]) + .boxed(); + + let hash = futures::executor::block_on(wait_for_chain_head_best_hash( + &mut follow, + "test chain", + Duration::from_secs(10), + Duration::from_secs(2), + )) + .expect("best hash should resolve"); + + assert_eq!(hash, vec![0x02]); + } + + #[test] + fn chain_head_best_hash_uses_new_block_before_stale_finalized_fallback() { + let mut follow = stream::iter(vec![ + RemoteChainHeadFollowItem::Initialized { + finalized_block_hashes: vec![vec![0x01]], + finalized_block_runtime: None, + }, + RemoteChainHeadFollowItem::NewBlock { + block_hash: vec![0x03], + parent_block_hash: vec![0x01], + new_runtime: None, + }, + RemoteChainHeadFollowItem::Stop, + ]) + .boxed(); + + let hash = futures::executor::block_on(wait_for_chain_head_best_hash( + &mut follow, + "test chain", + Duration::from_secs(10), + Duration::from_secs(2), + )) + .expect("new block hash should resolve"); + + assert_eq!(hash, vec![0x03]); + } + #[derive(Default)] struct UnavailableChainProvider; diff --git a/rust/crates/truapi-server/src/host_logic/session.rs b/rust/crates/truapi-server/src/host_logic/session.rs index 3eb51ff8..7eb9e46d 100644 --- a/rust/crates/truapi-server/src/host_logic/session.rs +++ b/rust/crates/truapi-server/src/host_logic/session.rs @@ -38,6 +38,32 @@ pub struct SessionInfo { pub full_username: Option, } +impl SessionInfo { + /// Whether the session already carries a usable username. + pub(crate) fn has_username(&self) -> bool { + non_empty_username(&self.full_username) || non_empty_username(&self.lite_username) + } + + /// Apply resolved username fields without replacing populated values with + /// empty strings. + pub(crate) fn apply_usernames( + &mut self, + lite_username: Option, + full_username: Option, + ) { + if non_empty_username(&full_username) { + self.full_username = full_username; + } + if non_empty_username(&lite_username) { + self.lite_username = lite_username; + } + } +} + +fn non_empty_username(value: &Option) -> bool { + value.as_ref().is_some_and(|value| !value.is_empty()) +} + /// SSO session material negotiated by the pairing host with the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SsoSessionInfo { @@ -197,6 +223,24 @@ mod tests { } } + #[test] + fn session_username_helpers_check_and_apply_non_empty_values() { + let mut session = info(0x42); + session.lite_username = None; + session.full_username = None; + + assert!(!session.has_username()); + + session.apply_usernames(Some(String::new()), Some("Alice Smith".to_string())); + assert!(session.has_username()); + assert_eq!(session.full_username.as_deref(), Some("Alice Smith")); + assert_eq!(session.lite_username, None); + + session.apply_usernames(Some("alice".to_string()), Some(String::new())); + assert_eq!(session.full_username.as_deref(), Some("Alice Smith")); + assert_eq!(session.lite_username.as_deref(), Some("alice")); + } + #[test] fn current_starts_empty() { let state = SessionState::new(); diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 3df0527a..aaa93083 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -18,6 +18,8 @@ pub(crate) mod sso_remote; pub(crate) mod statement_store; mod statement_store_rpc; +use core::future::Future; +use core::time::Duration; use std::sync::Arc; use crate::chain_runtime::RuntimeFailure; @@ -40,13 +42,11 @@ pub(crate) use services::RuntimeServices; pub(crate) use signing_host::{LocalActivation, SigningHost as SigningHostRole}; use authority::{ - AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, + AuthorityCancelError, AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; -#[cfg(test)] -use futures::FutureExt; -use futures::StreamExt; +use futures::{FutureExt, StreamExt, pin_mut}; #[cfg(test)] use parity_scale_codec::Encode; use tracing::{info, instrument}; @@ -128,7 +128,7 @@ use truapi::versioned::system::{ HostNavigateToError, HostNavigateToRequest, HostNavigateToResponse, }; use truapi::versioned::theme::HostThemeSubscribeItem; -use truapi::{CallContext, CallError, Subscription}; +use truapi::{CallContext, CallError, CancellationReason, Subscription}; #[cfg(test)] use truapi_platform::Platform; use truapi_platform::{ @@ -139,6 +139,62 @@ use truapi_platform::{ }; pub(super) const REMOTE_PERMISSION_DENIED_REASON: &str = "Permission denied"; +/// Host-spec B.6.2 recommends timing out unanswered SSO application requests +/// after 180 seconds: +/// +const DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT: Duration = Duration::from_secs(180); + +fn remote_authority_context(cx: &CallContext) -> CallContext { + let mut cx = cx.clone(); + if cx.timeout().is_none() { + cx.set_timeout(DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT); + } + cx +} + +async fn remote_authority_call(cx: &CallContext, call: F) -> Result +where + F: Future>, +{ + let call = call.fuse(); + let cancelled = cx.cancel().cancelled().fuse(); + pin_mut!(call, cancelled); + + if let Some(timeout_duration) = cx.timeout() { + let timeout = futures_timer::Delay::new(timeout_duration).fuse(); + pin_mut!(timeout); + futures::select! { + result = call => result, + reason = cancelled => { + let error = authority_cancellation_error(cx, reason); + let _ = call.await; + Err(error) + }, + () = timeout => { + let reason = CancellationReason::TimedOut { + timeout: timeout_duration, + }; + cx.cancel().cancel_with_reason(reason.clone()); + let error = authority_cancellation_error(cx, reason); + let _ = call.await; + Err(error) + } + } + } else { + futures::select! { + result = call => result, + reason = cancelled => { + let error = authority_cancellation_error(cx, reason); + let _ = call.await; + Err(error) + }, + } + } +} + +fn authority_cancellation_error(cx: &CallContext, reason: CancellationReason) -> AuthorityError { + AuthorityError::Cancelled(AuthorityCancelError::new(cx.request_id(), reason)) +} /// Product-scoped adapter that exposes a long-lived host runtime through the /// `truapi::api::*` trait set the generated dispatcher routes to. @@ -853,6 +909,9 @@ fn account_get_error_from_authority(err: AuthorityError) -> v01::HostAccountGetE match err { AuthorityError::Rejected => v01::HostAccountGetError::Rejected, AuthorityError::Disconnected => v01::HostAccountGetError::NotConnected, + AuthorityError::Cancelled(err) => v01::HostAccountGetError::Unknown { + reason: err.to_string(), + }, AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { v01::HostAccountGetError::Unknown { reason } } @@ -867,6 +926,9 @@ fn signing_call_error( AuthorityError::Rejected | AuthorityError::Disconnected => { v01::HostSignPayloadError::Rejected } + AuthorityError::Cancelled(err) => v01::HostSignPayloadError::Unknown { + reason: err.to_string(), + }, AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { v01::HostSignPayloadError::Unknown { reason } } @@ -881,6 +943,9 @@ fn transaction_call_error( AuthorityError::Rejected | AuthorityError::Disconnected => { v01::HostCreateTransactionError::Rejected } + AuthorityError::Cancelled(err) => v01::HostCreateTransactionError::Unknown { + reason: err.to_string(), + }, AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { v01::HostCreateTransactionError::Unknown { reason } } @@ -930,11 +995,15 @@ impl Signing for ProductRuntimeHost { v01::HostSignPayloadError::Rejected, ))); } - self.authority - .sign_payload(cx, &session, SignPayloadAuthorityRequest::Product(inner)) - .await - .map(HostSignPayloadResponse::V1) - .map_err(|reason| signing_call_error(HostSignPayloadError::V1, reason)) + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority + .sign_payload(&cx, &session, SignPayloadAuthorityRequest::Product(inner)), + ) + .await + .map(HostSignPayloadResponse::V1) + .map_err(|reason| signing_call_error(HostSignPayloadError::V1, reason)) } #[instrument(skip_all, fields(runtime.method = "signing.sign_raw"))] @@ -979,11 +1048,15 @@ impl Signing for ProductRuntimeHost { v01::HostSignPayloadError::Rejected, ))); } - self.authority - .sign_raw(cx, &session, SignRawAuthorityRequest::Product(inner)) - .await - .map(HostSignRawResponse::V1) - .map_err(|reason| signing_call_error(HostSignRawError::V1, reason)) + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority + .sign_raw(&cx, &session, SignRawAuthorityRequest::Product(inner)), + ) + .await + .map(HostSignRawResponse::V1) + .map_err(|reason| signing_call_error(HostSignRawError::V1, reason)) } #[instrument(skip_all, fields(runtime.method = "signing.create_transaction"))] @@ -1028,15 +1101,18 @@ impl Signing for ProductRuntimeHost { v01::HostCreateTransactionError::Rejected, ))); } - self.authority - .create_transaction( - cx, + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.create_transaction( + &cx, &session, CreateTransactionAuthorityRequest::Product(inner), - ) - .await - .map(HostCreateTransactionResponse::V1) - .map_err(|reason| transaction_call_error(HostCreateTransactionError::V1, reason)) + ), + ) + .await + .map(HostCreateTransactionResponse::V1) + .map_err(|reason| transaction_call_error(HostCreateTransactionError::V1, reason)) } #[instrument(skip_all, fields(runtime.method = "signing.sign_payload_with_legacy_account"))] @@ -1075,9 +1151,11 @@ impl Signing for ProductRuntimeHost { HostSignPayloadWithLegacyAccountError::V1(v01::HostSignPayloadError::Rejected), )); } - self.authority - .sign_payload( - cx, + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.sign_payload( + &cx, &session, SignPayloadAuthorityRequest::LegacyAccount { product_account: v01::ProductAccountId { @@ -1086,10 +1164,11 @@ impl Signing for ProductRuntimeHost { }, request: inner, }, - ) - .await - .map(HostSignPayloadWithLegacyAccountResponse::V1) - .map_err(|reason| signing_call_error(HostSignPayloadWithLegacyAccountError::V1, reason)) + ), + ) + .await + .map(HostSignPayloadWithLegacyAccountResponse::V1) + .map_err(|reason| signing_call_error(HostSignPayloadWithLegacyAccountError::V1, reason)) } #[instrument(skip_all, fields(runtime.method = "signing.sign_raw_with_legacy_account"))] @@ -1126,9 +1205,11 @@ impl Signing for ProductRuntimeHost { v01::HostSignPayloadError::Rejected, ))); } - self.authority - .sign_raw( - cx, + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.sign_raw( + &cx, &session, SignRawAuthorityRequest::LegacyAccount { product_account: v01::ProductAccountId { @@ -1137,10 +1218,11 @@ impl Signing for ProductRuntimeHost { }, request: inner, }, - ) - .await - .map(HostSignRawWithLegacyAccountResponse::V1) - .map_err(|reason| signing_call_error(HostSignRawWithLegacyAccountError::V1, reason)) + ), + ) + .await + .map(HostSignRawWithLegacyAccountResponse::V1) + .map_err(|reason| signing_call_error(HostSignRawWithLegacyAccountError::V1, reason)) } #[instrument(skip_all, fields(runtime.method = "signing.create_transaction_with_legacy_account"))] @@ -1185,9 +1267,11 @@ impl Signing for ProductRuntimeHost { ), )); } - self.authority - .create_transaction( - cx, + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.create_transaction( + &cx, &session, CreateTransactionAuthorityRequest::LegacyAccount { product_account: v01::ProductAccountId { @@ -1196,18 +1280,19 @@ impl Signing for ProductRuntimeHost { }, request: inner, }, + ), + ) + .await + .map(|response| { + HostCreateTransactionWithLegacyAccountResponse::V1( + v01::HostCreateTransactionWithLegacyAccountResponse { + transaction: response.transaction, + }, ) - .await - .map(|response| { - HostCreateTransactionWithLegacyAccountResponse::V1( - v01::HostCreateTransactionWithLegacyAccountResponse { - transaction: response.transaction, - }, - ) - }) - .map_err(|reason| { - transaction_call_error(HostCreateTransactionWithLegacyAccountError::V1, reason) - }) + }) + .map_err(|reason| { + transaction_call_error(HostCreateTransactionWithLegacyAccountError::V1, reason) + }) } } @@ -1542,17 +1627,21 @@ impl ResourceAllocation for ProductRuntimeHost { }, ))); } - self.authority - .allocate_resources(cx, &session, self.product_id(), inner) - .await - .map(HostRequestResourceAllocationResponse::V1) - .map_err(|err| { - CallError::Domain(HostRequestResourceAllocationError::V1( - v01::ResourceAllocationError::Unknown { - reason: err.reason(), - }, - )) - }) + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority + .allocate_resources(&cx, &session, self.product_id(), inner), + ) + .await + .map(HostRequestResourceAllocationResponse::V1) + .map_err(|err| { + CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { + reason: err.reason(), + }, + )) + }) } } // --------------------------------------------------------------------------- @@ -1747,6 +1836,27 @@ mod tests { } } + fn recorded_rpc_methods(sent_rpc: &Mutex>) -> Vec { + sent_rpc + .lock() + .expect("rpc list mutex poisoned") + .iter() + .map(|request| { + serde_json::from_str::(request).unwrap()["method"] + .as_str() + .unwrap() + .to_string() + }) + .collect() + } + + fn recorded_rpc_method_count(sent_rpc: &Mutex>, method: &str) -> usize { + recorded_rpc_methods(sent_rpc) + .iter() + .filter(|candidate| candidate.as_str() == method) + .count() + } + #[test] fn feature_supported_round_trips_through_runtime() { let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); @@ -2608,6 +2718,110 @@ mod tests { ); } + #[test] + fn sign_raw_uses_call_context_timeout_for_sso_response_wait() { + let session = sso_session_info(); + let message_id = "sign-raw-timeout"; + let mut rpc_responses = sso_success_responses( + &session, + message_id, + sign_response_message(message_id, vec![], None), + ); + rpc_responses.truncate(3); + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + rpc_responses, + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session); + let mut cx = CallContext::with_request_id(message_id.to_string()); + cx.set_timeout(std::time::Duration::from_millis(1)); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + + match err { + CallError::Domain(HostSignRawError::V1(v01::HostSignPayloadError::Unknown { + reason, + })) => assert_eq!( + reason, + "Account authority request timed out after 1ms for sign-raw-timeout" + ), + other => panic!("expected SSO response timeout, got {other:?}"), + } + + wait_until( + || recorded_rpc_method_count(&platform.sent_rpc, "statement_unsubscribeStatement") == 2, + "timed-out SSO request did not unsubscribe statement streams", + ); + } + + #[test] + fn sign_raw_cancellation_unsubscribes_sso_subscriptions() { + let session = sso_session_info(); + let message_id = "sign-raw-cancel"; + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + rpc_responses: vec![ + subscribe_ack_frame("truapi:1", "own-sub-sign-raw-cancel"), + subscribe_ack_frame("truapi:2", "peer-sub-sign-raw-cancel"), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session); + let cancel = truapi::CancellationToken::new(); + let cx = CallContext::with_parts(message_id.to_string(), cancel.clone()); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let handle = std::thread::spawn(move || { + futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err() + }); + + wait_until( + || recorded_rpc_method_count(&platform.sent_rpc, "statement_subscribeStatement") == 2, + "SSO subscriptions were not established before cancellation", + ); + cancel.cancel(); + + let err = handle + .join() + .expect("sign_raw cancellation thread panicked"); + match err { + CallError::Domain(HostSignRawError::V1(v01::HostSignPayloadError::Unknown { + reason, + })) => { + assert!( + reason.contains("cancelled"), + "expected cancellation, got {reason}" + ); + assert!( + reason.contains(message_id), + "expected request id in cancellation reason, got {reason}" + ); + } + other => panic!("expected SSO cancellation, got {other:?}"), + } + + wait_until( + || recorded_rpc_method_count(&platform.sent_rpc, "statement_unsubscribeStatement") == 2, + "cancelled SSO request did not unsubscribe statement streams", + ); + } + #[test] fn sign_raw_peer_disconnect_clears_session_store_and_broadcasts() { let session = sso_session_info(); diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index 1350bccb..c4c808fd 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -1,4 +1,12 @@ +//! Role-neutral account authority contracts used by product runtimes. +//! +//! Pairing and signing hosts implement these traits differently, but +//! `ProductRuntimeHost` can use this module's shared request/session types +//! without knowing where the key material lives. + use async_trait::async_trait; +use core::fmt; +use core::time::Duration; use std::sync::Arc; use truapi::latest::{ HostAccountGetAliasResponse, HostCreateTransactionResponse, @@ -8,7 +16,7 @@ use truapi::latest::{ ProductAccountId, ProductAccountTxPayload, }; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; -use truapi::{CallContext, CallError}; +use truapi::{CallContext, CallError, CancellationReason}; use truapi_platform::ProductContext; use crate::host_logic::session::{SessionInfo, SessionState}; @@ -53,6 +61,9 @@ pub(crate) enum AuthorityError { /// The selected authority session is no longer active. #[display("Disconnected")] Disconnected, + /// The authority call was cancelled before completion. + #[display("{_0}")] + Cancelled(AuthorityCancelError), /// The authority cannot service the request. #[display("{reason}")] Unavailable { reason: String }, @@ -67,6 +78,50 @@ impl AuthorityError { } } +/// Cancellation cause for an account-authority call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AuthorityCancelError { + request_id: String, + reason: CancellationReason, +} + +impl AuthorityCancelError { + pub(crate) fn new(request_id: &str, reason: CancellationReason) -> Self { + Self { + request_id: request_id.to_string(), + reason, + } + } +} + +impl fmt::Display for AuthorityCancelError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let request = if self.request_id.is_empty() { + String::new() + } else { + format!(" for {}", self.request_id) + }; + match &self.reason { + CancellationReason::Cancelled => { + write!(f, "Account authority request cancelled{request}") + } + CancellationReason::TimedOut { timeout } => write!( + f, + "Account authority request timed out after {}{request}", + format_timeout_duration(*timeout) + ), + } + } +} + +fn format_timeout_duration(duration: Duration) -> String { + if duration.subsec_millis() == 0 { + format!("{}s", duration.as_secs()) + } else { + format!("{}ms", duration.as_millis()) + } +} + /// Payload-signing request selected by the product API entrypoint. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum SignPayloadAuthorityRequest { diff --git a/rust/crates/truapi-server/src/runtime/identity.rs b/rust/crates/truapi-server/src/runtime/identity.rs index d5bda8e9..0c36a897 100644 --- a/rust/crates/truapi-server/src/runtime/identity.rs +++ b/rust/crates/truapi-server/src/runtime/identity.rs @@ -1,31 +1,24 @@ -//! People-chain identity lookup used to resolve usernames for a paired -//! session. - -use std::sync::atomic::{AtomicU64, Ordering}; +//! People-chain identity lookup used to resolve usernames for a paired session. #[cfg(not(target_arch = "wasm32"))] use std::time::Duration; #[cfg(target_arch = "wasm32")] use web_time::Duration; -use crate::chain_runtime::ChainRuntime; +use crate::chain_runtime::{ + ChainRuntime, wait_for_chain_head_best_hash, wait_for_chain_head_storage_value, +}; use crate::host_logic::identity::{ PeopleIdentity, decode_people_identity, resources_consumers_storage_key, }; use crate::host_logic::session::SessionInfo; -use futures::stream::BoxStream; -use futures::{FutureExt, StreamExt, pin_mut}; use tracing::{debug, instrument, warn}; use truapi::latest::{ - OperationStartedResult, RemoteChainHeadFollowItem, RemoteChainHeadFollowRequest, - RemoteChainHeadStorageRequest, StorageQueryItem, StorageQueryType, + OperationStartedResult, RemoteChainHeadFollowRequest, RemoteChainHeadStorageRequest, + StorageQueryItem, StorageQueryType, }; -/// Monotonic salt for local identity lookup follow ids, avoiding collisions -/// between concurrent People-chain identity lookups. -static IDENTITY_LOOKUP_COUNTER: AtomicU64 = AtomicU64::new(1); - /// Fill in missing usernames by querying the people chain; returns the /// session unchanged when it already carries a username or no people chain /// is configured. @@ -35,7 +28,7 @@ pub(super) async fn resolve_session_identity_with_chain( people_chain_genesis_hash: [u8; 32], mut session: SessionInfo, ) -> SessionInfo { - if session_has_username(&session) || people_chain_genesis_hash == [0; 32] { + if session.has_username() || people_chain_genesis_hash == [0; 32] { return session; } @@ -81,7 +74,7 @@ async fn lookup_and_apply( full_username = identity.full_username.as_deref().unwrap_or(""), "People-chain {label} lookup found username" ); - apply_people_identity(session, identity); + session.apply_usernames(identity.lite_username, identity.full_username); true } Ok(None) => { @@ -102,23 +95,6 @@ async fn lookup_and_apply( } } -fn non_empty(value: &Option) -> bool { - value.as_ref().is_some_and(|value| !value.is_empty()) -} - -fn session_has_username(session: &SessionInfo) -> bool { - non_empty(&session.full_username) || non_empty(&session.lite_username) -} - -fn apply_people_identity(session: &mut SessionInfo, identity: PeopleIdentity) { - if non_empty(&identity.full_username) { - session.full_username = identity.full_username; - } - if non_empty(&identity.lite_username) { - session.lite_username = identity.lite_username; - } -} - #[instrument(skip_all, fields(runtime.method = "session.identity.lookup"))] async fn lookup_people_identity( chain: &ChainRuntime, @@ -127,11 +103,16 @@ async fn lookup_people_identity( ) -> Result, String> { let genesis_hash = people_chain_genesis_hash.to_vec(); let key = resources_consumers_storage_key(&account_id); - let follow_id = format!( - "truapi:identity:{}:{}", - IDENTITY_LOOKUP_COUNTER.fetch_add(1, Ordering::Relaxed), - hex::encode(account_id), - ); + let lookup_id = { + use std::sync::atomic::{AtomicU64, Ordering}; + + /// Monotonic salt for local identity lookup follow ids, avoiding + /// collisions between concurrent People-chain identity lookups. + static IDENTITY_LOOKUP_COUNTER: AtomicU64 = AtomicU64::new(1); + + IDENTITY_LOOKUP_COUNTER.fetch_add(1, Ordering::Relaxed) + }; + let follow_id = format!("truapi:identity:{}:{}", lookup_id, hex::encode(account_id),); let mut follow = chain.remote_chain_head_follow( follow_id.clone(), RemoteChainHeadFollowRequest { @@ -140,7 +121,13 @@ async fn lookup_people_identity( }, ); - let hash = wait_for_identity_follow_hash(&mut follow).await?; + let hash = wait_for_chain_head_best_hash( + &mut follow, + "People-chain", + Duration::from_secs(10), + Duration::from_secs(2), + ) + .await?; let response = chain .remote_chain_head_storage(RemoteChainHeadStorageRequest { genesis_hash, @@ -161,166 +148,16 @@ async fn lookup_people_identity( return Err("People-chain storage lookup limit reached".to_string()); } }; - let Some(value) = wait_for_identity_storage_value(&mut follow, &operation_id, &key).await? + let Some(value) = wait_for_chain_head_storage_value( + &mut follow, + &operation_id, + &key, + "People-chain", + Duration::from_secs(10), + ) + .await? else { return Ok(None); }; decode_people_identity(&value).map(Some) } - -#[instrument(skip_all, fields(runtime.method = "session.identity.wait_follow_hash"))] -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(); - pin_mut!(timeout); - loop { - let next = follow.next().fuse(); - pin_mut!(next); - futures::select! { - item = next => match item { - Some(RemoteChainHeadFollowItem::Initialized { finalized_block_hashes, .. }) => { - let fallback = finalized_block_hashes.last().cloned(); - return wait_for_identity_best_hash(follow, fallback).await; - } - Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { - return Ok(best_block_hash); - } - Some(RemoteChainHeadFollowItem::Stop) | None => { - return Err("People-chain follow stopped before initialization".to_string()); - } - _ => {} - }, - () = timeout => return Err("People-chain follow initialization timed out".to_string()), - } - } -} - -async fn wait_for_identity_best_hash( - follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, - fallback: Option>, -) -> Result, String> { - let timeout = futures_timer::Delay::new(Duration::from_secs(2)).fuse(); - pin_mut!(timeout); - let mut candidate = fallback; - loop { - let next = follow.next().fuse(); - pin_mut!(next); - futures::select! { - item = next => match item { - Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { - return Ok(best_block_hash); - } - Some(RemoteChainHeadFollowItem::NewBlock { block_hash, .. }) => { - candidate = Some(block_hash); - } - Some(RemoteChainHeadFollowItem::Stop) | None => { - return candidate.ok_or_else(|| { - "People-chain follow stopped before best block".to_string() - }); - } - _ => {} - }, - () = timeout => { - return candidate.ok_or_else(|| { - "People-chain follow best block timed out".to_string() - }); - }, - } - } -} - -#[instrument(skip_all, fields(runtime.method = "session.identity.wait_storage_value"))] -async fn wait_for_identity_storage_value( - follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, - operation_id: &str, - key: &[u8], -) -> Result>, String> { - let timeout = futures_timer::Delay::new(Duration::from_secs(10)).fuse(); - pin_mut!(timeout); - let mut value = None; - loop { - let next = follow.next().fuse(); - pin_mut!(next); - futures::select! { - item = next => match item { - Some(RemoteChainHeadFollowItem::OperationStorageItems { operation_id: item_operation_id, items }) - if item_operation_id == operation_id => - { - for item in items { - if item.key == key { - value = item.value; - } - } - } - Some(RemoteChainHeadFollowItem::OperationStorageDone { operation_id: item_operation_id }) - if item_operation_id == operation_id => - { - return Ok(value); - } - Some(RemoteChainHeadFollowItem::OperationInaccessible { operation_id: item_operation_id }) - if item_operation_id == operation_id => - { - return Ok(None); - } - Some(RemoteChainHeadFollowItem::OperationError { operation_id: item_operation_id, error }) - if item_operation_id == operation_id => - { - return Err(error); - } - Some(RemoteChainHeadFollowItem::Stop) | None => { - return Err("People-chain follow stopped during storage lookup".to_string()); - } - _ => {} - }, - () = timeout => return Err("People-chain storage lookup timed out".to_string()), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use futures::stream; - - #[test] - fn identity_follow_prefers_best_block_after_initialization() { - let mut follow = stream::iter(vec![ - RemoteChainHeadFollowItem::Initialized { - finalized_block_hashes: vec![vec![0x01]], - finalized_block_runtime: None, - }, - RemoteChainHeadFollowItem::BestBlockChanged { - best_block_hash: vec![0x02], - }, - ]) - .boxed(); - - let hash = futures::executor::block_on(wait_for_identity_follow_hash(&mut follow)) - .expect("best hash should resolve"); - - assert_eq!(hash, vec![0x02]); - } - - #[test] - fn identity_follow_uses_new_block_before_stale_finalized_fallback() { - let mut follow = stream::iter(vec![ - RemoteChainHeadFollowItem::Initialized { - finalized_block_hashes: vec![vec![0x01]], - finalized_block_runtime: None, - }, - RemoteChainHeadFollowItem::NewBlock { - block_hash: vec![0x03], - parent_block_hash: vec![0x01], - new_runtime: None, - }, - RemoteChainHeadFollowItem::Stop, - ]) - .boxed(); - - let hash = futures::executor::block_on(wait_for_identity_follow_hash(&mut follow)) - .expect("new block hash should resolve"); - - assert_eq!(hash, vec![0x03]); - } -} diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index 6a9acd05..9f9ec59e 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -5,17 +5,12 @@ //! Role policy (session lifecycle, login, revalidation) stays in the parent //! module. -#[cfg(not(target_arch = "wasm32"))] -use std::time::Duration; -#[cfg(target_arch = "wasm32")] -use web_time::Duration; - use super::super::authority::{ - AuthorityError, CreateTransactionAuthorityRequest, SignPayloadAuthorityRequest, - SignRawAuthorityRequest, + AuthorityCancelError, AuthorityError, CreateTransactionAuthorityRequest, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; use super::super::sso_remote::{ - SSO_LOCAL_DISCONNECT_REASON, SSO_PEER_DISCONNECT_REASON, SsoRemoteResponseWait, SsoSessionKey, + SSO_LOCAL_DISCONNECT_REASON, SSO_PEER_DISCONNECT_REASON, SsoRemoteResponseError, SsoSessionKey, fresh_statement_expiry, sso_message_id, statement_subscription_stream, subscribe_statement_topic, wait_for_sso_remote_response, }; @@ -34,12 +29,8 @@ use crate::host_logic::statement_store::parse_new_statements_result; use futures::FutureExt; use futures::future::{AbortHandle, Abortable}; use tracing::{debug, instrument, warn}; -use truapi::{CallContext, v01}; +use truapi::{CallContext, latest}; -/// Host-spec B.6.2 recommends timing out unanswered SSO application requests -/// after 180 seconds: -/// -const DEFAULT_SSO_RESPONSE_TIMEOUT: Duration = Duration::from_secs(180); const UNEXPECTED_SSO_SIGNING_RESPONSE: &str = "Unexpected SSO response for signing request"; const UNEXPECTED_SSO_TRANSACTION_RESPONSE: &str = "Unexpected SSO response for transaction request"; @@ -72,25 +63,22 @@ impl PairingHost { session: &SessionInfo, action: AuthorityRequestKind, message: RemoteMessage, - ) -> Result { + ) -> Result { let response = self - .submit_remote_message( - cx, - session, - RemoteAction::Signing(action), - message, - Some(DEFAULT_SSO_RESPONSE_TIMEOUT), - ) + .submit_remote_message(cx, session, RemoteAction::Signing(action), message) .await?; let SsoRemoteResponse::Sign(response) = response else { - return Err(UNEXPECTED_SSO_SIGNING_RESPONSE.to_string()); + return Err(SsoRemoteResponseError::Failure( + UNEXPECTED_SSO_SIGNING_RESPONSE.to_string(), + )); }; response .payload - .map(|payload| v01::HostSignPayloadResponse { + .map(|payload| latest::HostSignPayloadResponse { signature: payload.signature, signed_transaction: payload.signed_transaction, }) + .map_err(SsoRemoteResponseError::Failure) } fn stop_disconnect_monitor(&self) { @@ -188,8 +176,8 @@ impl PairingHost { /// Submit an SSO remote message and wait for the signing-host response. /// - /// `timeout = None` is reserved for flows that block on remote user - /// interaction, such as alias approval. + /// Calls without a [`CallContext`] timeout block until response, + /// disconnect, or stream completion. #[instrument(skip_all, fields(runtime.method = "sso.remote_message.submit", action = %action))] async fn submit_remote_message( &self, @@ -197,16 +185,15 @@ impl PairingHost { session: &SessionInfo, action: RemoteAction, message: RemoteMessage, - timeout: Option, - ) -> Result { + ) -> Result { let sso = session .sso .as_ref() - .ok_or_else(|| "No SSO session state".to_string())?; + .ok_or_else(|| SsoRemoteResponseError::Failure("No SSO session state".to_string()))?; let key = SsoSessionKey::from_session(sso); let (_disconnect_guard, disconnect) = self.session_disconnects.subscribe(sso); if !session_matches_key(&self.session_state, key) { - return Err(SSO_LOCAL_DISCONNECT_REASON.to_string()); + return Err(SsoRemoteResponseError::LocalDisconnected); } let message_id = sso_message_id(cx, action); let statement = build_outgoing_request_statement( @@ -214,23 +201,34 @@ impl PairingHost { message_id.clone(), vec![message], fresh_statement_expiry(), - )?; + ) + .map_err(SsoRemoteResponseError::Failure)?; let rpc_client = self.statement_store.client("SSO statement-store").await?; let own_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_own) .await - .map_err(|err| format!("SSO own statement-store subscribe failed: {err}"))?; + .map_err(|err| { + SsoRemoteResponseError::Failure(format!( + "SSO own statement-store subscribe failed: {err}" + )) + })?; let peer_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_peer) .await - .map_err(|err| format!("SSO peer statement-store subscribe failed: {err}"))?; + .map_err(|err| { + SsoRemoteResponseError::Failure(format!( + "SSO peer statement-store subscribe failed: {err}" + )) + })?; let submit_client = rpc_client.clone(); let session_state = self.session_state.clone(); let submit = async move { if !session_matches_key(&session_state, key) { - return Err(SSO_LOCAL_DISCONNECT_REASON.to_string()); + return Err(SsoRemoteResponseError::LocalDisconnected); } statement_store_rpc::submit(&submit_client, statement) .await - .map_err(|err| format!("SSO statement submit failed: {err}")) + .map_err(|err| { + SsoRemoteResponseError::Failure(format!("SSO statement submit failed: {err}")) + }) } .boxed(); let action = action.to_string(); @@ -239,20 +237,18 @@ impl PairingHost { statement_subscription_stream(own_subscription, "own"), statement_subscription_stream(peer_subscription, "peer"), submit, - SsoRemoteResponseWait { - session: sso, - statement_request_id: &message_id, - remote_message_id: &message_id, - timeout, - disconnect: Some(disconnect), - }, + sso, + &message_id, + &message_id, + cx.cancel(), + Some(disconnect), ) .await; match &result { Ok(_) => debug!(action, %message_id, "SSO remote response received"), Err(reason) => warn!(action, %message_id, %reason, "SSO remote message failed"), } - if matches!(&result, Err(reason) if reason == SSO_PEER_DISCONNECT_REASON) { + if matches!(&result, Err(SsoRemoteResponseError::PeerDisconnected)) { self.handle_signing_host_disconnected(key).await; } result @@ -263,7 +259,7 @@ impl PairingHost { cx: &CallContext, session: &SessionInfo, request: SignPayloadAuthorityRequest, - ) -> Result { + ) -> Result { let action = AuthorityRequestKind::from(&request); let message_id = sso_message_id(cx, RemoteAction::Signing(action)); let request = match request { @@ -271,7 +267,7 @@ impl PairingHost { SignPayloadAuthorityRequest::LegacyAccount { product_account, request, - } => v01::HostSignPayloadRequest { + } => latest::HostSignPayloadRequest { account: product_account, payload: request.payload, }, @@ -287,7 +283,7 @@ impl PairingHost { cx: &CallContext, session: &SessionInfo, request: SignRawAuthorityRequest, - ) -> Result { + ) -> Result { let action = AuthorityRequestKind::from(&request); let message_id = sso_message_id(cx, RemoteAction::Signing(action)); let request = match request { @@ -295,20 +291,14 @@ impl PairingHost { SignRawAuthorityRequest::LegacyAccount { product_account, request, - } => v01::HostSignRawRequest { + } => latest::HostSignRawRequest { account: product_account, payload: request.payload, }, }; let message = sign_raw_message(message_id, request); let response = self - .submit_remote_message( - cx, - session, - RemoteAction::Signing(action), - message, - Some(DEFAULT_SSO_RESPONSE_TIMEOUT), - ) + .submit_remote_message(cx, session, RemoteAction::Signing(action), message) .await .map_err(remote_authority_error)?; let SsoRemoteResponse::Sign(response) = response else { @@ -318,7 +308,7 @@ impl PairingHost { }; response .payload - .map(|payload| v01::HostSignPayloadResponse { + .map(|payload| latest::HostSignPayloadResponse { signature: payload.signature, signed_transaction: payload.signed_transaction, }) @@ -330,7 +320,7 @@ impl PairingHost { cx: &CallContext, session: &SessionInfo, request: CreateTransactionAuthorityRequest, - ) -> Result { + ) -> Result { let action = AuthorityRequestKind::from(&request); let message_id = sso_message_id(cx, RemoteAction::Signing(action)); let request = match request { @@ -338,7 +328,7 @@ impl PairingHost { CreateTransactionAuthorityRequest::LegacyAccount { product_account, request, - } => v01::ProductAccountTxPayload { + } => latest::ProductAccountTxPayload { signer: product_account, genesis_hash: request.genesis_hash, call_data: request.call_data, @@ -348,13 +338,7 @@ impl PairingHost { }; let message = create_transaction_message(message_id, request); let response = self - .submit_remote_message( - cx, - session, - RemoteAction::Signing(action), - message, - Some(DEFAULT_SSO_RESPONSE_TIMEOUT), - ) + .submit_remote_message(cx, session, RemoteAction::Signing(action), message) .await .map_err(remote_authority_error)?; let SsoRemoteResponse::CreateTransaction(response) = response else { @@ -364,7 +348,7 @@ impl PairingHost { }; response .signed_transaction - .map(|transaction| v01::HostCreateTransactionResponse { transaction }) + .map(|transaction| latest::HostCreateTransactionResponse { transaction }) .map_err(remote_authority_error) } @@ -372,9 +356,9 @@ impl PairingHost { &self, cx: &CallContext, session: &SessionInfo, - product_account_id: v01::ProductAccountId, + product_account_id: latest::ProductAccountId, requesting_product_id: String, - ) -> Result { + ) -> Result { let message_id = sso_message_id(cx, RemoteAction::AccountAlias); let message = alias_request_message( message_id.clone(), @@ -382,7 +366,7 @@ impl PairingHost { requesting_product_id, ); let response = self - .submit_remote_message(cx, session, RemoteAction::AccountAlias, message, None) + .submit_remote_message(cx, session, RemoteAction::AccountAlias, message) .await .map_err(remote_authority_error)?; let SsoRemoteResponse::RingVrfAlias(response) = response else { @@ -398,8 +382,8 @@ impl PairingHost { cx: &CallContext, session: &SessionInfo, product_id: String, - request: v01::HostRequestResourceAllocationRequest, - ) -> Result { + request: latest::HostRequestResourceAllocationRequest, + ) -> Result { let message_id = sso_message_id(cx, RemoteAction::ResourceAllocation); let message = resource_allocation_message( message_id, @@ -408,13 +392,7 @@ impl PairingHost { OnExistingAllowancePolicy::Increase, ); let response = self - .submit_remote_message( - cx, - session, - RemoteAction::ResourceAllocation, - message, - Some(DEFAULT_SSO_RESPONSE_TIMEOUT), - ) + .submit_remote_message(cx, session, RemoteAction::ResourceAllocation, message) .await .map_err(remote_authority_error)?; let SsoRemoteResponse::ResourceAllocation(response) = response else { @@ -424,7 +402,7 @@ impl PairingHost { }; response .payload - .map(|outcomes| v01::HostRequestResourceAllocationResponse { + .map(|outcomes| latest::HostRequestResourceAllocationResponse { outcomes: outcomes.into_iter().map(Into::into).collect(), }) .map_err(remote_authority_error) @@ -441,11 +419,21 @@ pub(super) fn session_matches_key(session_state: &SessionState, key: SsoSessionK }) } -fn remote_authority_error(reason: String) -> AuthorityError { - match reason.as_str() { - "Rejected" | "User rejected" => AuthorityError::Rejected, - SSO_LOCAL_DISCONNECT_REASON | SSO_PEER_DISCONNECT_REASON => AuthorityError::Disconnected, - _ => AuthorityError::Unknown { reason }, +fn remote_authority_error(reason: impl Into) -> AuthorityError { + match reason.into() { + SsoRemoteResponseError::Cancelled(err) => AuthorityError::Cancelled( + AuthorityCancelError::new(err.remote_message_id(), err.reason()), + ), + SsoRemoteResponseError::LocalDisconnected | SsoRemoteResponseError::PeerDisconnected => { + AuthorityError::Disconnected + } + SsoRemoteResponseError::Failure(reason) => match reason.as_str() { + "Rejected" | "User rejected" => AuthorityError::Rejected, + SSO_LOCAL_DISCONNECT_REASON | SSO_PEER_DISCONNECT_REASON => { + AuthorityError::Disconnected + } + _ => AuthorityError::Unknown { reason }, + }, } } @@ -480,7 +468,7 @@ async fn wait_for_sso_peer_disconnect( Err("SSO disconnect monitor response stream ended".to_string()) } -impl From for v01::AllocationOutcome { +impl From for latest::AllocationOutcome { fn from(outcome: SsoAllocationOutcome) -> Self { match outcome { SsoAllocationOutcome::Allocated(_) => Self::Allocated, diff --git a/rust/crates/truapi-server/src/runtime/sso_remote.rs b/rust/crates/truapi-server/src/runtime/sso_remote.rs index 6d0d383d..286e94d4 100644 --- a/rust/crates/truapi-server/src/runtime/sso_remote.rs +++ b/rust/crates/truapi-server/src/runtime/sso_remote.rs @@ -3,7 +3,7 @@ //! matching response, honoring timeouts and local/peer disconnect signals. use core::mem; -use std::fmt::Display; +use std::fmt::{self, Display}; use std::sync::Mutex; #[cfg(not(target_arch = "wasm32"))] @@ -26,7 +26,7 @@ use serde_json::Value; use subxt_rpcs::RpcClient; use subxt_rpcs::client::RpcSubscription; use tracing::instrument; -use truapi::CallContext; +use truapi::{CallContext, CancellationReason, CancellationToken}; /// Host-spec B.3.3 recommends seven-day statement expiry for session traffic: /// @@ -35,6 +35,8 @@ const DEFAULT_SSO_STATEMENT_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60; pub(super) const SSO_LOCAL_DISCONNECT_REASON: &str = "SSO session disconnected"; /// Disconnect reason reported when the paired signing host announces a disconnect. pub(super) const SSO_PEER_DISCONNECT_REASON: &str = "SSO peer disconnected"; +/// Reason reported when the product caller cancels a pending SSO request. +const SSO_CALL_CANCELLED_REASON: &str = "SSO response wait cancelled by caller"; /// Registry of oneshot waiters resolved when the SSO session disconnects. #[derive(Default)] @@ -136,64 +138,126 @@ impl SessionDisconnects { } } -pub(super) struct SsoRemoteResponseWait<'a> { - pub(super) session: &'a SsoSessionInfo, - pub(super) statement_request_id: &'a str, - pub(super) remote_message_id: &'a str, - pub(super) timeout: Option, - pub(super) disconnect: Option>, +pub(super) type StatementPageStream = BoxStream<'static, Result>; +pub(super) type StatementSubmitFuture = BoxFuture<'static, Result<(), SsoRemoteResponseError>>; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct CancelError { + reason: CancellationReason, + remote_message_id: String, } -pub(super) type StatementPageStream = BoxStream<'static, Result>; -pub(super) type StatementSubmitFuture = BoxFuture<'static, Result<(), String>>; +impl CancelError { + fn new(reason: CancellationReason, remote_message_id: &str) -> Self { + Self { + reason, + remote_message_id: remote_message_id.to_string(), + } + } + + pub(super) fn reason(&self) -> CancellationReason { + self.reason.clone() + } + + pub(super) fn remote_message_id(&self) -> &str { + &self.remote_message_id + } +} + +impl Display for CancelError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.reason { + CancellationReason::Cancelled => { + write!( + f, + "{SSO_CALL_CANCELLED_REASON} for {}", + self.remote_message_id + ) + } + CancellationReason::TimedOut { timeout } => write!( + f, + "SSO response timed out after {} for {}", + format_timeout_duration(*timeout), + self.remote_message_id + ), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum SsoRemoteResponseError { + Cancelled(CancelError), + LocalDisconnected, + PeerDisconnected, + Failure(String), +} + +impl Display for SsoRemoteResponseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Cancelled(err) => err.fmt(f), + Self::LocalDisconnected => f.write_str(SSO_LOCAL_DISCONNECT_REASON), + Self::PeerDisconnected => f.write_str(SSO_PEER_DISCONNECT_REASON), + Self::Failure(reason) => f.write_str(reason), + } + } +} + +impl From for SsoRemoteResponseError { + fn from(reason: String) -> Self { + Self::Failure(reason) + } +} + +fn disconnect_error(reason: String) -> SsoRemoteResponseError { + match reason.as_str() { + SSO_LOCAL_DISCONNECT_REASON => SsoRemoteResponseError::LocalDisconnected, + SSO_PEER_DISCONNECT_REASON => SsoRemoteResponseError::PeerDisconnected, + _ => SsoRemoteResponseError::Failure(reason), + } +} #[instrument(skip_all, fields(runtime.method = "sso.remote_response.wait"))] pub(super) async fn wait_for_sso_remote_response( own_statements: StatementPageStream, peer_statements: StatementPageStream, submit: StatementSubmitFuture, - wait: SsoRemoteResponseWait<'_>, -) -> Result { - let timeout_reason = wait.timeout.map(|timeout| { - format!( - "SSO response timed out after {} for {}", - format_timeout_duration(timeout), - wait.remote_message_id - ) - }); + session: &SsoSessionInfo, + statement_request_id: &str, + remote_message_id: &str, + cancel: &CancellationToken, + disconnect: Option>, +) -> Result { let response = wait_for_sso_remote_response_inner( own_statements, peer_statements, submit, - wait.session, - wait.statement_request_id, - wait.remote_message_id, + session, + statement_request_id, + remote_message_id, ) .fuse(); - let timeout = async move { - match (wait.timeout, timeout_reason) { - (Some(timeout), Some(reason)) => { - futures_timer::Delay::new(timeout).await; - reason - } - _ => futures::future::pending::().await, + let disconnect = async move { + match disconnect { + Some(rx) => match rx.await { + Ok(reason) => disconnect_error(reason), + Err(_) => SsoRemoteResponseError::LocalDisconnected, + }, + None => futures::future::pending::().await, } } .fuse(); - let disconnect = async move { - match wait.disconnect { - Some(rx) => rx - .await - .unwrap_or_else(|_| SSO_LOCAL_DISCONNECT_REASON.to_string()), - None => futures::future::pending::().await, - } + let cancel_message_id = remote_message_id.to_string(); + let cancelled = async move { + let reason = cancel.cancelled().await; + SsoRemoteResponseError::Cancelled(CancelError::new(reason, &cancel_message_id)) } .fuse(); - pin_mut!(response, timeout, disconnect); + pin_mut!(response, disconnect, cancelled); futures::select! { result = response => result, - reason = timeout => Err(reason), reason = disconnect => Err(reason), + reason = cancelled => Err(reason), } } @@ -205,7 +269,7 @@ async fn wait_for_sso_remote_response_inner( session: &SsoSessionInfo, statement_request_id: &str, remote_message_id: &str, -) -> Result { +) -> Result { let mut own_statements = own_statements.fuse(); let mut peer_statements = peer_statements.fuse(); let mut submit = submit.fuse(); @@ -216,10 +280,10 @@ async fn wait_for_sso_remote_response_inner( loop { if own_done && peer_done { - return Err(format!( + return Err(SsoRemoteResponseError::Failure(format!( "SSO response stream ended before response for {}", remote_message_id - )); + ))); } futures::select! { item = own_statements.next() => { @@ -236,7 +300,7 @@ async fn wait_for_sso_remote_response_inner( return Ok(response); } } - Some(Err(reason)) => return Err(reason), + Some(Err(reason)) => return Err(SsoRemoteResponseError::Failure(reason)), None => own_done = true, } } @@ -254,7 +318,7 @@ async fn wait_for_sso_remote_response_inner( return Ok(response); } } - Some(Err(reason)) => return Err(reason), + Some(Err(reason)) => return Err(SsoRemoteResponseError::Failure(reason)), None => peer_done = true, } } @@ -272,16 +336,18 @@ fn handle_sso_remote_statement_page( remote_message_id: &str, request_accepted: &mut bool, pending_remote_response: &mut Option, -) -> Result, String> { +) -> Result, SsoRemoteResponseError> { let page = parse_new_statements_result("sso-remote".to_string(), value) - .map_err(|err| err.to_string())?; + .map_err(|err| SsoRemoteResponseError::Failure(err.to_string()))?; for statement in page.statements { match decode_sso_session_statement( session, &statement, statement_request_id, remote_message_id, - )? { + ) + .map_err(SsoRemoteResponseError::Failure)? + { Some(SsoSessionStatement::RequestAccepted) => { *request_accepted = true; if let Some(response) = pending_remote_response.take() { @@ -295,7 +361,7 @@ fn handle_sso_remote_statement_page( *pending_remote_response = Some(response); } Some(SsoSessionStatement::Disconnected) => { - return Err(SSO_PEER_DISCONNECT_REASON.to_string()); + return Err(SsoRemoteResponseError::PeerDisconnected); } None => {} } @@ -349,23 +415,31 @@ mod tests { use futures::stream; #[test] - fn sso_remote_response_waiter_times_out() { + fn sso_remote_response_waiter_reports_timeout_cancellation() { let session = sso_session_info(); + let cancel = CancellationToken::new(); + cancel.cancel_with_reason(CancellationReason::TimedOut { + timeout: Duration::from_millis(1), + }); let err = futures::executor::block_on(wait_for_sso_remote_response( stream::pending().boxed(), stream::pending().boxed(), futures::future::pending().boxed(), - SsoRemoteResponseWait { - session: session.sso.as_ref().unwrap(), - statement_request_id: "request-1", - remote_message_id: "request-1", - timeout: Some(Duration::from_millis(1)), - disconnect: None, - }, + session.sso.as_ref().unwrap(), + "request-1", + "request-1", + &cancel, + None, )) .unwrap_err(); - assert_eq!(err, "SSO response timed out after 1ms for request-1"); + let SsoRemoteResponseError::Cancelled(err) = err else { + panic!("expected cancellation error"); + }; + assert_eq!( + err.to_string(), + "SSO response timed out after 1ms for request-1" + ); } #[test] @@ -374,19 +448,24 @@ mod tests { let err = futures::executor::block_on(wait_for_sso_remote_response( stream::pending().boxed(), stream::pending().boxed(), - futures::future::ready(Err("SSO statement submit failed: no allowance".to_string())) - .boxed(), - SsoRemoteResponseWait { - session: session.sso.as_ref().unwrap(), - statement_request_id: "request-1", - remote_message_id: "request-1", - timeout: Some(Duration::from_secs(60)), - disconnect: None, - }, + futures::future::ready(Err(SsoRemoteResponseError::Failure( + "SSO statement submit failed: no allowance".to_string(), + ))) + .boxed(), + session.sso.as_ref().unwrap(), + "request-1", + "request-1", + &CancellationToken::new(), + None, )) .unwrap_err(); - assert_eq!(err, "SSO statement submit failed: no allowance"); + assert_eq!( + err, + SsoRemoteResponseError::Failure( + "SSO statement submit failed: no allowance".to_string() + ) + ); } #[test] @@ -398,17 +477,15 @@ mod tests { stream::pending().boxed(), stream::pending().boxed(), futures::future::pending().boxed(), - SsoRemoteResponseWait { - session: session.sso.as_ref().unwrap(), - statement_request_id: "request-1", - remote_message_id: "request-1", - timeout: Some(Duration::from_secs(60)), - disconnect: Some(rx), - }, + session.sso.as_ref().unwrap(), + "request-1", + "request-1", + &CancellationToken::new(), + Some(rx), )) .unwrap_err(); - assert_eq!(err, SSO_LOCAL_DISCONNECT_REASON); + assert_eq!(err, SsoRemoteResponseError::LocalDisconnected); } #[test] @@ -420,16 +497,41 @@ mod tests { stream::pending().boxed(), stream::pending().boxed(), futures::future::pending().boxed(), - SsoRemoteResponseWait { - session: session.sso.as_ref().unwrap(), - statement_request_id: "request-1", - remote_message_id: "request-1", - timeout: None, - disconnect: Some(rx), - }, + session.sso.as_ref().unwrap(), + "request-1", + "request-1", + &CancellationToken::new(), + Some(rx), )) .unwrap_err(); - assert_eq!(err, SSO_LOCAL_DISCONNECT_REASON); + assert_eq!(err, SsoRemoteResponseError::LocalDisconnected); + } + + #[test] + fn sso_remote_response_waiter_stops_on_call_cancellation() { + let session = sso_session_info(); + let cancel = CancellationToken::new(); + let wait = wait_for_sso_remote_response( + stream::pending().boxed(), + stream::pending().boxed(), + futures::future::pending().boxed(), + session.sso.as_ref().unwrap(), + "request-1", + "request-1", + &cancel, + None, + ); + + cancel.cancel(); + let err = futures::executor::block_on(wait).unwrap_err(); + + let SsoRemoteResponseError::Cancelled(err) = err else { + panic!("expected cancellation error"); + }; + assert_eq!( + err.to_string(), + "SSO response wait cancelled by caller for request-1" + ); } } diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index c37854e9..3c03ccfb 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -7,10 +7,14 @@ #![allow(async_fn_in_trait)] use core::convert::Infallible; +use core::fmt; +use core::future::Future; +use core::mem; use core::pin::Pin; -use core::task::{Context, Poll}; +use core::task::{Context, Poll, Waker}; +use core::time::Duration; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; use futures::Stream; use parity_scale_codec::{Decode, Encode}; @@ -23,10 +27,10 @@ pub mod latest { use crate::versioned::{self, Versioned}; pub use crate::v01::{ - AccountId, AllocatableResource, GenericError, HostSignPayloadData, NotificationId, - OperationStartedResult, ProductAccountId, RawPayload, RemotePermission, RuntimeApi, - RuntimeSpec, RuntimeType, StorageQueryItem, StorageQueryType, StorageResultItem, - ThemeVariant, + AccountId, AllocatableResource, AllocationOutcome, GenericError, HostSignPayloadData, + NotificationId, OperationStartedResult, ProductAccountId, RawPayload, RemotePermission, + RuntimeApi, RuntimeSpec, RuntimeType, StorageQueryItem, StorageQueryType, + StorageResultItem, ThemeVariant, }; pub type LatestOf = ::Latest; @@ -112,11 +116,62 @@ pub type FrameworkOnlyError = CallError; /// Cooperative cancellation token exposed to handlers. /// /// Current one-shot request frames have no cancel control message, so request -/// tokens only fire when a future runtime explicitly cancels them. Subscription -/// runtimes can cancel this token when the peer sends `_stop` or disconnects. -#[derive(Debug, Clone, Default)] +/// tokens fire when a runtime explicitly cancels them or attaches a timeout. +/// Subscription runtimes can cancel this token when the peer sends `_stop` or +/// disconnects. pub struct CancellationToken { - cancelled: Arc, + inner: Arc, +} + +#[derive(Default)] +struct CancellationInner { + state: Mutex, +} + +#[derive(Default)] +struct CancellationState { + reason: Option, + next_id: u64, + wakers: Vec<(u64, Waker)>, +} + +/// Cause attached to a cancelled call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CancellationReason { + /// The caller or runtime explicitly cancelled the call. + Cancelled, + /// The call exceeded the configured timeout. + TimedOut { timeout: Duration }, +} + +/// Future resolved when a [`CancellationToken`] is cancelled. +pub struct CancellationFuture { + inner: Arc, + id: Option, +} + +impl fmt::Debug for CancellationToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CancellationToken") + .field("reason", &self.reason()) + .finish_non_exhaustive() + } +} + +impl Clone for CancellationToken { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl Default for CancellationToken { + fn default() -> Self { + Self { + inner: Arc::new(CancellationInner::default()), + } + } } impl CancellationToken { @@ -127,19 +182,98 @@ impl CancellationToken { /// Mark the token as cancelled. pub fn cancel(&self) { - self.cancelled.store(true, Ordering::SeqCst); + self.cancel_with_reason(CancellationReason::Cancelled); + } + + /// Mark the token as cancelled with an explicit `reason`. + pub fn cancel_with_reason(&self, reason: CancellationReason) { + let wakers = { + let mut state = self.inner.state.lock().expect("cancel state poisoned"); + if state.reason.is_some() { + return; + } + state.reason = Some(reason); + mem::take(&mut state.wakers) + }; + for (_, waker) in wakers { + waker.wake(); + } + } + + /// Returns the cancellation reason, if cancellation has been requested. + pub fn reason(&self) -> Option { + self.inner + .state + .lock() + .expect("cancel state poisoned") + .reason + .clone() } /// Returns whether cancellation has been requested. pub fn is_cancelled(&self) -> bool { - self.cancelled.load(Ordering::SeqCst) + self.reason().is_some() + } + + /// Future resolved with the cancellation reason when cancellation is requested. + pub fn cancelled(&self) -> CancellationFuture { + CancellationFuture { + inner: self.inner.clone(), + id: None, + } + } +} + +impl Future for CancellationFuture { + type Output = CancellationReason; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + let mut state = this.inner.state.lock().expect("cancel state poisoned"); + if let Some(reason) = state.reason.clone() { + this.id = None; + return Poll::Ready(reason); + } + + if let Some(id) = this.id + && let Some((_, waker)) = state + .wakers + .iter_mut() + .find(|(waiter_id, _)| *waiter_id == id) + { + if !waker.will_wake(cx.waker()) { + *waker = cx.waker().clone(); + } + return Poll::Pending; + } + + state.next_id = state.next_id.wrapping_add(1); + let id = state.next_id; + state.wakers.push((id, cx.waker().clone())); + this.id = Some(id); + Poll::Pending + } +} + +impl Drop for CancellationFuture { + fn drop(&mut self) { + let Some(id) = self.id.take() else { + return; + }; + let mut state = self.inner.state.lock().expect("cancel state poisoned"); + if state.reason.is_some() { + return; + } + state.wakers.retain(|(waiter_id, _)| *waiter_id != id); } } /// Ambient context passed to every trait method. +#[derive(Clone)] pub struct CallContext { request_id: RequestId, cancel: CancellationToken, + timeout: Option, } impl CallContext { @@ -153,12 +287,22 @@ impl CallContext { Self { request_id, cancel: CancellationToken::new(), + timeout: None, } } /// Construct a context from explicit `request_id` and `cancel` parts. pub fn with_parts(request_id: RequestId, cancel: CancellationToken) -> Self { - Self { request_id, cancel } + Self { + request_id, + cancel, + timeout: None, + } + } + + /// Attach a timeout to this call. + pub fn set_timeout(&mut self, timeout: Duration) { + self.timeout = Some(timeout); } /// Return the request id this context is associated with. @@ -170,6 +314,11 @@ impl CallContext { pub fn cancel(&self) -> &CancellationToken { &self.cancel } + + /// Return the timeout attached to this call, if any. + pub fn timeout(&self) -> Option { + self.timeout + } } impl Default for CallContext { @@ -192,6 +341,38 @@ impl Stream for Subscription { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn call_context_timeout_can_be_set_and_replaced() { + let default = Duration::from_secs(180); + let explicit = Duration::from_millis(25); + + let mut cx = CallContext::with_request_id("request-1".to_string()); + assert_eq!(cx.timeout(), None); + cx.set_timeout(default); + assert_eq!(cx.timeout(), Some(default)); + + cx.set_timeout(explicit); + assert_eq!(cx.timeout(), Some(explicit)); + } + + #[test] + fn cancellation_token_clones_share_cancellation() { + let token = CancellationToken::new(); + let cloned = token.clone(); + let wait = cloned.cancelled(); + + token.cancel(); + + let reason = futures::executor::block_on(wait); + assert_eq!(reason, CancellationReason::Cancelled); + assert!(cloned.is_cancelled()); + } +} + impl Subscription { /// Creates a new subscription from a boxed stream. pub fn new(stream: Pin + Send>>) -> Self { From 95516f7b1cc4227bd13b6a1669c0f10e3b06a165 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 12:13:42 +0200 Subject: [PATCH 41/56] fixup! feat(truapi-server): add platform runtime and host bridge --- .../truapi-server/src/runtime/sso_pairing.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index 66a135ff..2fb60d36 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -536,22 +536,26 @@ mod tests { other => panic!("expected handshake decrypt failure, got {other:?}"), } let sent_rpc = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); - let methods = sent_rpc + let requests = sent_rpc .iter() .map(|request| serde_json::from_str::(request).unwrap()) - .map(|request| request["method"].as_str().unwrap().to_string()) + .collect::>(); + let methods = requests + .iter() + .map(|request| request["method"].as_str().unwrap()) .collect::>(); assert_eq!( - methods.first().map(String::as_str), + methods.first().copied(), Some("statement_subscribeStatement") ); assert!( - methods - .iter() - .any(|method| method == "statement_unsubscribeStatement"), + methods.contains(&"statement_unsubscribeStatement"), "pairing subscription should be cleaned up" ); - let unsubscribe: serde_json::Value = serde_json::from_str(&sent_rpc[1]).unwrap(); + let unsubscribe = requests + .iter() + .find(|request| request["method"].as_str() == Some("statement_unsubscribeStatement")) + .expect("pairing subscription should be cleaned up"); assert_eq!(unsubscribe["params"][0], "remote-sub"); } From 86ffc198b01946352e74de5191472b737452c4b9 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 12:23:22 +0200 Subject: [PATCH 42/56] fixup! feat(truapi-server): add platform runtime and host bridge --- rust/crates/truapi-codegen/src/rustdoc.rs | 2 + .../src/runtime/pairing_host/sso_channel.rs | 24 ++-- .../truapi-server/src/runtime/sso_remote.rs | 130 ++++++++++-------- rust/crates/truapi/src/lib.rs | 34 ++--- 4 files changed, 103 insertions(+), 87 deletions(-) diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index 8901fc0a..10dac17c 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -414,6 +414,8 @@ fn should_skip_type_name(name: &str) -> bool { "Subscription" | "CallContext" | "CallError" + | "CancellationFuture" + | "CancellationReason" | "CancellationToken" | "FrameworkOnlyError" | "Infallible" diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index 9f9ec59e..ee59e4b0 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -10,9 +10,9 @@ use super::super::authority::{ SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; use super::super::sso_remote::{ - SSO_LOCAL_DISCONNECT_REASON, SSO_PEER_DISCONNECT_REASON, SsoRemoteResponseError, SsoSessionKey, - fresh_statement_expiry, sso_message_id, statement_subscription_stream, - subscribe_statement_topic, wait_for_sso_remote_response, + RemoteResponseWait, SSO_LOCAL_DISCONNECT_REASON, SSO_PEER_DISCONNECT_REASON, + SsoRemoteResponseError, SsoSessionKey, fresh_statement_expiry, sso_message_id, + statement_subscription_stream, subscribe_statement_topic, wait_for_sso_remote_response, }; use super::super::statement_store_rpc::{self, StatementStoreRpc}; use super::AuthorityRequestKind; @@ -233,16 +233,16 @@ impl PairingHost { .boxed(); let action = action.to_string(); debug!(action, %message_id, "submitted SSO remote message, awaiting response"); - let result = wait_for_sso_remote_response( - statement_subscription_stream(own_subscription, "own"), - statement_subscription_stream(peer_subscription, "peer"), + let result = wait_for_sso_remote_response(RemoteResponseWait { + own_statements: statement_subscription_stream(own_subscription, "own"), + peer_statements: statement_subscription_stream(peer_subscription, "peer"), submit, - sso, - &message_id, - &message_id, - cx.cancel(), - Some(disconnect), - ) + session: sso, + statement_request_id: &message_id, + remote_message_id: &message_id, + cancel: cx.cancel(), + disconnect: Some(disconnect), + }) .await; match &result { Ok(_) => debug!(action, %message_id, "SSO remote response received"), diff --git a/rust/crates/truapi-server/src/runtime/sso_remote.rs b/rust/crates/truapi-server/src/runtime/sso_remote.rs index 286e94d4..9b154e0b 100644 --- a/rust/crates/truapi-server/src/runtime/sso_remote.rs +++ b/rust/crates/truapi-server/src/runtime/sso_remote.rs @@ -141,6 +141,17 @@ impl SessionDisconnects { pub(super) type StatementPageStream = BoxStream<'static, Result>; pub(super) type StatementSubmitFuture = BoxFuture<'static, Result<(), SsoRemoteResponseError>>; +pub(super) struct RemoteResponseWait<'a> { + pub(super) own_statements: StatementPageStream, + pub(super) peer_statements: StatementPageStream, + pub(super) submit: StatementSubmitFuture, + pub(super) session: &'a SsoSessionInfo, + pub(super) statement_request_id: &'a str, + pub(super) remote_message_id: &'a str, + pub(super) cancel: &'a CancellationToken, + pub(super) disconnect: Option>, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct CancelError { reason: CancellationReason, @@ -219,15 +230,18 @@ fn disconnect_error(reason: String) -> SsoRemoteResponseError { #[instrument(skip_all, fields(runtime.method = "sso.remote_response.wait"))] pub(super) async fn wait_for_sso_remote_response( - own_statements: StatementPageStream, - peer_statements: StatementPageStream, - submit: StatementSubmitFuture, - session: &SsoSessionInfo, - statement_request_id: &str, - remote_message_id: &str, - cancel: &CancellationToken, - disconnect: Option>, + wait: RemoteResponseWait<'_>, ) -> Result { + let RemoteResponseWait { + own_statements, + peer_statements, + submit, + session, + statement_request_id, + remote_message_id, + cancel, + disconnect, + } = wait; let response = wait_for_sso_remote_response_inner( own_statements, peer_statements, @@ -421,16 +435,16 @@ mod tests { cancel.cancel_with_reason(CancellationReason::TimedOut { timeout: Duration::from_millis(1), }); - let err = futures::executor::block_on(wait_for_sso_remote_response( - stream::pending().boxed(), - stream::pending().boxed(), - futures::future::pending().boxed(), - session.sso.as_ref().unwrap(), - "request-1", - "request-1", - &cancel, - None, - )) + let err = futures::executor::block_on(wait_for_sso_remote_response(RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::pending().boxed(), + submit: futures::future::pending().boxed(), + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel: &cancel, + disconnect: None, + })) .unwrap_err(); let SsoRemoteResponseError::Cancelled(err) = err else { @@ -445,19 +459,19 @@ mod tests { #[test] fn sso_remote_response_waiter_reports_submit_rejections() { let session = sso_session_info(); - let err = futures::executor::block_on(wait_for_sso_remote_response( - stream::pending().boxed(), - stream::pending().boxed(), - futures::future::ready(Err(SsoRemoteResponseError::Failure( + let err = futures::executor::block_on(wait_for_sso_remote_response(RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::pending().boxed(), + submit: futures::future::ready(Err(SsoRemoteResponseError::Failure( "SSO statement submit failed: no allowance".to_string(), ))) .boxed(), - session.sso.as_ref().unwrap(), - "request-1", - "request-1", - &CancellationToken::new(), - None, - )) + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel: &CancellationToken::new(), + disconnect: None, + })) .unwrap_err(); assert_eq!( @@ -473,16 +487,16 @@ mod tests { let session = sso_session_info(); let (tx, rx) = oneshot::channel(); tx.send(SSO_LOCAL_DISCONNECT_REASON.to_string()).unwrap(); - let err = futures::executor::block_on(wait_for_sso_remote_response( - stream::pending().boxed(), - stream::pending().boxed(), - futures::future::pending().boxed(), - session.sso.as_ref().unwrap(), - "request-1", - "request-1", - &CancellationToken::new(), - Some(rx), - )) + let err = futures::executor::block_on(wait_for_sso_remote_response(RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::pending().boxed(), + submit: futures::future::pending().boxed(), + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel: &CancellationToken::new(), + disconnect: Some(rx), + })) .unwrap_err(); assert_eq!(err, SsoRemoteResponseError::LocalDisconnected); @@ -493,16 +507,16 @@ mod tests { let session = sso_session_info(); let (tx, rx) = oneshot::channel(); tx.send(SSO_LOCAL_DISCONNECT_REASON.to_string()).unwrap(); - let err = futures::executor::block_on(wait_for_sso_remote_response( - stream::pending().boxed(), - stream::pending().boxed(), - futures::future::pending().boxed(), - session.sso.as_ref().unwrap(), - "request-1", - "request-1", - &CancellationToken::new(), - Some(rx), - )) + let err = futures::executor::block_on(wait_for_sso_remote_response(RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::pending().boxed(), + submit: futures::future::pending().boxed(), + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel: &CancellationToken::new(), + disconnect: Some(rx), + })) .unwrap_err(); assert_eq!(err, SsoRemoteResponseError::LocalDisconnected); @@ -512,16 +526,16 @@ mod tests { fn sso_remote_response_waiter_stops_on_call_cancellation() { let session = sso_session_info(); let cancel = CancellationToken::new(); - let wait = wait_for_sso_remote_response( - stream::pending().boxed(), - stream::pending().boxed(), - futures::future::pending().boxed(), - session.sso.as_ref().unwrap(), - "request-1", - "request-1", - &cancel, - None, - ); + let wait = wait_for_sso_remote_response(RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::pending().boxed(), + submit: futures::future::pending().boxed(), + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel: &cancel, + disconnect: None, + }); cancel.cancel(); let err = futures::executor::block_on(wait).unwrap_err(); diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 3c03ccfb..4139b334 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -341,6 +341,23 @@ impl Stream for Subscription { } } +impl Subscription { + /// Creates a new subscription from a boxed stream. + pub fn new(stream: Pin + Send>>) -> Self { + Self { inner: stream } + } + + /// Creates a subscription that yields no items. Useful as a placeholder for + /// default "unavailable" trait bodies where the dispatcher will discard the + /// stream and emit an Interrupt frame. + pub fn empty() -> Self + where + T: Send + 'static, + { + Self::new(Box::pin(futures::stream::empty())) + } +} + #[cfg(test)] mod tests { use super::*; @@ -372,20 +389,3 @@ mod tests { assert!(cloned.is_cancelled()); } } - -impl Subscription { - /// Creates a new subscription from a boxed stream. - pub fn new(stream: Pin + Send>>) -> Self { - Self { inner: stream } - } - - /// Creates a subscription that yields no items. Useful as a placeholder for - /// default "unavailable" trait bodies where the dispatcher will discard the - /// stream and emit an Interrupt frame. - pub fn empty() -> Self - where - T: Send + 'static, - { - Self::new(Box::pin(futures::stream::empty())) - } -} From 98b1023e1b9be7fc4c1a00110e55432ead7a480a Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 14:31:17 +0200 Subject: [PATCH 43/56] fixup! feat(truapi-server): add platform runtime and host bridge --- .../truapi-server/src/runtime/pairing_host/sso_channel.rs | 8 -------- rust/crates/truapi-server/src/runtime/signing_host.rs | 6 ------ .../src/runtime/signing_host/local_activation.rs | 3 --- 3 files changed, 17 deletions(-) diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index ee59e4b0..05831642 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -1,9 +1,4 @@ //! SSO statement-store channel to the paired remote signing host. -//! -//! The channel half of [`PairingHost`]: message submission and response -//! correlation over the statement store, plus the peer-disconnect monitor. -//! Role policy (session lifecycle, login, revalidation) stays in the parent -//! module. use super::super::authority::{ AuthorityCancelError, AuthorityError, CreateTransactionAuthorityRequest, @@ -175,9 +170,6 @@ impl PairingHost { } /// Submit an SSO remote message and wait for the signing-host response. - /// - /// Calls without a [`CallContext`] timeout block until response, - /// disconnect, or stream completion. #[instrument(skip_all, fields(runtime.method = "sso.remote_message.submit", action = %action))] async fn submit_remote_message( &self, diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index b6734d73..8cd58769 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -5,12 +5,6 @@ //! embedding host at unlock through [`LocalActivation::activate_local_session`] //! (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). mod local_activation; 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 fa0635e4..9da344b0 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 @@ -1,6 +1,3 @@ -//! Signing-host-only capability: activate a wallet-local session from -//! host-held secret material. Implemented solely by [`SigningHost`]. - use super::{SigningHost, product_authority_error}; use crate::host_logic::product_account::derive_root_keypair_from_entropy; use crate::host_logic::session::SessionInfo; From b639cb71466d6a77c85166838acdd44072051c87 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Tue, 7 Jul 2026 10:01:06 +0200 Subject: [PATCH 44/56] fixup! feat(truapi-server): add platform runtime and host bridge --- Cargo.lock | 10 + rust/crates/truapi-platform/src/lib.rs | 11 + rust/crates/truapi-server/Cargo.toml | 1 + .../crates/truapi-server/src/chain_runtime.rs | 76 +++- rust/crates/truapi-server/src/core.rs | 3 + .../src/host_logic/permissions.rs | 52 ++- .../src/host_logic/statement_store.rs | 3 +- .../host_logic/statement_store/statement.rs | 60 ++- rust/crates/truapi-server/src/runtime.rs | 393 +++++++++++++++--- .../truapi-server/src/runtime/authority.rs | 42 ++ .../truapi-server/src/runtime/pairing_host.rs | 154 ++++++- .../src/runtime/pairing_host/sso_channel.rs | 106 ++++- .../truapi-server/src/runtime/signing_host.rs | 32 +- .../truapi-server/src/runtime/sso_pairing.rs | 163 +++++++- .../truapi-server/src/runtime/sso_remote.rs | 40 +- .../src/runtime/statement_store.rs | 246 +++++++++-- rust/crates/truapi-server/src/test_support.rs | 271 +++++++++--- 17 files changed, 1438 insertions(+), 225 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5004eda9..84faa731 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1856,6 +1856,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "685a9ac4b61f4e728e1d2c6a7844609c16527aeb5e6c865915c08e619c16410f" +[[package]] +name = "nanoid" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +dependencies = [ + "rand", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -3283,6 +3292,7 @@ dependencies = [ "hex", "hkdf", "js-sys", + "nanoid", "p256", "parity-scale-codec", "pin-project", diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index b133af52..7602915c 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -544,6 +544,15 @@ pub struct AccountAliasReview { pub target_product_id: String, } +/// Review shown before a product asks to access another product account. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct AccountAccessReview { + /// Product currently handling the request. + pub requesting_product_id: String, + /// Product whose account is being requested. + pub target_product_id: String, +} + /// Review shown before a product learns the user's primary identity. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct IdentityDisclosureReview { @@ -576,6 +585,8 @@ pub enum UserConfirmationReview { ResourceAllocation(HostRequestResourceAllocationRequest), /// Submit a preimage to the host-selected backend. PreimageSubmit(PreimageSubmitReview), + /// Allow a product to access another product account. + AccountAccess(AccountAccessReview), } /// Local user confirmation UI for sensitive core-owned operations. diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 648fed22..93c896b6 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -29,6 +29,7 @@ thiserror = "1" unicode-normalization = "0.1" url = "2" hex = "0.4" +nanoid = "0.4" blake2-rfc = { version = "0.2", default-features = false } sp-crypto-hashing = { version = "0.1", default-features = false } bs58 = { version = "0.5", default-features = false, features = ["alloc"] } diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 8b31bc16..853fc133 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -17,6 +17,7 @@ use core::task::{Context, Poll}; use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(not(target_arch = "wasm32"))] use std::time::Duration; #[cfg(target_arch = "wasm32")] @@ -216,10 +217,18 @@ impl ChainRuntime { let cleanup_runtime = self.clone(); let cleanup_genesis_hash = request.genesis_hash.clone(); let cleanup_follow_id = follow_subscription_id.clone(); + let cancelled = Arc::new(AtomicBool::new(false)); + let setup_cancelled = cancelled.clone(); + let cleanup_cancelled = cancelled.clone(); let fut = async move { if runtime - .start_follow(follow_subscription_id, request, Some(tx.clone())) + .start_follow( + follow_subscription_id, + request, + Some(tx.clone()), + setup_cancelled, + ) .await .is_err() { @@ -231,6 +240,7 @@ impl ChainRuntime { ManagedSubscription::new( rx.boxed(), Some(Box::new(move || { + cleanup_cancelled.store(true, Ordering::SeqCst); cleanup_runtime.cleanup_follow(&cleanup_genesis_hash, &cleanup_follow_id); })), ) @@ -541,14 +551,30 @@ impl ChainRuntime { local_follow_id: String, request: RemoteChainHeadFollowRequest, sender: Option>, + cancelled: Arc, ) -> Result<(), RuntimeFailure> { + if cancelled.load(Ordering::SeqCst) { + return Err(RuntimeFailure::unavailable(FOLLOW_METHOD)); + } let connection = self .connection_for(FOLLOW_METHOD, &request.genesis_hash) .await?; + if cancelled.load(Ordering::SeqCst) { + return Err(RuntimeFailure::unavailable(FOLLOW_METHOD)); + } // Record this subscriber's sender before kicking off (or joining) the // single-flight setup so events route to it regardless of which caller // wins the setup. - connection.register_follow_intent(&local_follow_id, request.with_runtime, sender); + connection.register_follow_intent( + &local_follow_id, + request.with_runtime, + sender, + cancelled, + ); + if connection.follow_cancelled_or_missing(&local_follow_id) { + connection.unfollow(&local_follow_id); + return Err(RuntimeFailure::unavailable(FOLLOW_METHOD)); + } connection .ensure_remote_follow(local_follow_id, request.with_runtime) .await?; @@ -642,12 +668,14 @@ impl ChainConnection { local_follow_id: &str, with_runtime: bool, sender: Option>, + cancelled: Arc, ) { let mut follows = self.follows.lock().unwrap(); match follows.get_mut(local_follow_id) { Some(follow) => { if sender.is_some() { follow.sender = sender; + follow.cancelled = cancelled; } } None => { @@ -658,12 +686,20 @@ impl ChainConnection { remote_subscription_id: None, abort: None, sender, + cancelled, }, ); } } } + fn follow_cancelled_or_missing(&self, local_follow_id: &str) -> bool { + match self.follows.lock().unwrap().get(local_follow_id) { + Some(follow) => follow.cancelled.load(Ordering::SeqCst), + None => true, + } + } + /// Issue `chainHead_v1_follow` exactly once per local follow id and return /// the remote subscription id. Concurrent callers for the same id share /// one in-flight setup instead of each opening a duplicate remote @@ -674,6 +710,9 @@ impl ChainConnection { local_follow_id: String, with_runtime: bool, ) -> Result { + if self.follow_cancelled_or_missing(&local_follow_id) { + return Err(RuntimeFailure::unavailable(FOLLOW_METHOD)); + } if let Some(remote_follow_id) = self.remote_follow_id(&local_follow_id) { return Ok(remote_follow_id); } @@ -751,17 +790,10 @@ impl ChainConnection { local_follow_id: String, with_runtime: bool, ) -> Result { - self.follows - .lock() - .unwrap() - .entry(local_follow_id.clone()) - .or_insert_with(|| FollowState { - with_runtime, - remote_subscription_id: None, - abort: None, - sender: None, - }); - + if self.follow_cancelled_or_missing(&local_follow_id) { + self.remove_follow(&local_follow_id); + return Err(RuntimeFailure::unavailable(FOLLOW_METHOD)); + } let mut follow = self .methods .chainhead_v1_follow(with_runtime) @@ -776,6 +808,11 @@ impl ChainConnection { RuntimeFailure::host_failure(FOLLOW_METHOD, "missing follow subscription id") })? .to_string(); + if self.follow_cancelled_or_missing(&local_follow_id) { + self.remove_follow(&local_follow_id); + drop(follow); + return Err(RuntimeFailure::unavailable(FOLLOW_METHOD)); + } let (abort, abort_registration) = AbortHandle::new_pair(); let connection = self.clone(); @@ -894,6 +931,7 @@ struct FollowState { remote_subscription_id: Option, abort: Option, sender: Option>, + cancelled: Arc, } /// Subscription wrapper that runs an `on_drop` cleanup when the stream is @@ -1169,9 +1207,7 @@ async fn wait_for_chain_head_best_hash_after_initialization( candidate = Some(block_hash); } Some(RemoteChainHeadFollowItem::Stop) | None => { - return candidate.ok_or_else(|| { - format!("{label} follow stopped before best block") - }); + return Err(format!("{label} follow stopped before best block")); } _ => {} }, @@ -1283,7 +1319,7 @@ mod tests { } #[test] - fn chain_head_best_hash_uses_new_block_before_stale_finalized_fallback() { + fn chain_head_best_hash_errors_on_stop_before_best_block() { let mut follow = stream::iter(vec![ RemoteChainHeadFollowItem::Initialized { finalized_block_hashes: vec![vec![0x01]], @@ -1298,15 +1334,15 @@ mod tests { ]) .boxed(); - let hash = futures::executor::block_on(wait_for_chain_head_best_hash( + let err = futures::executor::block_on(wait_for_chain_head_best_hash( &mut follow, "test chain", Duration::from_secs(10), Duration::from_secs(2), )) - .expect("new block hash should resolve"); + .expect_err("follow stop should be terminal before best block"); - assert_eq!(hash, vec![0x03]); + assert_eq!(err, "test chain follow stopped before best block"); } #[derive(Default)] diff --git a/rust/crates/truapi-server/src/core.rs b/rust/crates/truapi-server/src/core.rs index d3e7a437..a9fbd337 100644 --- a/rust/crates/truapi-server/src/core.rs +++ b/rust/crates/truapi-server/src/core.rs @@ -104,6 +104,9 @@ impl TrUApiCore { /// Decode an incoming product frame, run it through the dispatcher, and /// return the SCALE-encoded response frame when the method has one. + /// Subscription starts should use [`Self::dispatch`] with a long-lived + /// transport; changing this byte-frame helper to reject them or return a + /// richer response shape is a separate API decision. #[instrument(skip_all, fields(runtime.method = "core.receive_from_product"))] pub async fn receive_from_product(&self, frame: &[u8]) -> Option> { let message = ProtocolMessage::decode(&mut &*frame).ok()?; diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs index d1414096..d44ec1da 100644 --- a/rust/crates/truapi-server/src/host_logic/permissions.rs +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -163,13 +163,12 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a if let Some(cached) = peek_stored(self.storage, key.clone()).await? { return Ok(cached.into()); } - // Only a genuine user authorization is persisted. A prompt-callback error is - // transient (UI unavailable, IPC timeout), not a denial, so fail closed - // for this call but do not cache it — the next request re-prompts rather - // than locking the capability out permanently with no revoke path. + // Only a genuine user authorization is persisted. A prompt-callback + // error is transient (dismissed UI, unavailable UI, IPC timeout), not + // a denial, so leave the authorization ask/default. let authorization = match self.prompt.device_permission(permission).await { Ok(HostDevicePermissionResponse { granted }) => granted.into(), - Err(_) => return Ok(PermissionAuthorizationStatus::Denied), + Err(_) => return Ok(PermissionAuthorizationStatus::NotDetermined), }; self.persist_decision(key, authorization).await } @@ -184,11 +183,11 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a if let Some(cached) = peek_stored(self.storage, key.clone()).await? { return Ok(cached.into()); } - // See `check_or_prompt_device`: persist only a genuine user decision; a - // transient callback error fails closed for this call without caching. + // See `check_or_prompt_device`: persist only a genuine user decision; + // transient callback errors leave the authorization ask/default. let authorization = match self.prompt.remote_permission(request).await { Ok(RemotePermissionResponse { granted }) => granted.into(), - Err(_) => return Ok(PermissionAuthorizationStatus::Denied), + Err(_) => return Ok(PermissionAuthorizationStatus::NotDetermined), }; self.persist_decision(key, authorization).await } @@ -665,25 +664,46 @@ mod tests { } #[test] - fn prompt_failure_denies_without_persisting() { + fn prompt_failure_stays_not_determined_without_persisting() { let storage = MemStorage::default(); let prompt = FailingPrompt; let service = PermissionsService::new(&storage, &prompt, "product.dot"); - let decision = futures::executor::block_on( + let device_decision = futures::executor::block_on( service.check_or_prompt_device(HostDevicePermissionRequest::Camera), ) .unwrap(); - assert_eq!(decision, PermissionAuthorizationStatus::Denied); + assert_eq!( + device_decision, + PermissionAuthorizationStatus::NotDetermined + ); + + let remote_request = RemotePermissionRequest { + permission: RemotePermission::ChainSubmit, + }; + let remote_decision = + futures::executor::block_on(service.check_or_prompt_remote(remote_request.clone())) + .unwrap(); + assert_eq!( + remote_decision, + PermissionAuthorizationStatus::NotDetermined + ); - // A transient callback error is fail-closed for this call but NOT - // cached, so peek still sees no authorization and the next request - // re-prompts rather than permanently locking out the capability. - let cached = + // A transient callback error is not cached, so peek still sees no + // authorization and the next request re-prompts rather than + // permanently locking out the capability. + let cached_device = futures::executor::block_on(service.peek_device(&HostDevicePermissionRequest::Camera)) .unwrap(); assert_eq!( - cached, + cached_device, + PermissionAuthorizationStatus::NotDetermined, + "a transient prompt error must not be persisted" + ); + let cached_remote = + futures::executor::block_on(service.peek_remote(&remote_request)).unwrap(); + assert_eq!( + cached_remote, PermissionAuthorizationStatus::NotDetermined, "a transient prompt error must not be persisted" ); diff --git a/rust/crates/truapi-server/src/host_logic/statement_store.rs b/rust/crates/truapi-server/src/host_logic/statement_store.rs index 3068285b..7195b5d6 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store.rs @@ -23,7 +23,8 @@ pub use statement::{ build_signed_statement, decode_signed_statement, decode_statement_data, decode_verified_statement_data, hex_topic, sign_statement_fields, signed_statement_to_scale, statement_expiry_elapsed, statement_fields_from_v01, statement_proof_to_v01, - statement_signing_payload, + statement_public_key_from_secret, statement_signing_payload, + unsigned_statement_signing_payload, }; /// Error while parsing statement-store JSON-RPC or SCALE statement payloads. diff --git a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs index c90bb848..1340cd3a 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs @@ -175,8 +175,7 @@ pub fn sign_statement_fields( } fields.sort_by_key(statement_field_sort_index); - let secret = - SecretKey::from_bytes(&ss_secret).map_err(|err| format!("invalid ss_secret: {err}"))?; + let secret = statement_secret_key_from_bytes(ss_secret)?; let public = secret.to_public(); if public.to_bytes() != expected_public_key { return Err("ss_secret does not match session statement public key".to_string()); @@ -196,6 +195,37 @@ pub fn sign_statement_fields( Ok(signed) } +/// Derive the sr25519 public key for a 64-byte statement-store secret. +pub fn statement_public_key_from_secret(ss_secret: [u8; 64]) -> Result<[u8; 32], String> { + let secret = statement_secret_key_from_bytes(ss_secret)?; + Ok(secret.to_public().to_bytes()) +} + +fn statement_secret_key_from_bytes(ss_secret: [u8; 64]) -> Result { + // Rust-generated session keys use schnorrkel's canonical scalar bytes. + // Legacy JS signers may send scure/ed25519-style scalar bytes instead. + match SecretKey::from_bytes(&ss_secret) { + Ok(secret) => Ok(secret), + Err(canonical_error) => SecretKey::from_ed25519_bytes(&ss_secret).map_err(|ed_error| { + format!("invalid ss_secret: canonical={canonical_error}; ed25519={ed_error}") + }), + } +} + +/// Build the statement proof payload for unsigned fields. +pub fn unsigned_statement_signing_payload( + mut fields: Vec, +) -> Result, String> { + if fields + .iter() + .any(|field| matches!(field, StatementField::Proof(_))) + { + return Err("statement is already signed".to_string()); + } + fields.sort_by_key(statement_field_sort_index); + statement_signing_payload(&fields) +} + /// Build the statement signing payload from sorted fields. pub fn statement_signing_payload(fields: &[StatementField]) -> Result, String> { let encoded = fields.to_vec().encode(); @@ -515,6 +545,32 @@ mod tests { } } + #[test] + fn scure_statement_store_secret_fixture_signs_statement() { + // Fixture from @novasamatech/statement-store 0.7.5 createSr25519Secret. + let secret: [u8; 64] = hex::decode( + "8848ab59e934e06a54835252b8abdf946b893055cd34a4433a32b37470f90745\ + 62bb6d70fa3ea98b6ff05ea3f3cac76b62affd2f44c66624873fc6a58e3cd776", + ) + .unwrap() + .try_into() + .unwrap(); + let public_key = statement_public_key_from_secret(secret).unwrap(); + assert_eq!( + hex::encode(public_key), + "c0c18f0e3bbb9c0cf31c6c4db37d7d34efb42035daac647a4a14c93320d33857" + ); + + let signed = sign_statement_fields( + secret, + public_key, + vec![StatementField::Data(vec![1, 2, 3])], + ) + .unwrap(); + let verified = decode_verified_statement_data(&signed.encode(), Some(public_key)).unwrap(); + assert_eq!(verified.data, vec![1, 2, 3]); + } + #[test] fn decodes_statement_data_field() { let statement = vec![ diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index aaa93083..a8e32903 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -132,7 +132,7 @@ use truapi::{CallContext, CallError, CancellationReason, Subscription}; #[cfg(test)] use truapi_platform::Platform; use truapi_platform::{ - AccountAliasReview, CreateTransactionReview, IdentityDisclosureReview, + AccountAccessReview, AccountAliasReview, CreateTransactionReview, IdentityDisclosureReview, PermissionAuthorizationRequest, PermissionAuthorizationStatus, PreimageSubmitReview, ProductContext, SessionUiInfo, SignPayloadReview, SignRawReview, UserConfirmationReview, normalize_product_identifier, @@ -310,7 +310,9 @@ impl ProductRuntimeHost { return false; }; let product_id = self.product_id(); - product_id.starts_with("localhost:") || dot_ns_identifier == product_id + product_id == "localhost" + || product_id.starts_with("localhost:") + || dot_ns_identifier == product_id } fn normalize_product_account_id( @@ -445,7 +447,10 @@ impl ProductRuntimeHost { return Ok(cached); } - let confirmed = self + // A dismissed/unavailable confirmation has no durable user decision. + // Fail the current disclosure request closed but keep authorization in + // the ask/default state so the next request can prompt again. + let confirmed = match self .services .platform .confirm_user_action(UserConfirmationReview::IdentityDisclosure( @@ -454,7 +459,10 @@ impl ProductRuntimeHost { }, )) .await - .map_err(|err| format!("identity disclosure confirmation failed: {err:?}"))?; + { + Ok(confirmed) => confirmed, + Err(_) => return Ok(PermissionAuthorizationStatus::NotDetermined), + }; let status = if confirmed { PermissionAuthorizationStatus::Authorized } else { @@ -711,13 +719,32 @@ impl Account for ProductRuntimeHost { v01::HostAccountGetError::DomainNotValid, )) })?; - let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostAccountGetError::V1( v01::HostAccountGetError::NotConnected, ))); }; + let product_id = self.product_id(); + if product_account_id.dot_ns_identifier != product_id { + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::AccountAccess(AccountAccessReview { + requesting_product_id: product_id, + target_product_id: product_account_id.dot_ns_identifier.clone(), + })) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("account access confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::Rejected, + ))); + } + } + let public_key = derive_product_public_key( session.public_key, &product_account_id.dot_ns_identifier, @@ -825,6 +852,9 @@ impl Account for ProductRuntimeHost { v01::HostGetLegacyAccountsResponse { accounts: vec![v01::LegacyAccount { public_key: public_key.to_vec(), + // TODO(#266): gate this legacy display name on + // IdentityDisclosure while keeping the public key for + // compatibility. name: session.lite_username.clone(), }], }, @@ -843,6 +873,19 @@ impl Account for ProductRuntimeHost { ))); }; + match self.identity_disclosure_authorization().await { + Ok(PermissionAuthorizationStatus::Authorized) => {} + Ok( + PermissionAuthorizationStatus::Denied + | PermissionAuthorizationStatus::NotDetermined, + ) => { + return Err(CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied, + ))); + } + Err(reason) => return Err(CallError::HostFailure { reason }), + } + let primary_username = session .full_username .clone() @@ -859,19 +902,6 @@ impl Account for ProductRuntimeHost { })) })?; - match self.identity_disclosure_authorization().await { - Ok(PermissionAuthorizationStatus::Authorized) => {} - Ok( - PermissionAuthorizationStatus::Denied - | PermissionAuthorizationStatus::NotDetermined, - ) => { - return Err(CallError::Domain(HostGetUserIdError::V1( - v01::HostGetUserIdError::PermissionDenied, - ))); - } - Err(reason) => return Err(CallError::HostFailure { reason }), - } - Ok(HostGetUserIdResponse::V1(v01::HostGetUserIdResponse { primary_username, })) @@ -1515,6 +1545,9 @@ 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 .chain .remote_chain_transaction_stop(inner) @@ -1699,6 +1732,9 @@ impl Preimage for ProductRuntimeHost { .platform .lookup_preimage(key) .filter_map(|item| async move { + // TODO: preserve platform stream errors as terminal + // subscription interrupts once subscription items can carry + // in-stream failures. item.ok().map(|value| { RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { value, @@ -1715,6 +1751,13 @@ impl Preimage for ProductRuntimeHost { request: RemotePreimageSubmitRequest, ) -> Result> { let RemotePreimageSubmitRequest::V1(value) = request; + if self.authority.current_session().is_none() { + return Err(CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { + reason: "No active session".to_string(), + }, + ))); + } self.require_remote_permission( v01::RemotePermission::PreimageSubmit, RemotePreimageSubmitError::V1(v01::PreimageSubmitError::Unknown { @@ -1764,6 +1807,9 @@ impl Theme for ProductRuntimeHost { .platform .subscribe_theme() .filter_map(|item| async { + // TODO: preserve platform stream errors as terminal + // subscription interrupts once subscription items can carry + // in-stream failures. item.ok().map(|variant| { HostThemeSubscribeItem::V1(v01::HostThemeSubscribeItem { name: v01::ThemeName::Default, @@ -1891,6 +1937,14 @@ mod tests { assert_eq!(second.follow_id("request-1"), "c2:request-1"); } + #[test] + fn bare_localhost_product_allows_dev_product_accounts() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("localhost"), test_spawner()); + + assert!(host.is_product_account_valid_for_caller("myapp.dot")); + } + #[test] fn navigate_to_uses_dotns_decision_and_then_platform() { let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); @@ -1983,7 +2037,8 @@ mod tests { #[test] fn get_account_requires_session() { - let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); let cx = CallContext::new(); let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { @@ -2002,7 +2057,8 @@ mod tests { #[test] fn get_account_rejects_invalid_product_identifier() { - let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { @@ -2020,9 +2076,97 @@ mod tests { )); } + #[test] + fn get_account_other_product_rejects_when_user_declines() { + let platform = stub_platform(); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "other.dot".to_string(), + derivation_index: 0, + }, + }); + let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetError::V1(v01::HostAccountGetError::Rejected)) + )); + assert_eq!( + platform + .account_access_reviews + .lock() + .expect("account access review list mutex poisoned") + .as_slice(), + &[AccountAccessReview { + requesting_product_id: "myapp.dot".to_string(), + target_product_id: "other.dot".to_string(), + }] + ); + } + + #[test] + fn get_account_other_product_maps_confirmation_failure_to_host_failure() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + account_access_error: Some("modal failed"), + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "other.dot".to_string(), + derivation_index: 0, + }, + }); + let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); + assert!( + matches!(err, CallError::HostFailure { reason } if reason.contains("modal failed")) + ); + } + + #[test] + fn get_account_other_product_accepts_confirmation_then_derives_key() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + account_access_confirmed: true, + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + let session = session_info(); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "other.dot".to_string(), + derivation_index: 0, + }, + }); + let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); + let HostAccountGetResponse::V1(inner) = response; + assert_eq!( + inner.account.public_key, + derive_product_public_key(session.public_key, "other.dot", 0) + .unwrap() + .to_vec() + ); + } + #[test] fn get_account_derives_dotli_product_key() { - let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { @@ -2041,7 +2185,8 @@ mod tests { #[test] fn get_account_normalizes_product_identifier_before_deriving() { - let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("MyApp.DOT"), test_spawner()); host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { @@ -2058,6 +2203,32 @@ mod tests { ); } + #[test] + fn get_account_localhost_product_prompts_for_other_product_identifier() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + account_access_confirmed: true, + ..Default::default() + }), + runtime_config("localhost:3000"), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + }); + let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); + let HostAccountGetResponse::V1(inner) = response; + assert_eq!( + hex::encode(inner.account.public_key), + "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + ); + } + #[test] fn get_account_alias_requires_session() { let host = @@ -2099,9 +2270,8 @@ mod tests { fn get_account_alias_same_domain_returns_sso_response() { let session = sso_session_info(); let platform = Arc::new(StubPlatform { - rpc_responses: sso_success_responses( + sso_response_script: Some(sso_success_response_script( &session, - "alias-1", crate::host_logic::sso::messages::RemoteMessage { message_id: "wallet-alias-1".to_string(), data: crate::host_logic::sso::messages::RemoteMessageData::V1( @@ -2116,7 +2286,7 @@ mod tests { ), ), }, - ), + )), ..Default::default() }); let host = ProductRuntimeHost::new( @@ -2148,9 +2318,8 @@ mod tests { fn get_account_alias_normalizes_remote_request_identifier() { let session = sso_session_info(); let platform = Arc::new(StubPlatform { - rpc_responses: sso_success_responses( + sso_response_script: Some(sso_success_response_script( &session, - "alias-1", crate::host_logic::sso::messages::RemoteMessage { message_id: "wallet-alias-1".to_string(), data: crate::host_logic::sso::messages::RemoteMessageData::V1( @@ -2165,7 +2334,7 @@ mod tests { ), ), }, - ), + )), ..Default::default() }); let host = ProductRuntimeHost::new( @@ -2234,9 +2403,8 @@ mod tests { let session = sso_session_info(); let platform = Arc::new(StubPlatform { account_alias_confirmed: true, - rpc_responses: sso_success_responses( + sso_response_script: Some(sso_success_response_script( &session, - "alias-2", crate::host_logic::sso::messages::RemoteMessage { message_id: "wallet-alias-2".to_string(), data: crate::host_logic::sso::messages::RemoteMessageData::V1( @@ -2251,7 +2419,7 @@ mod tests { ), ), }, - ), + )), ..Default::default() }); let host = ProductRuntimeHost::new( @@ -2382,6 +2550,89 @@ mod tests { assert_eq!(status, PermissionAuthorizationStatus::Denied); } + #[test] + fn get_user_id_dismissed_identity_disclosure_stays_not_determined() { + let platform = Arc::new(StubPlatform { + identity_disclosure_error: Some("dismissed"), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + + let first = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); + let second = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); + + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 2); + assert!(matches!( + first, + CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied + )) + )); + assert!(matches!( + second, + CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied + )) + )); + let status = + futures::executor::block_on(host.permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + )) + .unwrap(); + assert_eq!(status, PermissionAuthorizationStatus::NotDetermined); + } + + #[test] + fn get_user_id_checks_identity_disclosure_before_username() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + let mut session = session_info(); + session.full_username = None; + session.lite_username = None; + host.test_session_state().set_session(session); + let cx = CallContext::new(); + + let err = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); + + assert!(matches!( + err, + CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied + )) + )); + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn get_user_id_reports_missing_username_after_identity_disclosure() { + let platform = Arc::new(StubPlatform { + identity_disclosure_confirmed: true, + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + let mut session = session_info(); + session.full_username = None; + session.lite_username = None; + host.test_session_state().set_session(session); + let cx = CallContext::new(); + + let err = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); + + assert!(matches!( + err, + CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::Unknown { ref reason } + )) if reason == "No primary username for this session" + )); + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 1); + } + #[test] fn get_user_id_respects_pre_authorized_identity_disclosure() { let platform = Arc::new(StubPlatform::default()); @@ -2476,12 +2727,42 @@ mod tests { #[test] fn preimage_submit_confirms_and_delegates_to_platform() { let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); let response = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap(); assert_eq!(response, RemotePreimageSubmitResponse::V1(vec![1, 2, 3])); } + #[test] + fn preimage_submit_requires_session_before_backend_call() { + let preimage_submits = Arc::new(Mutex::new(Vec::new())); + let host = ProductRuntimeHost::new_compat( + Arc::new(StubPlatform { + preimage_submits: preimage_submits.clone(), + ..Default::default() + }), + test_spawner(), + ); + let cx = CallContext::new(); + let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); + + let err = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap_err(); + + match err { + CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { reason }, + )) => assert_eq!(reason, "No active session"), + other => panic!("expected preimage session error, got {other:?}"), + } + assert!( + preimage_submits + .lock() + .expect("preimage submit list mutex poisoned") + .is_empty() + ); + } + #[test] fn preimage_submit_requires_remote_permission_before_backend_call() { let preimage_submits = Arc::new(Mutex::new(Vec::new())); @@ -2493,6 +2774,7 @@ mod tests { }), test_spawner(), ); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); let err = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap_err(); @@ -2654,11 +2936,10 @@ mod tests { let session = sso_session_info(); let platform = Arc::new(StubPlatform { sign_raw_confirmed: true, - rpc_responses: sso_success_responses( + sso_response_script: Some(sso_success_response_script( &session, - "sign-raw-1", sign_response_message("sign-raw-1", vec![7, 7], None), - ), + )), ..Default::default() }); let host = ProductRuntimeHost::new( @@ -2712,10 +2993,7 @@ mod tests { .map(|request| request["params"][0].as_str().unwrap().to_string()) .collect::>(); unsubscribe_ids.sort(); - assert_eq!( - unsubscribe_ids, - vec!["own-sub-sign-raw-1", "peer-sub-sign-raw-1"] - ); + assert_eq!(unsubscribe_ids, vec!["own-sub", "peer-sub"]); } #[test] @@ -2827,7 +3105,7 @@ mod tests { let session = sso_session_info(); let platform = Arc::new(StubPlatform { sign_raw_confirmed: true, - rpc_responses: sso_peer_disconnect_responses(&session, "sign-raw-disconnect"), + sso_response_script: Some(sso_peer_disconnect_response_script(&session)), ..Default::default() }); let host = ProductRuntimeHost::new( @@ -2976,11 +3254,10 @@ mod tests { let session = sso_session_info(); let platform = Arc::new(StubPlatform { sign_payload_confirmed: true, - rpc_responses: sso_success_responses( + sso_response_script: Some(sso_success_response_script( &session, - "sign-payload-1", sign_response_message("sign-payload-1", vec![8, 8], Some(vec![0xab, 0xcd])), - ), + )), ..Default::default() }); let host = ProductRuntimeHost::new( @@ -3017,9 +3294,8 @@ mod tests { let session = sso_session_info(); let platform = Arc::new(StubPlatform { create_transaction_confirmed: true, - rpc_responses: sso_success_responses( + sso_response_script: Some(sso_success_response_script( &session, - "create-tx-1", crate::host_logic::sso::messages::RemoteMessage { message_id: "wallet-create-tx-1".to_string(), data: crate::host_logic::sso::messages::RemoteMessageData::V1( @@ -3031,7 +3307,7 @@ mod tests { ), ), }, - ), + )), ..Default::default() }); let host = ProductRuntimeHost::new( @@ -3107,11 +3383,10 @@ mod tests { let session = sso_session_info(); let platform = Arc::new(StubPlatform { sign_raw_confirmed: true, - rpc_responses: sso_success_responses( + sso_response_script: Some(sso_success_response_script( &session, - "legacy-sign-raw-1", sign_response_message("legacy-sign-raw-1", vec![9, 9], None), - ), + )), ..Default::default() }); let host = ProductRuntimeHost::new( @@ -3161,11 +3436,10 @@ mod tests { let signer = derive_product_public_key(session.public_key, "myapp.dot", 0).unwrap(); let platform = Arc::new(StubPlatform { sign_raw_confirmed: true, - rpc_responses: sso_success_responses( + sso_response_script: Some(sso_success_response_script( &session, - "legacy-sign-raw-hex-1", sign_response_message("legacy-sign-raw-hex-1", vec![8, 8], None), - ), + )), ..Default::default() }); let host = ProductRuntimeHost::new( @@ -3235,9 +3509,8 @@ mod tests { let signer = derive_product_public_key(session.public_key, "myapp.dot", 0).unwrap(); let platform = Arc::new(StubPlatform { create_transaction_confirmed: true, - rpc_responses: sso_success_responses( + sso_response_script: Some(sso_success_response_script( &session, - "legacy-create-tx-1", crate::host_logic::sso::messages::RemoteMessage { message_id: "wallet-legacy-create-tx-1".to_string(), data: crate::host_logic::sso::messages::RemoteMessageData::V1( @@ -3249,7 +3522,7 @@ mod tests { ), ), }, - ), + )), ..Default::default() }); let host = ProductRuntimeHost::new( @@ -3370,11 +3643,15 @@ mod tests { #[test] fn resource_allocation_accepts_confirmation_then_returns_sso_response() { let session = sso_session_info(); + let slot_account_key = { + let mini_secret = schnorrkel::MiniSecretKey::from_bytes(&[12; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(schnorrkel::ExpansionMode::Ed25519); + keypair.secret.to_bytes().to_vec() + }; let platform = Arc::new(StubPlatform { resource_allocation_confirmed: true, - rpc_responses: sso_success_responses( + sso_response_script: Some(sso_success_response_script( &session, - "alloc-1", crate::host_logic::sso::messages::RemoteMessage { message_id: "wallet-alloc-1".to_string(), data: crate::host_logic::sso::messages::RemoteMessageData::V1( @@ -3384,7 +3661,7 @@ mod tests { payload: Ok(vec![ crate::host_logic::sso::messages::SsoAllocationOutcome::Allocated( crate::host_logic::sso::messages::SsoAllocatedResource::StatementStoreAllowance { - slot_account_key: vec![1], + slot_account_key, }, ), crate::host_logic::sso::messages::SsoAllocationOutcome::Rejected, @@ -3394,7 +3671,7 @@ mod tests { ), ), }, - ), + )), ..Default::default() }); let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index c4c808fd..1ee987bf 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -20,6 +20,7 @@ use truapi::{CallContext, CallError, CancellationReason}; use truapi_platform::ProductContext; use crate::host_logic::session::{SessionInfo, SessionState}; +use crate::host_logic::statement_store::statement_public_key_from_secret; /// Snapshot of an account-authority session selected by the authority. /// @@ -164,6 +165,30 @@ pub(crate) enum CreateTransactionAuthorityRequest { }, } +/// Statement-store allowance signing material held by the authority layer. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct StatementStoreAllowanceKey { + pub(crate) secret: [u8; 64], + pub(crate) public_key: [u8; 32], +} + +impl StatementStoreAllowanceKey { + pub(crate) fn from_secret_bytes(secret: Vec) -> Result { + let secret: [u8; 64] = + secret + .try_into() + .map_err(|secret: Vec| AuthorityError::Unavailable { + reason: format!( + "statement-store allowance key must be 64 bytes, got {}", + secret.len() + ), + })?; + let public_key = statement_public_key_from_secret(secret) + .map_err(|reason| AuthorityError::Unavailable { reason })?; + Ok(Self { secret, public_key }) + } +} + /// Host-level account authority used by product runtimes. /// /// Pairing hosts implement this by forwarding authority requests to a paired @@ -231,6 +256,23 @@ pub(crate) trait ProductAuthority: Send + Sync { request: HostRequestResourceAllocationRequest, ) -> Result; + /// Return statement-store allowance key material for the calling product. + async fn statement_store_allowance_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result; + + /// Sign exact statement-store proof bytes with a product-derived account. + async fn sign_statement_store_product_payload( + &self, + cx: &CallContext, + session: &AuthoritySession, + account: ProductAccountId, + payload: Vec, + ) -> Result<[u8; 64], AuthorityError>; + /// Derive product-scoped entropy for a connected session. fn derive_entropy( &self, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index 666c14a6..b21b9b8e 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -6,6 +6,7 @@ mod sso_channel; +use std::collections::HashMap; use std::sync::{Arc, Mutex, Weak}; use futures::channel::oneshot; @@ -14,8 +15,8 @@ use sso_channel::SsoDisconnectMonitor; use super::auth_state::AuthStateMachine; use super::authority::{ AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, - SignPayloadAuthorityRequest, SignRawAuthorityRequest, authority_session, - require_current_session, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, + authority_session, require_current_session, }; use super::connected_session_ui_info; use super::identity::resolve_session_identity_with_chain; @@ -85,6 +86,12 @@ struct LoginInFlight { waiters: Vec>>, } +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct StatementStoreAllowanceCacheKey { + session: SsoSessionKey, + product_id: String, +} + struct LoginInFlightOwner<'a> { host: &'a PairingHost, active: bool, @@ -125,6 +132,9 @@ pub(crate) struct PairingHost { session_disconnects: Arc, disconnect_monitor: Mutex>, login_in_flight: Mutex>, + login_generation: Mutex, + statement_store_allowances: + Mutex>, /// Self-reference captured by the spawned disconnect-monitor task. weak_self: Weak, pub(super) spawner: Spawner, @@ -145,6 +155,8 @@ impl PairingHost { session_disconnects: Arc::new(SessionDisconnects::default()), disconnect_monitor: Mutex::new(None), login_in_flight: Mutex::new(None), + login_generation: Mutex::new(0), + statement_store_allowances: Mutex::new(HashMap::new()), weak_self: weak_self.clone(), spawner: services.spawner.clone(), }) @@ -287,6 +299,7 @@ impl PairingHost { } let mut login_owner = LoginInFlightOwner::new(self); + let login_generation = self.begin_login_attempt(); let outcome = match SsoPairingFlow::new(self).request_session().await { Ok(outcome) => outcome, Err(err) => { @@ -308,6 +321,16 @@ impl PairingHost { } } SsoPairingOutcome::Success(session) => { + if !self.is_current_login_attempt(login_generation) { + let _ = self + .platform + .clear_core_storage(CoreStorageKey::AuthSession) + .await; + login_owner.finish(Ok(())); + return Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::Rejected, + )); + } self.set_connected_session(*session); login_owner.finish(Ok(())); Ok(HostRequestLoginResponse::V1( @@ -329,9 +352,35 @@ impl PairingHost { #[instrument(skip_all, fields(runtime.method = "account.cancel_login"))] pub(crate) fn cancel_login(&self) { + self.invalidate_login_attempts(); self.auth_state.login_cancelled(); } + fn begin_login_attempt(&self) -> u64 { + let mut generation = self + .login_generation + .lock() + .expect("login generation mutex poisoned"); + *generation = generation.wrapping_add(1); + *generation + } + + fn invalidate_login_attempts(&self) { + let mut generation = self + .login_generation + .lock() + .expect("login generation mutex poisoned"); + *generation = generation.wrapping_add(1); + } + + fn is_current_login_attempt(&self, generation: u64) -> bool { + *self + .login_generation + .lock() + .expect("login generation mutex poisoned") + == generation + } + fn login_waiter(&self) -> Option>> { let mut in_flight = self .login_in_flight @@ -423,6 +472,62 @@ impl PairingHost { require_current_session(&self.session_state, session) } + fn statement_store_allowance_cache_key( + session: &SessionInfo, + product_id: &str, + ) -> Result { + let sso = session.sso.as_ref().ok_or(AuthorityError::Disconnected)?; + Ok(StatementStoreAllowanceCacheKey { + session: SsoSessionKey::from_session(sso), + product_id: product_id.to_string(), + }) + } + + pub(super) fn cache_statement_store_allowance_key( + &self, + session: &SessionInfo, + product_id: &str, + slot_account_key: Vec, + ) -> Result { + let cache_key = Self::statement_store_allowance_cache_key(session, product_id)?; + let allowance = StatementStoreAllowanceKey::from_secret_bytes(slot_account_key)?; + self.statement_store_allowances + .lock() + .expect("statement-store allowance cache mutex poisoned") + .insert(cache_key, allowance.clone()); + Ok(allowance) + } + + pub(super) fn cached_statement_store_allowance_key( + &self, + session: &SessionInfo, + product_id: &str, + ) -> Result, AuthorityError> { + let cache_key = Self::statement_store_allowance_cache_key(session, product_id)?; + Ok(self + .statement_store_allowances + .lock() + .expect("statement-store allowance cache mutex poisoned") + .get(&cache_key) + .cloned()) + } + + pub(super) fn clear_statement_store_allowance_keys(&self, session: Option<&SessionInfo>) { + let mut allowances = self + .statement_store_allowances + .lock() + .expect("statement-store allowance cache mutex poisoned"); + let Some(session) = session else { + allowances.clear(); + return; + }; + let Some(sso) = session.sso.as_ref() else { + return; + }; + let session_key = SsoSessionKey::from_session(sso); + allowances.retain(|key, _| key.session != session_key); + } + async fn sign_payload( &self, cx: &CallContext, @@ -477,6 +582,32 @@ impl PairingHost { .await } + async fn statement_store_allowance_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_statement_store_allowance_key(cx, &session, product_id) + .await + } + + async fn sign_statement_store_product_payload( + &self, + _cx: &CallContext, + session: &AuthoritySession, + _account: v01::ProductAccountId, + _payload: Vec, + ) -> Result<[u8; 64], AuthorityError> { + self.current_private_session(session)?; + Err(AuthorityError::Unavailable { + reason: "pairing host: exact statement proof signing is not supported over the \ + current SSO raw-signing protocol" + .to_string(), + }) + } + fn derive_entropy( &self, session: &AuthoritySession, @@ -582,6 +713,25 @@ impl ProductAuthority for PairingHost { PairingHost::allocate_resources(self, cx, session, product_id, request).await } + async fn statement_store_allowance_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result { + PairingHost::statement_store_allowance_key(self, cx, session, product_id).await + } + + async fn sign_statement_store_product_payload( + &self, + cx: &CallContext, + session: &AuthoritySession, + account: v01::ProductAccountId, + payload: Vec, + ) -> Result<[u8; 64], AuthorityError> { + PairingHost::sign_statement_store_product_payload(self, cx, session, account, payload).await + } + fn derive_entropy( &self, session: &AuthoritySession, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index 05831642..12e19c49 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -2,7 +2,7 @@ use super::super::authority::{ AuthorityCancelError, AuthorityError, CreateTransactionAuthorityRequest, - SignPayloadAuthorityRequest, SignRawAuthorityRequest, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, }; use super::super::sso_remote::{ RemoteResponseWait, SSO_LOCAL_DISCONNECT_REASON, SSO_PEER_DISCONNECT_REASON, @@ -14,8 +14,8 @@ use super::AuthorityRequestKind; use super::PairingHost; use crate::host_logic::session::{SessionInfo, SessionState, SsoSessionInfo}; use crate::host_logic::sso::messages::{ - OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, SsoAllocationOutcome, - SsoRemoteResponse, SsoSessionStatement, alias_request_message, + OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, SsoAllocatedResource, + SsoAllocationOutcome, SsoRemoteResponse, SsoSessionStatement, alias_request_message, build_outgoing_request_statement, create_transaction_message, decode_sso_session_statement, resource_allocation_message, sign_payload_message, sign_raw_message, v1, }; @@ -138,6 +138,7 @@ impl PairingHost { self.session_disconnects .notify(sso, SSO_LOCAL_DISCONNECT_REASON); } + self.clear_statement_store_allowance_keys(session); self.stop_disconnect_monitor(); } @@ -187,7 +188,7 @@ impl PairingHost { if !session_matches_key(&self.session_state, key) { return Err(SsoRemoteResponseError::LocalDisconnected); } - let message_id = sso_message_id(cx, action); + let message_id = message.message_id.clone(); let statement = build_outgoing_request_statement( sso, message_id.clone(), @@ -236,6 +237,12 @@ impl PairingHost { disconnect: Some(disconnect), }) .await; + let result = result.map_err(|reason| match reason { + SsoRemoteResponseError::Cancelled(err) if !cx.request_id().is_empty() => { + SsoRemoteResponseError::Cancelled(err.with_remote_message_id(cx.request_id())) + } + reason => reason, + }); match &result { Ok(_) => debug!(action, %message_id, "SSO remote response received"), Err(reason) => warn!(action, %message_id, %reason, "SSO remote message failed"), @@ -253,7 +260,7 @@ impl PairingHost { request: SignPayloadAuthorityRequest, ) -> Result { let action = AuthorityRequestKind::from(&request); - let message_id = sso_message_id(cx, RemoteAction::Signing(action)); + let message_id = sso_message_id(); let request = match request { SignPayloadAuthorityRequest::Product(request) => request, SignPayloadAuthorityRequest::LegacyAccount { @@ -277,7 +284,7 @@ impl PairingHost { request: SignRawAuthorityRequest, ) -> Result { let action = AuthorityRequestKind::from(&request); - let message_id = sso_message_id(cx, RemoteAction::Signing(action)); + let message_id = sso_message_id(); let request = match request { SignRawAuthorityRequest::Product(request) => request, SignRawAuthorityRequest::LegacyAccount { @@ -314,7 +321,7 @@ impl PairingHost { request: CreateTransactionAuthorityRequest, ) -> Result { let action = AuthorityRequestKind::from(&request); - let message_id = sso_message_id(cx, RemoteAction::Signing(action)); + let message_id = sso_message_id(); let request = match request { CreateTransactionAuthorityRequest::Product(request) => request, CreateTransactionAuthorityRequest::LegacyAccount { @@ -351,7 +358,7 @@ impl PairingHost { product_account_id: latest::ProductAccountId, requesting_product_id: String, ) -> Result { - let message_id = sso_message_id(cx, RemoteAction::AccountAlias); + let message_id = sso_message_id(); let message = alias_request_message( message_id.clone(), product_account_id, @@ -376,10 +383,10 @@ impl PairingHost { product_id: String, request: latest::HostRequestResourceAllocationRequest, ) -> Result { - let message_id = sso_message_id(cx, RemoteAction::ResourceAllocation); + let message_id = sso_message_id(); let message = resource_allocation_message( message_id, - product_id, + product_id.clone(), request.resources, OnExistingAllowancePolicy::Increase, ); @@ -392,12 +399,81 @@ impl PairingHost { reason: "Unexpected SSO response for resource allocation request".to_string(), }); }; - response + let outcomes = response.payload.map_err(remote_authority_error)?; + self.cache_statement_store_allowance_outcomes(session, &product_id, &outcomes)?; + Ok(latest::HostRequestResourceAllocationResponse { + outcomes: outcomes.into_iter().map(Into::into).collect(), + }) + } + + pub(super) async fn remote_statement_store_allowance_key( + &self, + cx: &CallContext, + session: &SessionInfo, + product_id: String, + ) -> Result { + if let Some(cached) = self.cached_statement_store_allowance_key(session, &product_id)? { + return Ok(cached); + } + + let message_id = sso_message_id(); + let message = resource_allocation_message( + message_id, + product_id.clone(), + vec![latest::AllocatableResource::StatementStoreAllowance], + OnExistingAllowancePolicy::Ignore, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::ResourceAllocation, message) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::ResourceAllocation(response) = response else { + return Err(AuthorityError::Unknown { + reason: "Unexpected SSO response for statement-store allowance request".to_string(), + }); + }; + let mut outcomes = response .payload - .map(|outcomes| latest::HostRequestResourceAllocationResponse { - outcomes: outcomes.into_iter().map(Into::into).collect(), - }) - .map_err(remote_authority_error) + .map_err(remote_authority_error)? + .into_iter(); + let outcome = outcomes.next().ok_or_else(|| AuthorityError::Unknown { + reason: "Empty statement-store allowance response".to_string(), + })?; + match outcome { + SsoAllocationOutcome::Allocated(SsoAllocatedResource::StatementStoreAllowance { + slot_account_key, + }) => self.cache_statement_store_allowance_key(session, &product_id, slot_account_key), + SsoAllocationOutcome::Allocated(other) => Err(AuthorityError::Unknown { + reason: format!( + "Unexpected statement-store allowance response resource: {other:?}" + ), + }), + SsoAllocationOutcome::Rejected => Err(AuthorityError::Rejected), + SsoAllocationOutcome::NotAvailable => Err(AuthorityError::Unavailable { + reason: "statement-store allowance is not available".to_string(), + }), + } + } + + fn cache_statement_store_allowance_outcomes( + &self, + session: &SessionInfo, + product_id: &str, + outcomes: &[SsoAllocationOutcome], + ) -> Result<(), AuthorityError> { + for outcome in outcomes { + if let SsoAllocationOutcome::Allocated( + SsoAllocatedResource::StatementStoreAllowance { slot_account_key }, + ) = outcome + { + self.cache_statement_store_allowance_key( + session, + product_id, + slot_account_key.clone(), + )?; + } + } + Ok(()) } } diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 8cd58769..79bc5e58 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -14,8 +14,8 @@ pub(crate) use local_activation::LocalActivation; use super::authority::{ AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, - SignPayloadAuthorityRequest, SignRawAuthorityRequest, authority_session, - require_current_session, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, + authority_session, require_current_session, }; use super::connected_session_ui_info; use crate::host_logic::entropy::derive_product_entropy; @@ -200,6 +200,34 @@ impl ProductAuthority for SigningHost { }) } + async fn statement_store_allowance_key( + &self, + _cx: &CallContext, + session: &AuthoritySession, + _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(), + }) + } + + async fn sign_statement_store_product_payload( + &self, + _cx: &CallContext, + session: &AuthoritySession, + account: v01::ProductAccountId, + payload: Vec, + ) -> Result<[u8; 64], AuthorityError> { + require_current_session(&self.session_state, session)?; + let keypair = self.product_keypair(&account)?; + Ok(keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &payload, &keypair.public) + .to_bytes()) + } + fn derive_entropy( &self, session: &AuthoritySession, diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index 2fb60d36..8b020a75 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -28,7 +28,7 @@ use crate::subscription::Spawner; use futures::channel::{mpsc, oneshot}; use futures::{FutureExt, StreamExt, pin_mut}; -use parity_scale_codec::Encode; +use parity_scale_codec::{Decode, Encode}; use serde_json::Value; use subxt_rpcs::RpcClient; use subxt_rpcs::client::RpcSubscription; @@ -97,7 +97,7 @@ impl<'a> SsoPairingFlow<'a> { pub(super) async fn request_session( &self, ) -> Result> { - let pairing_identity = create_fresh_pairing_device_identity(self.host.platform.as_ref()) + let pairing_identity = read_or_create_pairing_device_identity(self.host.platform.as_ref()) .await .map_err(|reason| self.fail_before_pairing(reason))?; let bootstrap = @@ -124,6 +124,7 @@ impl<'a> SsoPairingFlow<'a> { match self.run_pairing_flow(&bootstrap, cancel_rx).await { Ok(outcome @ SsoPairingOutcome::Cancelled) => { + clear_pairing_device_identity(self.host.platform.as_ref()).await; reset_guard.disarm(); Ok(outcome) } @@ -133,6 +134,7 @@ impl<'a> SsoPairingFlow<'a> { } Err(reason) => { self.host.auth_state.login_failed(reason.clone()); + clear_pairing_device_identity(self.host.platform.as_ref()).await; Err(CallError::HostFailure { reason }) } } @@ -225,10 +227,20 @@ impl<'a> SsoPairingFlow<'a> { .fuse(); pin_mut!(persist_session); futures::select! { - _ = cancel => return Ok(SsoPairingOutcome::Cancelled), + _ = cancel => { + clear_auth_session(self.host.platform.as_ref()).await; + return Ok(SsoPairingOutcome::Cancelled); + }, persist_result = persist_session => persist_result .map_err(|err| format!("session persist failed: {err:?}"))?, }; + futures::select! { + _ = cancel => { + clear_auth_session(self.host.platform.as_ref()).await; + return Ok(SsoPairingOutcome::Cancelled); + }, + default => {} + }; Ok(SsoPairingOutcome::Success(Box::new(session))) } } @@ -246,6 +258,46 @@ async fn create_fresh_pairing_device_identity( Ok(identity) } +#[instrument(skip_all, fields(runtime.method = "sso.pairing_device.read_or_create"))] +async fn read_or_create_pairing_device_identity( + storage: &(impl CoreStorage + ?Sized), +) -> Result { + let stored = storage + .read_core_storage(CoreStorageKey::PairingDeviceIdentity) + .await + .map_err(|err| format!("pairing device identity read failed: {err:?}"))?; + if let Some(stored) = stored { + match PairingDeviceIdentity::decode(&mut stored.as_slice()) { + Ok(identity) => return Ok(identity), + Err(err) => { + debug!("discarding invalid stored pairing device identity: {err}"); + } + } + } + + create_fresh_pairing_device_identity(storage).await +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing_device.clear"))] +async fn clear_pairing_device_identity(storage: &(impl CoreStorage + ?Sized)) { + if let Err(err) = storage + .clear_core_storage(CoreStorageKey::PairingDeviceIdentity) + .await + { + debug!("pairing device identity clear failed: {err:?}"); + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.auth_session.clear"))] +async fn clear_auth_session(storage: &(impl CoreStorage + ?Sized)) { + if let Err(err) = storage + .clear_core_storage(CoreStorageKey::AuthSession) + .await + { + debug!("auth session clear failed: {err:?}"); + } +} + struct PairingSuccess { peer_statement_account_id: [u8; 32], success: v2::Success, @@ -456,7 +508,7 @@ mod tests { } #[test] - fn request_login_rotates_pairing_device_identity_between_attempts() { + fn request_login_clears_cancelled_pairing_device_identity_between_attempts() { let platform = stub_platform(); let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); @@ -492,13 +544,68 @@ mod tests { pairing_device_from_deeplink(&deeplinks[1]) ); assert!( - platform + !platform .local_storage .lock() .expect("local storage mutex poisoned") .contains_key(&core_storage_test_key( CoreStorageKey::PairingDeviceIdentity - )) + )), + "cancelled pairing must clear the device identity so stale statement-store responses cannot be replayed" + ); + } + + #[test] + fn request_login_reuses_stored_pairing_device_identity() { + let platform = stub_platform(); + let identity = generate_pairing_device_identity().unwrap(); + platform + .local_storage + .lock() + .expect("local storage mutex poisoned") + .insert( + core_storage_test_key(CoreStorageKey::PairingDeviceIdentity), + identity.encode(), + ); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + let deeplink = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .iter() + .find_map(|state| match state { + AuthState::Pairing { deeplink } => Some(deeplink.clone()), + _ => None, + }) + .expect("pairing state should be emitted"); + assert_eq!( + pairing_device_from_deeplink(&deeplink), + ( + identity.statement_store_public_key, + identity.encryption_public_key + ) + ); + assert!( + !platform + .local_storage + .lock() + .expect("local storage mutex poisoned") + .contains_key(&core_storage_test_key( + CoreStorageKey::PairingDeviceIdentity + )), + "cancelled pairing still clears a reused identity" ); } @@ -647,6 +754,50 @@ mod tests { ); } + #[test] + fn request_login_clears_auth_session_when_cancelled_after_persist() { + let session_writes = Arc::new(Mutex::new(Vec::new())); + let session_clears = Arc::new(Mutex::new(0)); + let platform = Arc::new(StubPlatform { + pairing_success_response: true, + session_writes: session_writes.clone(), + session_clears: session_clears.clone(), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let cancel_host = pairing_host.clone(); + *platform + .on_auth_session_write + .lock() + .expect("auth session write hook mutex poisoned") = Some(Arc::new(move || { + cancel_host.cancel_login(); + })); + + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + assert!(host.test_session_state().current().is_none()); + assert_eq!( + session_writes + .lock() + .expect("session write list mutex poisoned") + .len(), + 1 + ); + assert_eq!( + *session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 1 + ); + } + #[test] fn request_login_connected_callback_can_clear_session_without_reinstalling_it() { let platform = Arc::new(StubPlatform { diff --git a/rust/crates/truapi-server/src/runtime/sso_remote.rs b/rust/crates/truapi-server/src/runtime/sso_remote.rs index 9b154e0b..0fe318c7 100644 --- a/rust/crates/truapi-server/src/runtime/sso_remote.rs +++ b/rust/crates/truapi-server/src/runtime/sso_remote.rs @@ -26,7 +26,7 @@ use serde_json::Value; use subxt_rpcs::RpcClient; use subxt_rpcs::client::RpcSubscription; use tracing::instrument; -use truapi::{CallContext, CancellationReason, CancellationToken}; +use truapi::{CancellationReason, CancellationToken}; /// Host-spec B.3.3 recommends seven-day statement expiry for session traffic: /// @@ -50,7 +50,7 @@ struct SessionDisconnectsInner { waiters: Vec<(u64, SsoSessionKey, oneshot::Sender)>, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(super) struct SsoSessionKey { own: [u8; 32], peer: [u8; 32], @@ -173,6 +173,13 @@ impl CancelError { pub(super) fn remote_message_id(&self) -> &str { &self.remote_message_id } + + pub(super) fn with_remote_message_id(self, remote_message_id: &str) -> Self { + Self { + reason: self.reason, + remote_message_id: remote_message_id.to_string(), + } + } } impl Display for CancelError { @@ -407,14 +414,9 @@ fn format_timeout_duration(duration: Duration) -> String { } } -/// Stable message id for an SSO request: the wire request id when present, -/// otherwise a fixed per-action fallback. -pub(super) fn sso_message_id(cx: &CallContext, action: impl Display) -> String { - if cx.request_id().is_empty() { - format!("truapi:sso:{action}") - } else { - cx.request_id().to_string() - } +/// Fresh opaque message id for one SSO request. +pub(super) fn sso_message_id() -> String { + nanoid::nanoid!(8) } pub(super) fn fresh_statement_expiry() -> u64 { @@ -428,6 +430,24 @@ mod tests { use crate::test_support::sso_session_info; use futures::stream; + #[test] + fn sso_message_id_uses_short_opaque_nanoids() { + let first = sso_message_id(); + let second = sso_message_id(); + + assert_eq!(first.len(), 8); + assert_eq!(second.len(), 8); + assert_ne!(first, "p:1"); + assert_ne!(second, "p:1"); + assert_ne!(first, second); + assert!(first.bytes().all(is_nanoid_safe_byte)); + assert!(second.bytes().all(is_nanoid_safe_byte)); + } + + fn is_nanoid_safe_byte(value: u8) -> bool { + value.is_ascii_alphanumeric() || value == b'_' || value == b'-' + } + #[test] fn sso_remote_response_waiter_reports_timeout_cancellation() { let session = sso_session_info(); diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index 2d7b42d3..c2607a37 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -4,12 +4,17 @@ use core::pin::Pin; use core::task::{Context, Poll}; +use super::authority::{AuthorityError, StatementStoreAllowanceKey}; use super::statement_store_rpc::{self, StatementStoreRpc}; -use super::{ProductRuntimeHost, REMOTE_PERMISSION_DENIED_REASON}; +use super::{ + ProductRuntimeHost, REMOTE_PERMISSION_DENIED_REASON, remote_authority_call, + remote_authority_context, +}; +use crate::host_logic::product_account::derive_product_public_key; use crate::host_logic::statement_store::{ MAX_MATCH_ALL_TOPICS, MAX_MATCH_ANY_TOPICS, TopicFilterKind, decode_signed_statement, parse_new_statements_result, sign_statement_fields, signed_statement_to_scale, - statement_fields_from_v01, statement_proof_to_v01, + statement_fields_from_v01, statement_proof_to_v01, unsigned_statement_signing_payload, }; use serde_json::Value; @@ -77,7 +82,7 @@ impl StatementStore for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "statement_store.create_proof"))] async fn create_proof( &self, - _cx: &CallContext, + cx: &CallContext, request: RemoteStatementStoreCreateProofRequest, ) -> Result< RemoteStatementStoreCreateProofResponse, @@ -96,7 +101,8 @@ impl StatementStore for ProductRuntimeHost { ))); } let proof = self - .create_statement_proof(inner.statement) + .create_product_statement_proof(cx, inner.product_account_id, inner.statement) + .await .map_err(statement_proof_error)?; Ok(RemoteStatementStoreCreateProofResponse::V1( v01::RemoteStatementStoreCreateProofResponse { proof }, @@ -106,7 +112,7 @@ impl StatementStore for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "statement_store.create_proof_authorized"))] async fn create_proof_authorized( &self, - _cx: &CallContext, + cx: &CallContext, request: RemoteStatementStoreCreateProofAuthorizedRequest, ) -> Result< RemoteStatementStoreCreateProofAuthorizedResponse, @@ -114,7 +120,8 @@ impl StatementStore for ProductRuntimeHost { > { let RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement) = request; let proof = self - .create_statement_proof(statement) + .create_authorized_statement_proof(cx, statement) + .await .map_err(statement_proof_authorized_error)?; Ok(RemoteStatementStoreCreateProofAuthorizedResponse::V1( v01::RemoteStatementStoreCreateProofResponse { proof }, @@ -254,41 +261,94 @@ impl ProductRuntimeHost { self.services.statement_store.clone() } - fn create_statement_proof( + async fn create_product_statement_proof( &self, + cx: &CallContext, + product_account_id: v01::ProductAccountId, statement: v01::Statement, ) -> Result { let session = self .authority - .session_state() - .current() - .ok_or(StatementProofFailure::NoSession)?; - let sso = session - .sso - .as_ref() + .current_session() .ok_or(StatementProofFailure::NoSession)?; + let signer = derive_product_public_key( + session.public_key, + &product_account_id.dot_ns_identifier, + product_account_id.derivation_index, + ) + .map_err(|err| StatementProofFailure::UnableToSign(err.to_string()))?; let fields = statement_fields_from_v01(statement) .map_err(StatementProofFailure::InvalidStatement)?; - let signed = sign_statement_fields(sso.ss_secret, sso.ss_public_key, fields) + let payload = unsigned_statement_signing_payload(fields) .map_err(StatementProofFailure::UnableToSign)?; - signed - .into_iter() - .find_map(|field| match field { - crate::host_logic::statement_store::StatementField::Proof(proof) => { - Some(statement_proof_to_v01(proof)) - } - _ => None, - }) - .ok_or_else(|| StatementProofFailure::UnableToSign("missing proof".to_string())) + let cx = remote_authority_context(cx); + let signature = remote_authority_call( + &cx, + self.authority.sign_statement_store_product_payload( + &cx, + &session, + product_account_id, + payload, + ), + ) + .await + .map_err(statement_authority_failure)?; + Ok(v01::StatementProof::Sr25519 { signature, signer }) + } + + async fn create_authorized_statement_proof( + &self, + cx: &CallContext, + statement: v01::Statement, + ) -> Result { + let session = self + .authority + .current_session() + .ok_or(StatementProofFailure::NoSession)?; + let cx = remote_authority_context(cx); + let allowance = remote_authority_call( + &cx, + self.authority + .statement_store_allowance_key(&cx, &session, self.product_id()), + ) + .await + .map_err(statement_authority_failure)?; + create_statement_proof_with_key(statement, &allowance) } } +fn create_statement_proof_with_key( + statement: v01::Statement, + key: &StatementStoreAllowanceKey, +) -> Result { + let fields = + statement_fields_from_v01(statement).map_err(StatementProofFailure::InvalidStatement)?; + let signed = sign_statement_fields(key.secret, key.public_key, fields) + .map_err(StatementProofFailure::UnableToSign)?; + signed + .into_iter() + .find_map(|field| match field { + crate::host_logic::statement_store::StatementField::Proof(proof) => { + Some(statement_proof_to_v01(proof)) + } + _ => None, + }) + .ok_or_else(|| StatementProofFailure::UnableToSign("missing proof".to_string())) +} + enum StatementProofFailure { NoSession, InvalidStatement(String), UnableToSign(String), } +fn statement_authority_failure(err: AuthorityError) -> StatementProofFailure { + match err { + AuthorityError::Disconnected => StatementProofFailure::NoSession, + err => StatementProofFailure::UnableToSign(err.reason()), + } +} + fn statement_proof_v01_error( failure: StatementProofFailure, ) -> v01::RemoteStatementStoreCreateProofError { @@ -321,22 +381,61 @@ fn statement_proof_authorized_error( #[cfg(test)] mod tests { + use super::super::{LocalActivation, RuntimeServices, SigningHostRole}; use super::*; + use crate::host_logic::product_account::{ + SR25519_SIGNING_CONTEXT, derive_product_keypair, derive_root_keypair_from_entropy, + }; use crate::test_support::{ StubPlatform, account_id, new_statements_frame, runtime_config, signed_statement, - sso_session_info, statement, stub_platform, subscribe_ack_frame, test_spawner, + sso_session_info, sso_success_response_script, statement, stub_platform, + submitted_remote_message, subscribe_ack_frame, test_spawner, }; use futures::StreamExt; use parity_scale_codec::Encode; + use schnorrkel::{ExpansionMode, MiniSecretKey, PublicKey, Signature}; use std::sync::Arc; + use truapi_platform::ProductContext; + + const ENTROPY: [u8; 16] = [0xAB; 16]; + + fn statement_payload(statement: v01::Statement) -> Vec { + unsigned_statement_signing_payload(statement_fields_from_v01(statement).unwrap()).unwrap() + } + + fn allowance_key(seed: u8) -> ([u8; 64], [u8; 32]) { + let mini_secret = MiniSecretKey::from_bytes(&[seed; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + (keypair.secret.to_bytes(), keypair.public.to_bytes()) + } + + fn assert_sr25519_signature(signer: [u8; 32], signature: [u8; 64], payload: &[u8]) { + let public = PublicKey::from_bytes(&signer).unwrap(); + let signature = Signature::from_bytes(&signature).unwrap(); + public + .verify_simple(SR25519_SIGNING_CONTEXT, payload, &signature) + .unwrap(); + } + + fn signing_host_runtime(product_id: &str) -> (ProductRuntimeHost, Arc) { + let platform: Arc = Arc::new(StubPlatform::default()); + let services = RuntimeServices::new(platform.clone(), [0; 32], test_spawner()); + let signing_host = SigningHostRole::new(platform); + futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let host = ProductRuntimeHost::from_services( + services, + signing_host.clone(), + ProductContext::new(product_id.to_string()).expect("valid product id"), + ); + (host, signing_host) + } #[test] - fn statement_store_create_proof_signs_with_session_key() { + fn statement_store_create_proof_pairing_host_does_not_use_session_key() { let host = ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); - let session = sso_session_info(); - let expected_signer = session.sso.as_ref().unwrap().ss_public_key; - host.test_session_state().set_session(session); + host.test_session_state().set_session(sso_session_info()); let cx = CallContext::new(); let request = RemoteStatementStoreCreateProofRequest::V1( v01::RemoteStatementStoreCreateProofRequest { @@ -345,6 +444,33 @@ mod tests { }, ); + let err = futures::executor::block_on(StatementStore::create_proof(&host, &cx, request)) + .unwrap_err(); + + assert!(matches!( + err, + CallError::Domain(RemoteStatementStoreCreateProofError::V1( + v01::RemoteStatementStoreCreateProofError::UnableToSign + )) + )); + } + + #[test] + fn statement_store_create_proof_signing_host_uses_product_key() { + let (host, _signing_host) = signing_host_runtime("myapp.dot"); + let statement = statement(); + let payload = statement_payload(statement.clone()); + let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); + let product_keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let expected_signer = product_keypair.public.to_bytes(); + let cx = CallContext::new(); + let request = RemoteStatementStoreCreateProofRequest::V1( + v01::RemoteStatementStoreCreateProofRequest { + product_account_id: account_id("myapp.dot", 0), + statement, + }, + ); + let response = futures::executor::block_on(StatementStore::create_proof(&host, &cx, request)).unwrap(); @@ -353,7 +479,7 @@ mod tests { panic!("expected sr25519 statement proof"); }; assert_eq!(signer, expected_signer); - assert_ne!(signature, [0; 64]); + assert_sr25519_signature(signer, signature, &payload); } #[test] @@ -381,14 +507,42 @@ mod tests { } #[test] - fn statement_store_create_proof_authorized_signs_with_session_key() { - let host = - ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + fn statement_store_create_proof_authorized_signs_with_allowance_key() { let session = sso_session_info(); - let expected_signer = session.sso.as_ref().unwrap().ss_public_key; - host.test_session_state().set_session(session); - let cx = CallContext::new(); - let request = RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement()); + let statement = statement(); + let payload = statement_payload(statement.clone()); + let (allowance_secret, expected_signer) = allowance_key(11); + let platform = Arc::new(StubPlatform { + sso_response_script: Some(sso_success_response_script( + &session, + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-proof-auth-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::ResourceAllocationResponse( + crate::host_logic::sso::messages::ResourceAllocationResponse { + responding_to: "proof-auth-1".to_string(), + payload: Ok(vec![ + crate::host_logic::sso::messages::SsoAllocationOutcome::Allocated( + crate::host_logic::sso::messages::SsoAllocatedResource::StatementStoreAllowance { + slot_account_key: allowance_secret.to_vec(), + }, + ), + ]), + }, + ), + ), + }, + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("proof-auth-1".to_string()); + let request = RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement); let response = futures::executor::block_on(StatementStore::create_proof_authorized( &host, &cx, request, @@ -396,10 +550,28 @@ mod tests { .unwrap(); let RemoteStatementStoreCreateProofAuthorizedResponse::V1(inner) = response; - let v01::StatementProof::Sr25519 { signer, .. } = inner.proof else { + let v01::StatementProof::Sr25519 { signer, signature } = inner.proof else { panic!("expected sr25519 statement proof"); }; assert_eq!(signer, expected_signer); + assert_sr25519_signature(signer, signature, &payload); + + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::ResourceAllocationRequest(request), + ) = message.data + else { + panic!("expected resource allocation request"); + }; + assert_eq!(request.calling_product_id, "myapp.dot"); + assert_eq!( + request.on_existing, + crate::host_logic::sso::messages::OnExistingAllowancePolicy::Ignore + ); + assert_eq!( + request.resources, + vec![crate::host_logic::sso::messages::SsoAllocatableResource::StatementStoreAllowance] + ); } #[test] diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 62f2816f..5cce7283 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -10,6 +10,8 @@ use std::time::Duration; #[cfg(target_arch = "wasm32")] use web_time::Duration; +use crate::host_logic::session::SessionInfo; +use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, v1}; use crate::host_logic::sso::pairing; use crate::subscription::Spawner; #[cfg(not(target_arch = "wasm32"))] @@ -31,11 +33,12 @@ use truapi::v01; use truapi::versioned::account::HostAccountGetAliasRequest; use truapi::versioned::resource_allocation::HostRequestResourceAllocationRequest; use truapi_platform::{ - AuthPresenter, AuthState, ChainProvider, CoreStorage as PlatformCoreStorage, CoreStorageKey, - Features as PlatformFeatures, HostInfo, JsonRpcConnection, Navigation as PlatformNavigation, - Notifications as PlatformNotifications, PairingHostConfig, Permissions as PlatformPermissions, - PlatformInfo, PreimageHost, ProductContext, ProductStorage as PlatformProductStorage, - ThemeHost, UserConfirmation, UserConfirmationReview, + AccountAccessReview, AuthPresenter, AuthState, ChainProvider, + CoreStorage as PlatformCoreStorage, CoreStorageKey, Features as PlatformFeatures, HostInfo, + JsonRpcConnection, Navigation as PlatformNavigation, Notifications as PlatformNotifications, + PairingHostConfig, Permissions as PlatformPermissions, PlatformInfo, PreimageHost, + ProductContext, ProductStorage as PlatformProductStorage, ThemeHost, UserConfirmation, + UserConfirmationReview, }; /// Test spawner that matches the current target. @@ -58,6 +61,8 @@ pub(crate) fn immediate_spawner() -> Spawner { /// Test hook invoked after each recorded auth state. pub type AuthStateHook = Arc; +/// Test hook invoked after an auth-session write is recorded. +pub type StorageWriteHook = Arc; /// Minimal Platform impl that only answers `feature_supported`. Every /// other callback returns a unit value or empty stream, so the runtime @@ -67,6 +72,9 @@ pub(crate) struct StubPlatform { pub(crate) remote_permission_denied: bool, pub(crate) account_alias_confirmed: bool, pub(crate) account_alias_error: Option<&'static str>, + pub(crate) account_access_confirmed: bool, + pub(crate) account_access_error: Option<&'static str>, + pub(crate) account_access_reviews: Arc>>, pub(crate) identity_disclosure_confirmed: bool, pub(crate) identity_disclosure_error: Option<&'static str>, pub(crate) identity_disclosure_calls: Arc, @@ -82,6 +90,7 @@ pub(crate) struct StubPlatform { pub(crate) session_error: Option<&'static str>, pub(crate) session_clears: Arc>, pub(crate) session_writes: Arc>>>, + pub(crate) on_auth_session_write: Arc>>, /// Every `auth_state_changed` emission in order. pub(crate) auth_states: Arc>>, /// Invoked after each recorded auth state, outside any stub lock, so a @@ -103,6 +112,7 @@ pub(crate) struct StubPlatform { pub(crate) cancelled_notifications: Arc>>, pub(crate) sent_rpc: Arc>>, pub(crate) rpc_responses: Vec, + pub(crate) sso_response_script: Option, pub(crate) chain_connect_error: Option<&'static str>, pub(crate) chain_connect_pending: bool, pub(crate) preimage_submits: Arc>>>, @@ -111,6 +121,17 @@ pub(crate) struct StubPlatform { pub(crate) local_storage_error: Option<&'static str>, } +#[derive(Clone)] +pub(crate) enum SsoResponseScript { + Success { + session: SessionInfo, + response: RemoteMessage, + }, + PeerDisconnect { + session: SessionInfo, + }, +} + struct DropFlagGuard(Arc); impl Drop for DropFlagGuard { @@ -252,9 +273,17 @@ pub(crate) fn signed_test_statement(data: Vec) -> Vec { /// Last submitted SSO remote message decoded from the stub RPC log. pub(crate) fn submitted_remote_message( platform: &Arc, - session: &crate::host_logic::session::SessionInfo, -) -> crate::host_logic::sso::messages::RemoteMessage { + session: &SessionInfo, +) -> RemoteMessage { let submit = wait_for_statement_submit(&platform.sent_rpc); + let (_, message) = submitted_sso_request_from_submit(&submit, session); + message +} + +fn submitted_sso_request_from_submit( + submit: &str, + session: &SessionInfo, +) -> (String, RemoteMessage) { let value: serde_json::Value = serde_json::from_str(&submit).unwrap(); let statement_hex = value["params"][0].as_str().unwrap(); let statement = hex::decode(statement_hex.strip_prefix("0x").unwrap_or(statement_hex)).unwrap(); @@ -262,11 +291,20 @@ pub(crate) fn submitted_remote_message( .expect("statement data should decode"); let data = pairing::decrypt_session_statement_data(session.sso.as_ref().unwrap(), &encrypted) .expect("statement data should decrypt"); - let pairing::SsoStatementData::Request { data, .. } = data else { + let pairing::SsoStatementData::Request { request_id, data } = data else { panic!("expected request statement data"); }; - crate::host_logic::sso::messages::RemoteMessage::decode(&mut data[0].as_slice()) - .expect("remote message should decode") + let message = + RemoteMessage::decode(&mut data[0].as_slice()).expect("remote message should decode"); + (request_id, message) +} + +fn submitted_sso_request( + sent: &Arc>>, + session: &SessionInfo, +) -> (String, RemoteMessage) { + let submit = wait_for_statement_submit(sent); + submitted_sso_request_from_submit(&submit, session) } fn wait_for_statement_submit(sent: &Arc>>) -> String { @@ -291,9 +329,9 @@ fn wait_for_statement_submit(sent: &Arc>>) -> String { /// JSON-RPC response sequence for a successful SSO request/response exchange. pub(crate) fn sso_success_responses( - session: &crate::host_logic::session::SessionInfo, + session: &SessionInfo, message_id: &str, - response: crate::host_logic::sso::messages::RemoteMessage, + response: RemoteMessage, ) -> Vec { let own_subscription_id = format!("own-sub-{message_id}"); let peer_subscription_id = format!("peer-sub-{message_id}"); @@ -326,48 +364,22 @@ pub(crate) fn sso_success_responses( ] } -/// JSON-RPC response sequence where the SSO peer sends `Disconnected`. -pub(crate) fn sso_peer_disconnect_responses( - session: &crate::host_logic::session::SessionInfo, - message_id: &str, -) -> Vec { - let own_subscription_id = format!("own-sub-{message_id}"); - let peer_subscription_id = format!("peer-sub-{message_id}"); - vec![ - subscribe_ack_frame("truapi:1", &own_subscription_id), - subscribe_ack_frame("truapi:2", &peer_subscription_id), - statement_submit_ack_frame("truapi:3"), - new_statements_frame( - &own_subscription_id, - vec![sso_statement( - session, - pairing::SsoStatementData::Response { - request_id: message_id.to_string(), - response_code: 0, - }, - 1, - )], - ), - new_statements_frame( - &peer_subscription_id, - vec![sso_statement( - session, - pairing::SsoStatementData::Request { - request_id: format!("wallet-disconnect-{message_id}"), - data: vec![ - crate::host_logic::sso::messages::RemoteMessage { - message_id: format!("wallet-disconnect-{message_id}"), - data: crate::host_logic::sso::messages::RemoteMessageData::V1( - crate::host_logic::sso::messages::v1::RemoteMessage::Disconnected, - ), - } - .encode(), - ], - }, - 2, - )], - ), - ] +/// Dynamic JSON-RPC response script for a successful SSO request/response exchange. +pub(crate) fn sso_success_response_script( + session: &SessionInfo, + response: RemoteMessage, +) -> SsoResponseScript { + SsoResponseScript::Success { + session: session.clone(), + response, + } +} + +/// Dynamic JSON-RPC response script where the SSO peer sends `Disconnected`. +pub(crate) fn sso_peer_disconnect_response_script(session: &SessionInfo) -> SsoResponseScript { + SsoResponseScript::PeerDisconnect { + session: session.clone(), + } } /// JSON-RPC response sequence for the background peer-disconnect monitor. @@ -720,6 +732,14 @@ impl PlatformCoreStorage for StubPlatform { .lock() .expect("session write list mutex poisoned") .push(value); + let hook = self + .on_auth_session_write + .lock() + .expect("auth session write hook mutex poisoned") + .clone(); + if let Some(hook) = hook { + hook(); + } return Ok(()); } if let Some(reason) = self.local_storage_error { @@ -823,12 +843,21 @@ impl PlatformFeatures for StubPlatform { struct RecordingConnection { sent: Arc>>, responses: Vec, + sso_response_script: Option, auth_states: Arc>>, pairing_success_response: bool, pairing_success_via_query: bool, } async fn wait_for_statement_subscribe_id(sent: Arc>>, index: usize) -> String { + wait_for_rpc_method_id(sent, "statement_subscribeStatement", index).await +} + +async fn wait_for_rpc_method_id( + sent: Arc>>, + method: &str, + index: usize, +) -> String { for _ in 0..100 { let ids = sent .lock() @@ -836,7 +865,7 @@ async fn wait_for_statement_subscribe_id(sent: Arc>>, index: u .iter() .filter_map(|request| { let value: serde_json::Value = serde_json::from_str(request).ok()?; - (value.get("method")?.as_str()? == "statement_subscribeStatement") + (value.get("method")?.as_str()? == method) .then(|| value.get("id")?.as_str().map(ToString::to_string))? }) .collect::>(); @@ -845,7 +874,126 @@ async fn wait_for_statement_subscribe_id(sent: Arc>>, index: u } futures_timer::Delay::new(Duration::from_millis(1)).await; } - panic!("statement_subscribeStatement request {index} was not issued"); + panic!("{method} request {index} was not issued"); +} + +fn retarget_sso_response(mut response: RemoteMessage, message_id: &str) -> RemoteMessage { + response.message_id = format!("wallet-{message_id}"); + match &mut response.data { + RemoteMessageData::V1(v1::RemoteMessage::SignResponse(response)) => { + response.responding_to = message_id.to_string(); + } + RemoteMessageData::V1(v1::RemoteMessage::RingVrfAliasResponse(response)) => { + response.responding_to = message_id.to_string(); + } + RemoteMessageData::V1(v1::RemoteMessage::SignRawLegacyResponse(response)) => { + response.responding_to = message_id.to_string(); + } + RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse(response)) => { + response.responding_to = message_id.to_string(); + } + RemoteMessageData::V1(v1::RemoteMessage::CreateTransactionResponse(response)) => { + response.responding_to = message_id.to_string(); + } + _ => {} + } + response +} + +fn sso_scripted_responses( + sent: Arc>>, + script: SsoResponseScript, +) -> BoxStream<'static, String> { + Box::pin(stream::unfold(0, move |state| { + let sent = sent.clone(); + let script = script.clone(); + async move { + match state { + 0 => { + let id = wait_for_statement_subscribe_id(sent.clone(), 0).await; + Some((subscribe_ack_frame(&id, "own-sub"), 1)) + } + 1 => { + let id = wait_for_statement_subscribe_id(sent.clone(), 1).await; + Some((subscribe_ack_frame(&id, "peer-sub"), 2)) + } + 2 => { + let id = wait_for_rpc_method_id(sent.clone(), "statement_submit", 0).await; + Some((statement_submit_ack_frame(&id), 3)) + } + 3 => match &script { + SsoResponseScript::Success { session, .. } + | SsoResponseScript::PeerDisconnect { session } => { + let (statement_request_id, _) = submitted_sso_request(&sent, session); + Some(( + new_statements_frame( + "own-sub", + vec![sso_statement( + session, + pairing::SsoStatementData::Response { + request_id: statement_request_id, + response_code: 0, + }, + 1, + )], + ), + 4, + )) + } + }, + 4 => match script { + SsoResponseScript::Success { session, response } => { + let (_, request) = submitted_sso_request(&sent, &session); + let response = retarget_sso_response(response, &request.message_id); + Some(( + new_statements_frame( + "peer-sub", + vec![sso_statement( + &session, + pairing::SsoStatementData::Request { + request_id: format!( + "wallet-response-{}", + request.message_id + ), + data: vec![response.encode()], + }, + 2, + )], + ), + 5, + )) + } + SsoResponseScript::PeerDisconnect { session } => { + let (_, request) = submitted_sso_request(&sent, &session); + let message_id = format!("wallet-disconnect-{}", request.message_id); + Some(( + new_statements_frame( + "peer-sub", + vec![sso_statement( + &session, + pairing::SsoStatementData::Request { + request_id: message_id.clone(), + data: vec![ + RemoteMessage { + message_id, + data: RemoteMessageData::V1( + v1::RemoteMessage::Disconnected, + ), + } + .encode(), + ], + }, + 2, + )], + ), + 5, + )) + } + }, + _ => futures::future::pending().await, + } + } + })) } impl JsonRpcConnection for RecordingConnection { @@ -924,6 +1072,9 @@ impl JsonRpcConnection for RecordingConnection { } })); } + if let Some(script) = self.sso_response_script.clone() { + return sso_scripted_responses(self.sent.clone(), script); + } if self.responses.is_empty() { Box::pin(futures::stream::pending()) } else { @@ -991,6 +1142,7 @@ impl ChainProvider for StubPlatform { Ok(Box::new(RecordingConnection { sent: self.sent_rpc.clone(), responses: self.rpc_responses.clone(), + sso_response_script: self.sso_response_script.clone(), auth_states: self.auth_states.clone(), pairing_success_response: self.pairing_success_response, pairing_success_via_query: self.pairing_success_via_query, @@ -1033,6 +1185,13 @@ impl UserConfirmation for StubPlatform { UserConfirmationReview::AccountAlias(_) => { (self.account_alias_error, self.account_alias_confirmed) } + UserConfirmationReview::AccountAccess(review) => { + self.account_access_reviews + .lock() + .expect("account access review list mutex poisoned") + .push(review); + (self.account_access_error, self.account_access_confirmed) + } UserConfirmationReview::IdentityDisclosure(_) => { self.identity_disclosure_calls .fetch_add(1, Ordering::SeqCst); From 95ce2e9bded890595ab72f70a647a325ec7b1a97 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Tue, 7 Jul 2026 10:09:12 +0200 Subject: [PATCH 45/56] fixup! feat(codegen): generate wasm bridge callbacks --- .../tests/golden/host-callbacks.ts | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 4c060456..f86b2a36 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -31,6 +31,21 @@ import type { ThemeVariant, } from "@parity/truapi"; +/** + * Review shown before a product asks to access another product account. + */ +export interface AccountAccessReview { + /** + * Product currently handling the request. + */ + requestingProductId: string; + + /** + * Product whose account is being requested. + */ + targetProductId: string; +} + /** * Review shown before a product asks to alias another product account. */ @@ -234,7 +249,16 @@ export type UserConfirmationReview = /** * Submit a preimage to the host-selected backend. */ - | { tag: "PreimageSubmit"; value: PreimageSubmitReview }; + | { tag: "PreimageSubmit"; value: PreimageSubmitReview } + /** + * Allow a product to access another product account. + */ + | { tag: "AccountAccess"; value: AccountAccessReview }; + +/** + * Review shown before a product asks to access another product account. + */ +export const AccountAccessReview: S.Codec = S.lazy((): S.Codec => S.Struct({requestingProductId: S.str, targetProductId: S.str}) as S.Codec); /** * Review shown before a product asks to alias another product account. @@ -305,7 +329,7 @@ export const SignRawReview: S.Codec = S.lazy((): S.Codec = S.lazy((): S.Codec => S.TaggedUnion({SignPayload: SignPayloadReview, SignRaw: SignRawReview, CreateTransaction: CreateTransactionReview, AccountAlias: AccountAliasReview, IdentityDisclosure: IdentityDisclosureReview, ResourceAllocation: HostRequestResourceAllocationRequest, PreimageSubmit: PreimageSubmitReview})); +export const UserConfirmationReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({SignPayload: SignPayloadReview, SignRaw: SignRawReview, CreateTransaction: CreateTransactionReview, AccountAlias: AccountAliasReview, IdentityDisclosure: IdentityDisclosureReview, ResourceAllocation: HostRequestResourceAllocationRequest, PreimageSubmit: PreimageSubmitReview, AccountAccess: AccountAccessReview})); /** * Host auth UI driven by core-owned `AuthState` transitions. From 4bdb9b55d169ed4ff46e8723b7a5cee760dedd05 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 8 Jul 2026 09:42:58 +0200 Subject: [PATCH 46/56] fixup! feat(truapi-server): add platform runtime and host bridge --- .../tests/golden/host-callbacks-adapter.ts | 5 +- .../tests/golden/host-callbacks.ts | 54 +++- .../tests/golden/worker-callbacks.ts | 4 +- rust/crates/truapi-platform/Cargo.toml | 2 +- rust/crates/truapi-platform/src/lib.rs | 125 +++++++- rust/crates/truapi-server/Cargo.toml | 2 +- rust/crates/truapi-server/src/host_logic.rs | 1 + .../src/host_logic/allowance_signer.rs | 93 ++++++ .../truapi-server/src/host_logic/session.rs | 45 +-- .../src/host_logic/sso/messages.rs | 50 +-- .../truapi-server/src/host_rpc_client.rs | 14 +- rust/crates/truapi-server/src/runtime.rs | 153 ++++++++- .../truapi-server/src/runtime/allowances.rs | 296 ++++++++++++++++++ .../truapi-server/src/runtime/authority.rs | 20 +- .../truapi-server/src/runtime/pairing_host.rs | 205 +++++++++--- .../src/runtime/pairing_host/sso_channel.rs | 101 +++++- .../truapi-server/src/runtime/signing_host.rs | 18 +- .../truapi-server/src/runtime/sso_pairing.rs | 140 +++++++-- rust/crates/truapi-server/src/test_support.rs | 23 +- rust/crates/truapi-server/tests/common/mod.rs | 12 +- rust/crates/truapi/src/lib.rs | 2 +- 21 files changed, 1171 insertions(+), 194 deletions(-) create mode 100644 rust/crates/truapi-server/src/host_logic/allowance_signer.rs create mode 100644 rust/crates/truapi-server/src/runtime/allowances.rs 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..d87dfd88 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -20,6 +20,7 @@ import type { NotificationId, } from "@parity/truapi"; import { + BulletinAllowanceSigner, CoreStorageKey, UserConfirmationReview, } from "./host-callbacks.js"; @@ -41,7 +42,7 @@ export interface RawCallbacks { cancelNotification(id: NotificationId): Promise; devicePermission(request: Uint8Array): Promise; remotePermission(request: Uint8Array): Promise; - submitPreimage(value: Uint8Array): Promise; + submitPreimage(value: Uint8Array, bulletinAllowanceSigner: Uint8Array): Promise; lookupPreimage(key: Uint8Array, sendItem: (item?: Uint8Array) => void): (() => void) | void; read(key: string): Promise; write(key: string, value: Uint8Array): Promise; @@ -67,7 +68,7 @@ export function createWasmRawCallbacks( 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), + submitPreimage: async (value, bulletinAllowanceSigner) => await host.submitPreimage(value, BulletinAllowanceSigner.dec(bulletinAllowanceSigner)), lookupPreimage: (key, sendItem) => driveResultStream(host.lookupPreimage(key), sendItem), read: async (key) => await host.read(key), write: async (key, value) => await host.write(key, value), diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 4321e226..43b3a7b4 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -31,6 +31,21 @@ import type { ThemeVariant, } from "@parity/truapi"; +/** + * Review shown before a product asks to access another product account. + */ +export interface AccountAccessReview { + /** + * Product currently handling the request. + */ + requestingProductId: string; + + /** + * Product whose account is being requested. + */ + targetProductId: string; +} + /** * Review shown before a product asks to alias another product account. */ @@ -71,6 +86,13 @@ export type AuthState = */ | { tag: "LoginFailed"; value: { reason: string } }; +/** + * Host-facing signer for Bulletin preimage submission. + */ +export interface BulletinAllowanceSigner { + +} + /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. @@ -90,7 +112,15 @@ export type CoreStorageKey = /** * Persisted authorization for one product-scoped permission request. */ - | { tag: "PermissionAuthorization"; value: { productId: string; request: PermissionAuthorizationRequest } }; + | { tag: "PermissionAuthorization"; value: { productId: string; request: PermissionAuthorizationRequest } } + /** + * Persisted allowance-slot keys for one paired SSO session. + */ + | { tag: "AllowanceKeys"; value: { sessionId: string } } + /** + * Last processed SSO pairing response statement for the pairing device. + */ + | { tag: "LastProcessedPairingStatement"; value?: undefined }; /** * Review shown before a transaction-creation request is sent to the paired wallet. @@ -234,13 +264,27 @@ export type UserConfirmationReview = /** * Submit a preimage to the host-selected backend. */ - | { tag: "PreimageSubmit"; value: PreimageSubmitReview }; + | { tag: "PreimageSubmit"; value: PreimageSubmitReview } + /** + * Allow a product to access another product account. + */ + | { tag: "AccountAccess"; value: AccountAccessReview }; + +/** + * Review shown before a product asks to access another product account. + */ +export const AccountAccessReview: S.Codec = S.lazy((): S.Codec => S.Struct({requestingProductId: S.str, targetProductId: S.str}) as S.Codec); /** * Review shown before a product asks to alias another product account. */ export const AccountAliasReview: S.Codec = S.lazy((): S.Codec => S.Struct({requestingProductId: S.str, targetProductId: S.str}) as S.Codec); +/** + * Host-facing signer for Bulletin preimage submission. + */ +export const BulletinAllowanceSigner: S.Codec = S.lazy((): S.Codec => S.Struct({}) as S.Codec); + /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. @@ -248,7 +292,7 @@ export const AccountAliasReview: S.Codec = S.lazy((): S.Code * Storage is host-local; `storage.md` records the current status quo: * */ -export const CoreStorageKey: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({AuthSession: S._void, PairingDeviceIdentity: S._void, PermissionAuthorization: S.Struct({productId: S.str, request: PermissionAuthorizationRequest}) as S.Codec<{ productId: string; request: PermissionAuthorizationRequest }>})); +export const CoreStorageKey: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({AuthSession: S._void, PairingDeviceIdentity: S._void, PermissionAuthorization: S.Struct({productId: S.str, request: PermissionAuthorizationRequest}) as S.Codec<{ productId: string; request: PermissionAuthorizationRequest }>, AllowanceKeys: S.Struct({sessionId: S.str}) as S.Codec<{ sessionId: string }>, LastProcessedPairingStatement: S._void})); /** * Review shown before a transaction-creation request is sent to the paired wallet. @@ -292,7 +336,7 @@ export const SignRawReview: S.Codec = S.lazy((): S.Codec = S.lazy((): S.Codec => S.TaggedUnion({SignPayload: SignPayloadReview, SignRaw: SignRawReview, CreateTransaction: CreateTransactionReview, AccountAlias: AccountAliasReview, IdentityDisclosure: IdentityDisclosureReview, ResourceAllocation: HostRequestResourceAllocationRequest, PreimageSubmit: PreimageSubmitReview})); +export const UserConfirmationReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({SignPayload: SignPayloadReview, SignRaw: SignRawReview, CreateTransaction: CreateTransactionReview, AccountAlias: AccountAliasReview, IdentityDisclosure: IdentityDisclosureReview, ResourceAllocation: HostRequestResourceAllocationRequest, PreimageSubmit: PreimageSubmitReview, AccountAccess: AccountAccessReview})); /** * Host auth UI driven by core-owned `AuthState` transitions. @@ -481,7 +525,7 @@ export interface PreimageHost { /** * Submit the preimage and return its key. */ - submitPreimage?(value: Uint8Array): Promise; + submitPreimage?(value: Uint8Array, bulletinAllowanceSigner: BulletinAllowanceSigner): Promise; /** * Emits current value/miss immediately, then future updates. diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts index 34275dbf..fadb7938 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -65,8 +65,8 @@ function rawCallbacks(bridge: WorkerCallbackBridge): Required, remotePermission: (request) => bridge.callbackRequest("remotePermission", [request]) as ReturnType, - submitPreimage: (value) => - bridge.callbackRequest("submitPreimage", [value]) as ReturnType, + submitPreimage: (value, bulletinAllowanceSigner) => + bridge.callbackRequest("submitPreimage", [value, bulletinAllowanceSigner]) as ReturnType, read: (key) => bridge.callbackRequest("read", [key]) as ReturnType, write: (key, value) => diff --git a/rust/crates/truapi-platform/Cargo.toml b/rust/crates/truapi-platform/Cargo.toml index ee4f101c..402cb3d1 100644 --- a/rust/crates/truapi-platform/Cargo.toml +++ b/rust/crates/truapi-platform/Cargo.toml @@ -8,7 +8,7 @@ license = "MIT" [dependencies] truapi = { path = "../truapi" } async-trait = "0.1" -derive_more = { version = "2", features = ["display"] } +derive_more = { version = "2", features = ["display", "error"] } futures = "0.3" parity-scale-codec = { version = "3", features = ["derive"] } unicode-normalization = "0.1" diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 7602915c..392747c1 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -9,6 +9,8 @@ //! Async capability traits use `async_trait` so the combined [`Platform`] //! surface can be used as a trait object by the runtime. +use std::sync::Arc; + use futures::stream::BoxStream; use parity_scale_codec::{Decode, Encode}; use unicode_normalization::UnicodeNormalization; @@ -210,7 +212,7 @@ fn require_non_empty(field: &'static str, value: &str) -> Result<(), RuntimeConf } /// Runtime config validation error. -#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] pub enum RuntimeConfigValidationError { /// Required string field was empty or whitespace-only. #[display("{field} must not be empty")] @@ -244,8 +246,6 @@ pub enum RuntimeConfigValidationError { }, } -impl core::error::Error for RuntimeConfigValidationError {} - /// Product-scoped key-value storage. /// /// The core namespaces product keys before calling this trait. Host @@ -440,6 +440,13 @@ pub enum CoreStorageKey { /// Permission request whose authorization is being stored. request: PermissionAuthorizationRequest, }, + /// Persisted allowance-slot keys for one paired SSO session. + AllowanceKeys { + /// Stable host-derived SSO session id. + session_id: String, + }, + /// Last processed SSO pairing response statement for the pairing device. + LastProcessedPairingStatement, } /// Host-private persistence for core-owned state. @@ -605,13 +612,121 @@ pub trait ThemeHost: Send + Sync { fn subscribe_theme(&self) -> BoxStream<'static, Result>; } +/// Secret key allocated for Bulletin preimage submission. +#[derive(Clone, PartialEq, Eq)] +pub struct BulletinAllowanceKey { + secret: [u8; 64], +} + +impl BulletinAllowanceKey { + /// Build a Bulletin allowance key from raw secret bytes. + pub fn from_secret_bytes(secret: Vec) -> Result { + let secret: [u8; 64] = secret.try_into().map_err(|secret: Vec| { + BulletinAllowanceKeyError::InvalidLength { + actual: secret.len(), + } + })?; + Ok(Self { secret }) + } + + /// Raw secret bytes for bridge and storage adapters. + pub fn as_secret_bytes(&self) -> &[u8] { + &self.secret + } + + /// Consume the wrapper and return raw secret bytes. + pub fn into_secret_bytes(self) -> [u8; 64] { + self.secret + } +} + +impl core::fmt::Debug for BulletinAllowanceKey { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("BulletinAllowanceKey") + .field("secret", &"") + .finish() + } +} + +/// Invalid Bulletin allowance key material. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] +pub enum BulletinAllowanceKeyError { + /// Secret material was not a 64-byte sr25519 secret key. + #[display("bulletin allowance key must be 64 bytes, got {actual}")] + InvalidLength { + /// Actual secret byte length. + actual: usize, + }, +} + +/// Bulletin allowance signing capability exposed across the platform boundary. +/// +/// Rust owns the allowance key format and secret material. Host code only gets +/// `public_key + sign(payload)`, enough for PAPI to build and submit the +/// Bulletin transaction without reintroducing Nova key parsing in dotli. +type BulletinAllowanceSignFn = + dyn Fn(&[u8]) -> Result<[u8; 64], BulletinAllowanceSignError> + Send + Sync; + +/// Host-facing signer for Bulletin preimage submission. +#[derive(Clone)] +pub struct BulletinAllowanceSigner { + public_key: [u8; 32], + /// Rust-owned signing capability passed to host code without exposing the + /// raw allowance secret. + sign: Arc, +} + +impl BulletinAllowanceSigner { + /// Build a signer from a public key and signing function. + pub fn new( + public_key: [u8; 32], + sign: impl Fn(&[u8]) -> Result<[u8; 64], BulletinAllowanceSignError> + Send + Sync + 'static, + ) -> Self { + Self { + public_key, + sign: Arc::new(sign), + } + } + + /// Public key of the allowance account. + pub fn public_key(&self) -> [u8; 32] { + self.public_key + } + + /// Sign a SCALE transaction payload with the allowance account. + pub fn sign(&self, payload: &[u8]) -> Result<[u8; 64], BulletinAllowanceSignError> { + (self.sign)(payload) + } +} + +impl core::fmt::Debug for BulletinAllowanceSigner { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("BulletinAllowanceSigner") + .field("public_key", &self.public_key) + .field("sign", &"") + .finish() + } +} + +/// Bulletin allowance signing failed. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] +#[display("{reason}")] +pub struct BulletinAllowanceSignError { + /// Human-readable failure reason. + pub reason: String, +} + /// Host preimage backend. The core owns wire mapping and subscription /// lifecycle; the host owns the selected backend. #[async_trait] pub trait PreimageHost: Send + Sync { /// Submit the preimage and return its key. - async fn submit_preimage(&self, value: Vec) -> Result, PreimageSubmitError> { - let _ = value; + async fn submit_preimage( + &self, + value: Vec, + bulletin_allowance_signer: BulletinAllowanceSigner, + ) -> Result, PreimageSubmitError> { + let _ = (value, bulletin_allowance_signer); Err(PreimageSubmitError::Unknown { reason: "submitPreimage callback not provided by host".to_string(), }) diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 93c896b6..93ce86b5 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -18,7 +18,7 @@ unsafe_code = "forbid" truapi = { path = "../truapi" } truapi-platform = { path = "../truapi-platform" } async-trait = "0.1" -derive_more = { version = "2", features = ["display"] } +derive_more = { version = "2", features = ["display", "error"] } futures = "0.3" futures-timer = { version = "3", features = ["wasm-bindgen"] } parity-scale-codec = { version = "3", features = ["derive"] } diff --git a/rust/crates/truapi-server/src/host_logic.rs b/rust/crates/truapi-server/src/host_logic.rs index 183688fa..0ed7572f 100644 --- a/rust/crates/truapi-server/src/host_logic.rs +++ b/rust/crates/truapi-server/src/host_logic.rs @@ -4,6 +4,7 @@ //! storage, URL handler, notification center). Everything else lives here so //! iOS, Android, and web hosts share one canonical implementation. +pub mod allowance_signer; pub mod dotns; pub mod entropy; pub mod features; diff --git a/rust/crates/truapi-server/src/host_logic/allowance_signer.rs b/rust/crates/truapi-server/src/host_logic/allowance_signer.rs new file mode 100644 index 00000000..6651844a --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/allowance_signer.rs @@ -0,0 +1,93 @@ +//! Bulletin allowance signing helpers shared by platform backends. + +use schnorrkel::SecretKey; +use truapi_platform::{BulletinAllowanceKey, BulletinAllowanceSignError, BulletinAllowanceSigner}; + +use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; + +/// Build a host-facing Bulletin signer from cached allowance key material. +pub(crate) fn bulletin_allowance_signer_from_key( + key: BulletinAllowanceKey, +) -> Result { + let secret = key.into_secret_bytes(); + let public_key = public_key_from_allowance_secret(secret)?; + Ok(BulletinAllowanceSigner::new(public_key, move |payload| { + sign_with_allowance_secret(secret, payload) + .map_err(|reason| BulletinAllowanceSignError { reason }) + })) +} + +/// Derive the public key for a mobile slot-account allowance secret. +pub(crate) fn public_key_from_allowance_secret(secret: [u8; 64]) -> Result<[u8; 32], String> { + Ok(secret_key_from_allowance_secret(secret)? + .to_public() + .to_bytes()) +} + +fn sign_with_allowance_secret(secret: [u8; 64], payload: &[u8]) -> Result<[u8; 64], String> { + let secret = secret_key_from_allowance_secret(secret)?; + let public = secret.to_public(); + Ok(secret + .sign_simple(SR25519_SIGNING_CONTEXT, payload, &public) + .to_bytes()) +} + +fn secret_key_from_allowance_secret(secret: [u8; 64]) -> Result { + // Mobile allowance keys are `SlotAccountKey` values (`privateKey || nonce`) + // and must use schnorrkel's canonical `SecretKey::from_bytes` path. Older + // JS-derived keys used ed25519-expanded bytes, so keep the fallback for + // compatibility with persisted allocations. + match SecretKey::from_bytes(&secret) { + Ok(secret) => Ok(secret), + Err(canonical_error) => SecretKey::from_ed25519_bytes(&secret).map_err(|ed_error| { + format!( + "invalid bulletin allowance key: canonical={canonical_error}; ed25519={ed_error}" + ) + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use schnorrkel::{PublicKey, Signature}; + + fn slot_secret_fixture() -> [u8; 64] { + hex::decode( + "0eef5183411d40c32446bb1cbaabd70004a17af6012a577c735d054f04059208\ + 573dfc9b6ffeb1c786a16349e70f9836876a743c31c0a7a2a70727a852eec372", + ) + .unwrap() + .try_into() + .unwrap() + } + + #[test] + fn derives_mobile_slot_account_public_key() { + let public_key = public_key_from_allowance_secret(slot_secret_fixture()).unwrap(); + + assert_eq!( + hex::encode(public_key), + "10c68432943c68a6e1be650818b5e08db79e57823de9f34df7ba36d404d91e1d" + ); + } + + #[test] + fn signs_with_mobile_slot_account_secret() { + let secret = slot_secret_fixture(); + let signer = bulletin_allowance_signer_from_key( + BulletinAllowanceKey::from_secret_bytes(secret.to_vec()).unwrap(), + ) + .unwrap(); + let payload = b"hello-slot"; + let signature = signer.sign(payload).unwrap(); + let public_key = PublicKey::from_bytes(&signer.public_key()).unwrap(); + let signature = Signature::from_bytes(&signature).unwrap(); + + assert!( + public_key + .verify_simple(SR25519_SIGNING_CONTEXT, payload, &signature) + .is_ok() + ); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/session.rs b/rust/crates/truapi-server/src/host_logic/session.rs index 7eb9e46d..b19f4704 100644 --- a/rust/crates/truapi-server/src/host_logic/session.rs +++ b/rust/crates/truapi-server/src/host_logic/session.rs @@ -89,37 +89,21 @@ pub struct SsoSessionInfo { pub peer_request_channel: [u8; 32], } -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -enum PersistedSessionBlob { - #[codec(index = 3)] - V3(SessionInfo), -} - /// Encode the active-session fields the core currently understands into an -/// opaque host-global session blob. Later SSO channel state should bump -/// the enum variant instead of extending this layout silently. +/// opaque host-global session blob. pub fn encode_persisted_session(info: &SessionInfo) -> Vec { - PersistedSessionBlob::V3(info.clone()).encode() + info.encode() } /// Decode a core-owned persisted session blob. pub fn decode_persisted_session(blob: &[u8]) -> Result { - let Some(version) = blob.first() else { - return Err("invalid session blob: missing version".to_string()); - }; - if *version != 3 { - return Err(format!("unsupported session blob version {version}")); - } - let mut input = blob; - let decoded = PersistedSessionBlob::decode(&mut input) - .map_err(|err| format!("invalid session blob: {err}"))?; + let decoded = + SessionInfo::decode(&mut input).map_err(|err| format!("invalid session blob: {err}"))?; if !input.is_empty() { return Err("invalid session blob: trailing bytes".to_string()); } - match decoded { - PersistedSessionBlob::V3(info) => Ok(info), - } + Ok(decoded) } /// Holds the currently-active session and broadcasts connection-status @@ -290,25 +274,6 @@ mod tests { assert_eq!(decoded, session); } - #[test] - fn persisted_session_rejects_unknown_version() { - let mut blob = encode_persisted_session(&info(0x42)); - blob[0] = 0xff; - - let err = decode_persisted_session(&blob).unwrap_err(); - - assert_eq!(err, "unsupported session blob version 255"); - } - - #[test] - fn persisted_session_rejects_legacy_v2() { - let blob = vec![2]; - - let err = decode_persisted_session(&blob).unwrap_err(); - - assert_eq!(err, "unsupported session blob version 2"); - } - #[test] fn persisted_session_rejects_trailing_bytes() { let mut blob = encode_persisted_session(&info(0x42)); 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..5af8e3b8 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -112,8 +112,8 @@ pub struct SigningPayloadRequest { pub with_signed_transaction: OptionBool, } -impl From for SigningPayloadRequest { - fn from(value: truapi::v01::HostSignPayloadRequest) -> Self { +impl SigningPayloadRequest { + fn from_host_request(value: HostSignPayloadRequest) -> Self { let payload = value.payload; Self { product_account_id: value.account, @@ -148,8 +148,8 @@ pub struct SigningRawRequest { pub data: SigningRawPayload, } -impl From for SigningRawRequest { - fn from(value: truapi::v01::HostSignRawRequest) -> Self { +impl SigningRawRequest { + fn from_host_request(value: HostSignRawRequest) -> Self { Self { product_account_id: value.account, data: value.payload.into(), @@ -236,8 +236,9 @@ pub struct RingVrfAliasResponse { /// Request sent when a product asks the signing host to allocate SSO-backed /// resources. /// -/// Used by `ResourceAllocation::request` for capabilities such as statement -/// store allowance and auto-signing material. +/// Used by `ResourceAllocation::request` for capabilities from +/// `docs/rfcs/0010-allowance.md`, such as statement-store allowance and +/// auto-signing material. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct ResourceAllocationRequest { pub calling_product_id: String, @@ -249,7 +250,7 @@ pub struct ResourceAllocationRequest { #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SsoAllocatableResource { StatementStoreAllowance, - BulletInAllowance, + BulletinAllowance, SmartContractAllowance(u32), AutoSigning, } @@ -258,7 +259,7 @@ impl From for SsoAllocatableResource { fn from(value: AllocatableResource) -> Self { match value { AllocatableResource::StatementStoreAllowance => Self::StatementStoreAllowance, - AllocatableResource::BulletinAllowance => Self::BulletInAllowance, + AllocatableResource::BulletinAllowance => Self::BulletinAllowance, AllocatableResource::SmartContractAllowance(index) => { Self::SmartContractAllowance(index) } @@ -295,7 +296,7 @@ pub enum SsoAllocatedResource { StatementStoreAllowance { slot_account_key: Vec, }, - BulletInAllowance { + BulletinAllowance { slot_account_key: Vec, }, SmartContractAllowance, @@ -470,7 +471,7 @@ pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) RemoteMessage { message_id, data: RemoteMessageData::V1(v1::RemoteMessage::SignRequest(Box::new( - SigningRequest::Payload(Box::new(request.into())), + SigningRequest::Payload(Box::new(SigningPayloadRequest::from_host_request(request))), ))), } } @@ -480,7 +481,7 @@ pub fn sign_raw_message(message_id: String, request: HostSignRawRequest) -> Remo RemoteMessage { message_id, data: RemoteMessageData::V1(v1::RemoteMessage::SignRequest(Box::new( - SigningRequest::Raw(request.into()), + SigningRequest::Raw(SigningRawRequest::from_host_request(request)), ))), } } @@ -639,8 +640,7 @@ mod tests { use p256::SecretKey as P256SecretKey; use p256::elliptic_curve::sec1::ToEncodedPoint; use schnorrkel::{ExpansionMode, MiniSecretKey}; - use truapi::latest::HostSignPayloadData; - use truapi::v01; + use truapi::latest::{HostSignPayloadData, TxPayloadExtension}; fn account() -> ProductAccountId { ProductAccountId { @@ -714,7 +714,7 @@ mod tests { fn late_remote_message_variants_match_host_papp_order() { let legacy_tx = create_transaction_legacy_message( String::new(), - v01::LegacyAccountTxPayload { + LegacyAccountTxPayload { signer: [1; 32], genesis_hash: [2; 32], call_data: Vec::new(), @@ -766,14 +766,14 @@ mod tests { fn create_transaction_message_matches_host_papp_0_8_8_fixture() { let message = create_transaction_message( "m-product-tx".to_string(), - v01::ProductAccountTxPayload { - signer: v01::ProductAccountId { + ProductAccountTxPayload { + signer: ProductAccountId { dot_ns_identifier: "truapi-playground.dot".to_string(), derivation_index: 0, }, genesis_hash: sequential_bytes(32), call_data: vec![0, 0], - extensions: vec![v01::TxPayloadExtension { + extensions: vec![TxPayloadExtension { id: "CheckNonce".to_string(), extra: vec![1], additional_signed: vec![2, 3], @@ -792,8 +792,8 @@ mod tests { fn playground_create_transaction_message_matches_host_papp_0_8_8_fixture() { let message = create_transaction_message( "create-transaction-1".to_string(), - v01::ProductAccountTxPayload { - signer: v01::ProductAccountId { + ProductAccountTxPayload { + signer: ProductAccountId { dot_ns_identifier: "truapi-playground.dot".to_string(), derivation_index: 0, }, @@ -818,11 +818,11 @@ mod tests { fn create_transaction_legacy_message_matches_host_papp_0_8_8_fixture() { let message = create_transaction_legacy_message( "m-legacy-tx".to_string(), - v01::LegacyAccountTxPayload { + LegacyAccountTxPayload { signer: sequential_bytes(0), genesis_hash: sequential_bytes(32), call_data: vec![0, 0], - extensions: vec![v01::TxPayloadExtension { + extensions: vec![TxPayloadExtension { id: "CheckNonce".to_string(), extra: vec![1], additional_signed: vec![2, 3], @@ -883,11 +883,11 @@ mod tests { with_signed_transaction: Some(true), }, }; - let true_encoded = SigningPayloadRequest::from(request.clone()).encode(); + let true_encoded = SigningPayloadRequest::from_host_request(request.clone()).encode(); request.payload.with_signed_transaction = Some(false); - let false_encoded = SigningPayloadRequest::from(request.clone()).encode(); + let false_encoded = SigningPayloadRequest::from_host_request(request.clone()).encode(); request.payload.with_signed_transaction = None; - let none_encoded = SigningPayloadRequest::from(request).encode(); + let none_encoded = SigningPayloadRequest::from_host_request(request).encode(); assert_eq!(true_encoded.last(), Some(&1)); assert_eq!(false_encoded.last(), Some(&2)); @@ -917,7 +917,7 @@ mod tests { request.resources, vec![ SsoAllocatableResource::StatementStoreAllowance, - SsoAllocatableResource::BulletInAllowance, + SsoAllocatableResource::BulletinAllowance, SsoAllocatableResource::SmartContractAllowance(9), SsoAllocatableResource::AutoSigning, ] diff --git a/rust/crates/truapi-server/src/host_rpc_client.rs b/rust/crates/truapi-server/src/host_rpc_client.rs index 0d46e586..b487107f 100644 --- a/rust/crates/truapi-server/src/host_rpc_client.rs +++ b/rust/crates/truapi-server/src/host_rpc_client.rs @@ -5,7 +5,6 @@ //! [`subxt_rpcs::RpcClientT`]: request correlation, subscription routing, and //! best-effort unsubscribe on subscription drop. -use core::fmt; use core::mem; use core::pin::Pin; use core::task::{Context, Poll}; @@ -57,16 +56,9 @@ struct SubscriptionSink { tx: mpsc::UnboundedSender, RpcError>>, } -#[derive(Debug)] -struct HostRpcClientError(String); - -impl fmt::Display for HostRpcClientError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl std::error::Error for HostRpcClientError {} +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[display("{}", _0)] +struct HostRpcClientError(#[error(not(source))] String); #[derive(Serialize)] struct JsonRpcRequest<'a> { diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index a8e32903..d93cdc6f 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -7,6 +7,7 @@ //! permission cache layer). Methods with no platform backing return //! `CallError::unavailable()`. +mod allowances; pub(crate) mod auth_state; mod authority; mod identity; @@ -23,6 +24,7 @@ use core::time::Duration; use std::sync::Arc; use crate::chain_runtime::RuntimeFailure; +use crate::host_logic::allowance_signer::bulletin_allowance_signer_from_key; use crate::host_logic::dotns::{NavigateDecision, parse_navigate}; use crate::host_logic::features::feature_supported; use crate::host_logic::permissions::PermissionsService; @@ -1747,17 +1749,17 @@ impl Preimage for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "preimage.submit"))] async fn submit( &self, - _cx: &CallContext, + cx: &CallContext, request: RemotePreimageSubmitRequest, ) -> Result> { let RemotePreimageSubmitRequest::V1(value) = request; - if self.authority.current_session().is_none() { + let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(RemotePreimageSubmitError::V1( v01::PreimageSubmitError::Unknown { reason: "No active session".to_string(), }, ))); - } + }; self.require_remote_permission( v01::RemotePermission::PreimageSubmit, RemotePreimageSubmitError::V1(v01::PreimageSubmitError::Unknown { @@ -1786,9 +1788,28 @@ impl Preimage for ProductRuntimeHost { }, ))); } + let cx = remote_authority_context(cx); + let allowance = remote_authority_call( + &cx, + self.authority + .bulletin_allowance_key(&cx, &session, self.product_id()), + ) + .await + .map_err(|err| { + CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { + reason: err.reason(), + }, + )) + })?; + let signer = bulletin_allowance_signer_from_key(allowance).map_err(|reason| { + CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { reason }, + )) + })?; self.services .platform - .submit_preimage(value) + .submit_preimage(value, signer) .await .map(RemotePreimageSubmitResponse::V1) .map_err(|err| CallError::Domain(RemotePreimageSubmitError::V1(err))) @@ -1868,7 +1889,10 @@ impl Notifications for ProductRuntimeHost { #[cfg(test)] mod tests { use super::*; - use crate::host_logic::sso::messages::{RemoteMessageData, v1}; + use crate::host_logic::sso::messages::{ + OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, ResourceAllocationResponse, + SsoAllocatableResource, SsoAllocatedResource, SsoAllocationOutcome, v1, + }; use crate::test_support::*; use std::sync::Mutex; use std::sync::atomic::Ordering; @@ -2724,14 +2748,125 @@ mod tests { } } + fn bulletin_slot_account_key_fixture() -> Vec { + hex::decode( + "0eef5183411d40c32446bb1cbaabd70004a17af6012a577c735d054f04059208\ + 573dfc9b6ffeb1c786a16349e70f9836876a743c31c0a7a2a70727a852eec372", + ) + .unwrap() + } + + fn expected_bulletin_slot_account_public_key() -> Vec { + hex::decode("10c68432943c68a6e1be650818b5e08db79e57823de9f34df7ba36d404d91e1d").unwrap() + } + #[test] fn preimage_submit_confirms_and_delegates_to_platform() { - let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); - host.test_session_state().set_session(session_info()); + let session = sso_session_info(); + let slot_account_key = bulletin_slot_account_key_fixture(); + let preimage_submit_allowance_public_keys = Arc::new(Mutex::new(Vec::new())); + let preimage_submit_signatures = Arc::new(Mutex::new(Vec::new())); + let platform = Arc::new(StubPlatform { + preimage_submit_allowance_public_keys: preimage_submit_allowance_public_keys.clone(), + preimage_submit_signatures: preimage_submit_signatures.clone(), + sso_response_script: Some(sso_success_response_script( + &session, + RemoteMessage { + message_id: "wallet-preimage-allowance".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse( + ResourceAllocationResponse { + responding_to: "preimage-submit".to_string(), + payload: Ok(vec![SsoAllocationOutcome::Allocated( + SsoAllocatedResource::BulletinAllowance { + slot_account_key: slot_account_key.clone(), + }, + )]), + }, + )), + }, + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session.clone()); let cx = CallContext::new(); let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); let response = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap(); assert_eq!(response, RemotePreimageSubmitResponse::V1(vec![1, 2, 3])); + assert_eq!( + preimage_submit_allowance_public_keys + .lock() + .expect("preimage allowance public key list mutex poisoned") + .as_slice(), + &[expected_bulletin_slot_account_public_key()] + ); + assert_eq!( + preimage_submit_signatures + .lock() + .expect("preimage allowance signature list mutex poisoned")[0] + .len(), + 64 + ); + let message = submitted_remote_message(&platform, &session); + match message.data { + RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationRequest(request)) => { + assert_eq!( + request.resources, + vec![SsoAllocatableResource::BulletinAllowance] + ); + assert_eq!(request.on_existing, OnExistingAllowancePolicy::Ignore); + } + other => panic!("expected bulletin allowance request, got {other:?}"), + } + } + + #[test] + fn preimage_submit_uses_persisted_bulletin_allowance_key() { + let session = sso_session_info(); + let slot_account_key = bulletin_slot_account_key_fixture(); + let preimage_submit_allowance_public_keys = Arc::new(Mutex::new(Vec::new())); + let preimage_submit_signatures = Arc::new(Mutex::new(Vec::new())); + let platform = Arc::new(StubPlatform { + preimage_submit_allowance_public_keys: preimage_submit_allowance_public_keys.clone(), + preimage_submit_signatures: preimage_submit_signatures.clone(), + ..Default::default() + }); + futures::executor::block_on(allowances::write_allowance_key( + &*platform, + &session, + "unknown.dot", + allowances::AllowanceResource::Bulletin, + slot_account_key.clone(), + )) + .unwrap(); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session); + let cx = CallContext::new(); + let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); + let response = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap(); + assert_eq!(response, RemotePreimageSubmitResponse::V1(vec![1, 2, 3])); + assert_eq!( + preimage_submit_allowance_public_keys + .lock() + .expect("preimage allowance public key list mutex poisoned") + .as_slice(), + &[expected_bulletin_slot_account_public_key()] + ); + assert_eq!( + preimage_submit_signatures + .lock() + .expect("preimage allowance signature list mutex poisoned")[0] + .len(), + 64 + ); + assert!( + platform + .sent_rpc + .lock() + .expect("rpc list mutex poisoned") + .is_empty(), + "persisted allowance should not send an SSO resource-allocation request" + ); } #[test] @@ -3911,14 +4046,14 @@ mod tests { 1 ); assert!( - !platform + platform .local_storage .lock() .expect("local storage mutex poisoned") .contains_key(&core_storage_test_key( CoreStorageKey::PairingDeviceIdentity )), - "logout must rotate the pairing device identity so stale statement-store responses cannot be replayed on the next login" + "logout keeps the pairing device identity; stale pairing responses are skipped by the processed-statement marker" ); assert_eq!( futures::executor::block_on(statuses.next()).unwrap(), diff --git a/rust/crates/truapi-server/src/runtime/allowances.rs b/rust/crates/truapi-server/src/runtime/allowances.rs new file mode 100644 index 00000000..aa5e2ad1 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/allowances.rs @@ -0,0 +1,296 @@ +//! Persistent allowance-key repository for pairing-host SSO sessions. +//! +//! Implements the host-side allowance cache described in +//! `docs/rfcs/0010-allowance.md`. +//! +//! This mirrors host-papp's allowance repository shape: keys are grouped by +//! SSO session and then indexed by `(product_id, resource)`. The runtime keeps +//! a short-lived memory cache in `PairingHost`; this module owns the durable +//! CoreStorage encoding. + +use parity_scale_codec::{Decode, Encode}; +use truapi::latest::GenericError; +use truapi_platform::{CoreStorage, CoreStorageKey}; + +use super::authority::AuthorityError; +use super::sso_remote::SsoSessionKey; +use crate::host_logic::session::{SessionInfo, SsoSessionInfo}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Encode, Decode)] +pub(super) enum AllowanceResource { + Bulletin, + StatementStore, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(super) struct AllowanceCacheKey { + session: SsoSessionKey, + product_id: String, + resource: AllowanceResource, +} + +impl AllowanceCacheKey { + pub(super) fn new( + session: &SessionInfo, + product_id: &str, + resource: AllowanceResource, + ) -> Result { + Ok(Self { + session: sso_cache_key(session)?, + product_id: product_id.to_string(), + resource, + }) + } + + pub(super) fn is_for_session(&self, session: SsoSessionKey) -> bool { + self.session == session + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +struct StoredAllowanceEntry { + product_id: String, + resource: AllowanceResource, + slot_account_key: Vec, +} + +pub(super) async fn read_allowance_key( + storage: &(impl CoreStorage + ?Sized), + session: &SessionInfo, + product_id: &str, + resource: AllowanceResource, +) -> Result>, AuthorityError> { + let entries = read_entries(storage, session).await?; + Ok(entries + .into_iter() + .find(|entry| entry.product_id == product_id && entry.resource == resource) + .map(|entry| entry.slot_account_key)) +} + +pub(super) async fn write_allowance_key( + storage: &(impl CoreStorage + ?Sized), + session: &SessionInfo, + product_id: &str, + resource: AllowanceResource, + slot_account_key: Vec, +) -> Result<(), AuthorityError> { + let mut entries = read_entries(storage, session).await?; + entries.retain(|entry| !(entry.product_id == product_id && entry.resource == resource)); + entries.push(StoredAllowanceEntry { + product_id: product_id.to_string(), + resource, + slot_account_key, + }); + storage + .write_core_storage(storage_key(session)?, encode_entries(entries)) + .await + .map_err(storage_error) +} + +pub(super) async fn clear_session_allowance_keys( + storage: &(impl CoreStorage + ?Sized), + session: &SessionInfo, +) -> Result<(), AuthorityError> { + storage + .clear_core_storage(storage_key(session)?) + .await + .map_err(storage_error) +} + +async fn read_entries( + storage: &(impl CoreStorage + ?Sized), + session: &SessionInfo, +) -> Result, AuthorityError> { + let Some(blob) = storage + .read_core_storage(storage_key(session)?) + .await + .map_err(storage_error)? + else { + return Ok(Vec::new()); + }; + decode_entries(&blob) +} + +fn encode_entries(entries: Vec) -> Vec { + entries.encode() +} + +fn decode_entries(blob: &[u8]) -> Result, AuthorityError> { + let mut input = blob; + let entries = + Vec::::decode(&mut input).map_err(|err| AuthorityError::Unknown { + reason: format!("invalid persisted allowance keys: {err}"), + })?; + if !input.is_empty() { + return Err(AuthorityError::Unknown { + reason: "invalid persisted allowance keys: trailing bytes".to_string(), + }); + } + Ok(entries) +} + +fn storage_key(session: &SessionInfo) -> Result { + Ok(CoreStorageKey::AllowanceKeys { + session_id: session_storage_id(session.sso.as_ref().ok_or(AuthorityError::Disconnected)?), + }) +} + +fn sso_cache_key(session: &SessionInfo) -> Result { + let sso = session.sso.as_ref().ok_or(AuthorityError::Disconnected)?; + Ok(SsoSessionKey::from_session(sso)) +} + +fn session_storage_id(session: &SsoSessionInfo) -> String { + let mut bytes = Vec::with_capacity(64); + bytes.extend_from_slice(&session.session_id_own); + bytes.extend_from_slice(&session.session_id_peer); + hex::encode(bytes) +} + +fn storage_error(err: GenericError) -> AuthorityError { + AuthorityError::Unknown { + reason: format!("allowance storage failed: {}", err.reason), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + use crate::test_support::sso_session_info; + + #[derive(Default)] + struct MemStorage { + inner: Mutex, Vec>>, + } + + #[truapi_platform::async_trait] + impl CoreStorage for MemStorage { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, GenericError> { + Ok(self + .inner + .lock() + .expect("storage mutex poisoned") + .get(&key.encode()) + .cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), GenericError> { + self.inner + .lock() + .expect("storage mutex poisoned") + .insert(key.encode(), value); + Ok(()) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), GenericError> { + self.inner + .lock() + .expect("storage mutex poisoned") + .remove(&key.encode()); + Ok(()) + } + } + + #[test] + fn stores_allowance_keys_by_product_and_resource() { + let storage = MemStorage::default(); + let session = sso_session_info(); + + futures::executor::block_on(async { + write_allowance_key( + &storage, + &session, + "dotli.localhost", + AllowanceResource::Bulletin, + vec![1; 64], + ) + .await + .unwrap(); + write_allowance_key( + &storage, + &session, + "dotli.localhost", + AllowanceResource::StatementStore, + vec![2; 64], + ) + .await + .unwrap(); + + assert_eq!( + read_allowance_key( + &storage, + &session, + "dotli.localhost", + AllowanceResource::Bulletin + ) + .await + .unwrap(), + Some(vec![1; 64]) + ); + assert_eq!( + read_allowance_key( + &storage, + &session, + "dotli.localhost", + AllowanceResource::StatementStore + ) + .await + .unwrap(), + Some(vec![2; 64]) + ); + assert_eq!( + read_allowance_key( + &storage, + &session, + "other.localhost", + AllowanceResource::Bulletin + ) + .await + .unwrap(), + None + ); + }); + } + + #[test] + fn clears_session_allowance_keys() { + let storage = MemStorage::default(); + let session = sso_session_info(); + + futures::executor::block_on(async { + write_allowance_key( + &storage, + &session, + "dotli.localhost", + AllowanceResource::Bulletin, + vec![1; 64], + ) + .await + .unwrap(); + clear_session_allowance_keys(&storage, &session) + .await + .unwrap(); + assert_eq!( + read_allowance_key( + &storage, + &session, + "dotli.localhost", + AllowanceResource::Bulletin + ) + .await + .unwrap(), + None + ); + }); + } +} diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index 1ee987bf..7b5ce02d 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -17,7 +17,9 @@ use truapi::latest::{ }; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, CancellationReason}; -use truapi_platform::ProductContext; +use truapi_platform::{BulletinAllowanceKeyError, ProductContext}; + +pub(crate) use truapi_platform::BulletinAllowanceKey; use crate::host_logic::session::{SessionInfo, SessionState}; use crate::host_logic::statement_store::statement_public_key_from_secret; @@ -79,6 +81,14 @@ impl AuthorityError { } } +impl From for AuthorityError { + fn from(err: BulletinAllowanceKeyError) -> Self { + AuthorityError::Unavailable { + reason: err.to_string(), + } + } +} + /// Cancellation cause for an account-authority call. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct AuthorityCancelError { @@ -264,6 +274,14 @@ pub(crate) trait ProductAuthority: Send + Sync { product_id: String, ) -> Result; + /// Return Bulletin allowance key material for the calling product. + async fn bulletin_allowance_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result; + /// Sign exact statement-store proof bytes with a product-derived account. async fn sign_statement_store_product_payload( &self, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index b21b9b8e..4969a42c 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -12,11 +12,12 @@ use std::sync::{Arc, Mutex, Weak}; use futures::channel::oneshot; use sso_channel::SsoDisconnectMonitor; +use super::allowances::{self, AllowanceCacheKey, AllowanceResource}; use super::auth_state::AuthStateMachine; use super::authority::{ - AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, - SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, - authority_session, require_current_session, + AuthorityError, AuthoritySession, BulletinAllowanceKey, CreateTransactionAuthorityRequest, + ProductAuthority, SignPayloadAuthorityRequest, SignRawAuthorityRequest, + StatementStoreAllowanceKey, authority_session, require_current_session, }; use super::connected_session_ui_info; use super::identity::resolve_session_identity_with_chain; @@ -86,12 +87,6 @@ struct LoginInFlight { waiters: Vec>>, } -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -struct StatementStoreAllowanceCacheKey { - session: SsoSessionKey, - product_id: String, -} - struct LoginInFlightOwner<'a> { host: &'a PairingHost, active: bool, @@ -133,8 +128,8 @@ pub(crate) struct PairingHost { disconnect_monitor: Mutex>, login_in_flight: Mutex>, login_generation: Mutex, - statement_store_allowances: - Mutex>, + statement_store_allowances: Mutex>, + bulletin_allowances: Mutex>, /// Self-reference captured by the spawned disconnect-monitor task. weak_self: Weak, pub(super) spawner: Spawner, @@ -157,6 +152,7 @@ impl PairingHost { login_in_flight: Mutex::new(None), login_generation: Mutex::new(0), statement_store_allowances: Mutex::new(HashMap::new()), + bulletin_allowances: Mutex::new(HashMap::new()), weak_self: weak_self.clone(), spawner: services.spawner.clone(), }) @@ -235,16 +231,16 @@ impl PairingHost { pairing_host.set_connected_session(resolved); } Err(_) => { - pairing_host.clear_disconnected_session(true, false).await; + pairing_host.clear_disconnected_session(true).await; } } } Ok(None) => { cleared_after_read_error = false; - pairing_host.clear_disconnected_session(false, false).await; + pairing_host.clear_disconnected_session(false).await; } Err(_) => { - pairing_host.clear_disconnected_session(false, false).await; + pairing_host.clear_disconnected_session(false).await; if !cleared_after_read_error { cleared_after_read_error = true; let _ = pairing_host @@ -344,7 +340,7 @@ impl PairingHost { async fn disconnect(&self) { self.cancel_login(); let session = self.session_state.current(); - self.clear_disconnected_session(true, true).await; + self.clear_disconnected_session(true).await; if let Some(session) = session.as_ref() { let _ = self.submit_disconnected_message(session).await; } @@ -412,11 +408,7 @@ impl PairingHost { } #[instrument(skip_all, fields(runtime.method = "session_store.clear_disconnected"))] - async fn clear_disconnected_session( - &self, - clear_auth_session: bool, - clear_pairing_identity: bool, - ) { + async fn clear_disconnected_session(&self, clear_auth_session: bool) { let previous = self.session_state.current(); self.session_state.clear_session(); self.stop_session_channel(previous.as_ref()); @@ -426,13 +418,10 @@ impl PairingHost { .clear_core_storage(CoreStorageKey::AuthSession) .await; } - self.auth_state.store_disconnected(); - if clear_pairing_identity { - let _ = self - .platform - .clear_core_storage(CoreStorageKey::PairingDeviceIdentity) - .await; + if let Some(session) = previous.as_ref() { + let _ = allowances::clear_session_allowance_keys(&*self.platform, session).await; } + self.auth_state.store_disconnected(); } fn set_connected_session(&self, session: SessionInfo) { @@ -458,7 +447,7 @@ impl PairingHost { return; } - self.clear_disconnected_session(true, true).await; + self.clear_disconnected_session(true).await; } fn current_sso_session_matches(&self, key: SsoSessionKey) -> bool { @@ -472,44 +461,132 @@ impl PairingHost { require_current_session(&self.session_state, session) } - fn statement_store_allowance_cache_key( + pub(super) async fn cache_statement_store_allowance_key( + &self, session: &SessionInfo, product_id: &str, - ) -> Result { - let sso = session.sso.as_ref().ok_or(AuthorityError::Disconnected)?; - Ok(StatementStoreAllowanceCacheKey { - session: SsoSessionKey::from_session(sso), - product_id: product_id.to_string(), - }) + slot_account_key: Vec, + ) -> Result { + let allowance = StatementStoreAllowanceKey::from_secret_bytes(slot_account_key)?; + allowances::write_allowance_key( + &*self.platform, + session, + product_id, + AllowanceResource::StatementStore, + allowance.secret.to_vec(), + ) + .await?; + self.remember_statement_store_allowance_key(session, product_id, allowance.clone())?; + Ok(allowance) } - pub(super) fn cache_statement_store_allowance_key( + fn remember_statement_store_allowance_key( &self, session: &SessionInfo, product_id: &str, - slot_account_key: Vec, - ) -> Result { - let cache_key = Self::statement_store_allowance_cache_key(session, product_id)?; - let allowance = StatementStoreAllowanceKey::from_secret_bytes(slot_account_key)?; + allowance: StatementStoreAllowanceKey, + ) -> Result<(), AuthorityError> { + let cache_key = + AllowanceCacheKey::new(session, product_id, AllowanceResource::StatementStore)?; self.statement_store_allowances .lock() .expect("statement-store allowance cache mutex poisoned") - .insert(cache_key, allowance.clone()); - Ok(allowance) + .insert(cache_key, allowance); + Ok(()) } - pub(super) fn cached_statement_store_allowance_key( + pub(super) async fn cached_statement_store_allowance_key( &self, session: &SessionInfo, product_id: &str, ) -> Result, AuthorityError> { - let cache_key = Self::statement_store_allowance_cache_key(session, product_id)?; - Ok(self + let cache_key = + AllowanceCacheKey::new(session, product_id, AllowanceResource::StatementStore)?; + if let Some(allowance) = self .statement_store_allowances .lock() .expect("statement-store allowance cache mutex poisoned") .get(&cache_key) - .cloned()) + .cloned() + { + return Ok(Some(allowance)); + } + let Some(secret) = allowances::read_allowance_key( + &*self.platform, + session, + product_id, + AllowanceResource::StatementStore, + ) + .await? + else { + return Ok(None); + }; + let allowance = StatementStoreAllowanceKey::from_secret_bytes(secret)?; + self.remember_statement_store_allowance_key(session, product_id, allowance.clone())?; + Ok(Some(allowance)) + } + + pub(super) async fn cache_bulletin_allowance_key( + &self, + session: &SessionInfo, + product_id: &str, + slot_account_key: Vec, + ) -> Result { + let allowance = BulletinAllowanceKey::from_secret_bytes(slot_account_key)?; + allowances::write_allowance_key( + &*self.platform, + session, + product_id, + AllowanceResource::Bulletin, + allowance.as_secret_bytes().to_vec(), + ) + .await?; + self.remember_bulletin_allowance_key(session, product_id, allowance.clone())?; + Ok(allowance) + } + + fn remember_bulletin_allowance_key( + &self, + session: &SessionInfo, + product_id: &str, + allowance: BulletinAllowanceKey, + ) -> Result<(), AuthorityError> { + let cache_key = AllowanceCacheKey::new(session, product_id, AllowanceResource::Bulletin)?; + self.bulletin_allowances + .lock() + .expect("bulletin allowance cache mutex poisoned") + .insert(cache_key, allowance); + Ok(()) + } + + pub(super) async fn cached_bulletin_allowance_key( + &self, + session: &SessionInfo, + product_id: &str, + ) -> Result, AuthorityError> { + let cache_key = AllowanceCacheKey::new(session, product_id, AllowanceResource::Bulletin)?; + if let Some(allowance) = self + .bulletin_allowances + .lock() + .expect("bulletin allowance cache mutex poisoned") + .get(&cache_key) + .cloned() + { + return Ok(Some(allowance)); + } + let Some(secret) = allowances::read_allowance_key( + &*self.platform, + session, + product_id, + AllowanceResource::Bulletin, + ) + .await? + else { + return Ok(None); + }; + let allowance = BulletinAllowanceKey::from_secret_bytes(secret)?; + self.remember_bulletin_allowance_key(session, product_id, allowance.clone())?; + Ok(Some(allowance)) } pub(super) fn clear_statement_store_allowance_keys(&self, session: Option<&SessionInfo>) { @@ -525,7 +602,23 @@ impl PairingHost { return; }; let session_key = SsoSessionKey::from_session(sso); - allowances.retain(|key, _| key.session != session_key); + allowances.retain(|key, _| !key.is_for_session(session_key)); + } + + pub(super) fn clear_bulletin_allowance_keys(&self, session: Option<&SessionInfo>) { + let mut allowances = self + .bulletin_allowances + .lock() + .expect("bulletin allowance cache mutex poisoned"); + let Some(session) = session else { + allowances.clear(); + return; + }; + let Some(sso) = session.sso.as_ref() else { + return; + }; + let session_key = SsoSessionKey::from_session(sso); + allowances.retain(|key, _| !key.is_for_session(session_key)); } async fn sign_payload( @@ -593,6 +686,17 @@ impl PairingHost { .await } + async fn bulletin_allowance_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_bulletin_allowance_key(cx, &session, product_id) + .await + } + async fn sign_statement_store_product_payload( &self, _cx: &CallContext, @@ -722,6 +826,15 @@ impl ProductAuthority for PairingHost { PairingHost::statement_store_allowance_key(self, cx, session, product_id).await } + async fn bulletin_allowance_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result { + PairingHost::bulletin_allowance_key(self, cx, session, product_id).await + } + async fn sign_statement_store_product_payload( &self, cx: &CallContext, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index 12e19c49..77668cba 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -1,7 +1,7 @@ //! SSO statement-store channel to the paired remote signing host. use super::super::authority::{ - AuthorityCancelError, AuthorityError, CreateTransactionAuthorityRequest, + AuthorityCancelError, AuthorityError, BulletinAllowanceKey, CreateTransactionAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, }; use super::super::sso_remote::{ @@ -139,6 +139,7 @@ impl PairingHost { .notify(sso, SSO_LOCAL_DISCONNECT_REASON); } self.clear_statement_store_allowance_keys(session); + self.clear_bulletin_allowance_keys(session); self.stop_disconnect_monitor(); } @@ -400,7 +401,8 @@ impl PairingHost { }); }; let outcomes = response.payload.map_err(remote_authority_error)?; - self.cache_statement_store_allowance_outcomes(session, &product_id, &outcomes)?; + self.cache_allowance_outcomes(session, &product_id, &outcomes) + .await?; Ok(latest::HostRequestResourceAllocationResponse { outcomes: outcomes.into_iter().map(Into::into).collect(), }) @@ -412,7 +414,10 @@ impl PairingHost { session: &SessionInfo, product_id: String, ) -> Result { - if let Some(cached) = self.cached_statement_store_allowance_key(session, &product_id)? { + if let Some(cached) = self + .cached_statement_store_allowance_key(session, &product_id) + .await? + { return Ok(cached); } @@ -442,7 +447,10 @@ impl PairingHost { match outcome { SsoAllocationOutcome::Allocated(SsoAllocatedResource::StatementStoreAllowance { slot_account_key, - }) => self.cache_statement_store_allowance_key(session, &product_id, slot_account_key), + }) => { + self.cache_statement_store_allowance_key(session, &product_id, slot_account_key) + .await + } SsoAllocationOutcome::Allocated(other) => Err(AuthorityError::Unknown { reason: format!( "Unexpected statement-store allowance response resource: {other:?}" @@ -455,22 +463,87 @@ impl PairingHost { } } - fn cache_statement_store_allowance_outcomes( + pub(super) async fn remote_bulletin_allowance_key( + &self, + cx: &CallContext, + session: &SessionInfo, + product_id: String, + ) -> Result { + if let Some(cached) = self + .cached_bulletin_allowance_key(session, &product_id) + .await? + { + return Ok(cached); + } + + let message_id = sso_message_id(); + let message = resource_allocation_message( + message_id, + product_id.clone(), + vec![latest::AllocatableResource::BulletinAllowance], + OnExistingAllowancePolicy::Ignore, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::ResourceAllocation, message) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::ResourceAllocation(response) = response else { + return Err(AuthorityError::Unknown { + reason: "Unexpected SSO response for bulletin allowance request".to_string(), + }); + }; + let mut outcomes = response + .payload + .map_err(remote_authority_error)? + .into_iter(); + let outcome = outcomes.next().ok_or_else(|| AuthorityError::Unknown { + reason: "Empty bulletin allowance response".to_string(), + })?; + match outcome { + SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { + slot_account_key, + }) => { + self.cache_bulletin_allowance_key(session, &product_id, slot_account_key) + .await + } + SsoAllocationOutcome::Allocated(other) => Err(AuthorityError::Unknown { + reason: format!("Unexpected bulletin allowance response resource: {other:?}"), + }), + SsoAllocationOutcome::Rejected => Err(AuthorityError::Rejected), + SsoAllocationOutcome::NotAvailable => Err(AuthorityError::Unavailable { + reason: "bulletin allowance is not available".to_string(), + }), + } + } + + async fn cache_allowance_outcomes( &self, session: &SessionInfo, product_id: &str, outcomes: &[SsoAllocationOutcome], ) -> Result<(), AuthorityError> { for outcome in outcomes { - if let SsoAllocationOutcome::Allocated( - SsoAllocatedResource::StatementStoreAllowance { slot_account_key }, - ) = outcome - { - self.cache_statement_store_allowance_key( - session, - product_id, - slot_account_key.clone(), - )?; + if let SsoAllocationOutcome::Allocated(resource) = outcome { + match resource { + SsoAllocatedResource::StatementStoreAllowance { slot_account_key } => { + self.cache_statement_store_allowance_key( + session, + product_id, + slot_account_key.clone(), + ) + .await?; + } + SsoAllocatedResource::BulletinAllowance { slot_account_key } => { + self.cache_bulletin_allowance_key( + session, + product_id, + slot_account_key.clone(), + ) + .await?; + } + SsoAllocatedResource::SmartContractAllowance + | SsoAllocatedResource::AutoSigning { .. } => {} + } } } Ok(()) diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 79bc5e58..4ec98c67 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -13,9 +13,9 @@ use std::sync::{Arc, Mutex}; pub(crate) use local_activation::LocalActivation; use super::authority::{ - AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, - SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, - authority_session, require_current_session, + AuthorityError, AuthoritySession, BulletinAllowanceKey, CreateTransactionAuthorityRequest, + ProductAuthority, SignPayloadAuthorityRequest, SignRawAuthorityRequest, + StatementStoreAllowanceKey, authority_session, require_current_session, }; use super::connected_session_ui_info; use crate::host_logic::entropy::derive_product_entropy; @@ -213,6 +213,18 @@ impl ProductAuthority for SigningHost { }) } + async fn bulletin_allowance_key( + &self, + _cx: &CallContext, + session: &AuthoritySession, + _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(), + }) + } + async fn sign_statement_store_product_payload( &self, _cx: &CallContext, diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index 8b020a75..1e833585 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -97,9 +97,25 @@ impl<'a> SsoPairingFlow<'a> { pub(super) async fn request_session( &self, ) -> Result> { - let pairing_identity = read_or_create_pairing_device_identity(self.host.platform.as_ref()) - .await - .map_err(|reason| self.fail_before_pairing(reason))?; + let (mut pairing_identity, reused_identity) = + read_or_create_pairing_device_identity(self.host.platform.as_ref()) + .await + .map_err(|reason| self.fail_before_pairing(reason))?; + // Match legacy host-papp: keep the pairing device identity stable so + // the wallet can reuse the registered host statement-store slot, but + // also remember the last handled handshake statement. Statement-store + // subscriptions replay retained topic data, so after a reload/logout a + // stale Success must be skipped before the user re-authenticates. + let last_processed_statement = + read_last_processed_pairing_statement(self.host.platform.as_ref()) + .await + .map_err(|reason| self.fail_before_pairing(reason))?; + if reused_identity && last_processed_statement.is_none() { + debug!("regenerating unmarked pairing device identity"); + pairing_identity = create_fresh_pairing_device_identity(self.host.platform.as_ref()) + .await + .map_err(|reason| self.fail_before_pairing(reason))?; + } let bootstrap = create_pairing_bootstrap_from_identity(&self.host.host_config, pairing_identity) .map_err(|err| self.fail_before_pairing(err.to_string()))?; @@ -122,9 +138,11 @@ impl<'a> SsoPairingFlow<'a> { active: true, }; - match self.run_pairing_flow(&bootstrap, cancel_rx).await { + match self + .run_pairing_flow(&bootstrap, cancel_rx, last_processed_statement) + .await + { Ok(outcome @ SsoPairingOutcome::Cancelled) => { - clear_pairing_device_identity(self.host.platform.as_ref()).await; reset_guard.disarm(); Ok(outcome) } @@ -134,7 +152,6 @@ impl<'a> SsoPairingFlow<'a> { } Err(reason) => { self.host.auth_state.login_failed(reason.clone()); - clear_pairing_device_identity(self.host.platform.as_ref()).await; Err(CallError::HostFailure { reason }) } } @@ -158,6 +175,7 @@ impl<'a> SsoPairingFlow<'a> { &self, bootstrap: &PairingBootstrap, cancel_rx: oneshot::Receiver<()>, + last_processed_statement: Option>, ) -> Result { let mut cancel = cancel_rx.fuse(); let statement_store = self.host.statement_store.clone(); @@ -184,6 +202,7 @@ impl<'a> SsoPairingFlow<'a> { live_subscription, bootstrap.topic, bootstrap.encryption_secret_key, + last_processed_statement, self.host.spawner.clone(), ) .fuse(); @@ -193,6 +212,8 @@ impl<'a> SsoPairingFlow<'a> { _ = cancel => return Ok(SsoPairingOutcome::Cancelled), response_result = pairing_response => response_result?, }; + write_last_processed_pairing_statement(self.host.platform.as_ref(), &response.statement) + .await; let sso = establish_sso_session_info( bootstrap, response.peer_statement_account_id, @@ -261,30 +282,48 @@ async fn create_fresh_pairing_device_identity( #[instrument(skip_all, fields(runtime.method = "sso.pairing_device.read_or_create"))] async fn read_or_create_pairing_device_identity( storage: &(impl CoreStorage + ?Sized), -) -> Result { +) -> Result<(PairingDeviceIdentity, bool), String> { let stored = storage .read_core_storage(CoreStorageKey::PairingDeviceIdentity) .await .map_err(|err| format!("pairing device identity read failed: {err:?}"))?; if let Some(stored) = stored { match PairingDeviceIdentity::decode(&mut stored.as_slice()) { - Ok(identity) => return Ok(identity), + Ok(identity) => return Ok((identity, true)), Err(err) => { debug!("discarding invalid stored pairing device identity: {err}"); } } } - create_fresh_pairing_device_identity(storage).await + create_fresh_pairing_device_identity(storage) + .await + .map(|identity| (identity, false)) +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing.last_processed.read"))] +async fn read_last_processed_pairing_statement( + storage: &(impl CoreStorage + ?Sized), +) -> Result>, String> { + storage + .read_core_storage(CoreStorageKey::LastProcessedPairingStatement) + .await + .map_err(|err| format!("last processed pairing statement read failed: {err:?}")) } -#[instrument(skip_all, fields(runtime.method = "sso.pairing_device.clear"))] -async fn clear_pairing_device_identity(storage: &(impl CoreStorage + ?Sized)) { +#[instrument(skip_all, fields(runtime.method = "sso.pairing.last_processed.write"))] +async fn write_last_processed_pairing_statement( + storage: &(impl CoreStorage + ?Sized), + statement: &[u8], +) { if let Err(err) = storage - .clear_core_storage(CoreStorageKey::PairingDeviceIdentity) + .write_core_storage( + CoreStorageKey::LastProcessedPairingStatement, + statement.to_vec(), + ) .await { - debug!("pairing device identity clear failed: {err:?}"); + debug!("last processed pairing statement write failed: {err:?}"); } } @@ -299,6 +338,7 @@ async fn clear_auth_session(storage: &(impl CoreStorage + ?Sized)) { } struct PairingSuccess { + statement: Vec, peer_statement_account_id: [u8; 32], success: v2::Success, } @@ -323,6 +363,7 @@ impl PairingSuccess { v2::EncryptedResponse::Pending(_) => Ok(None), v2::EncryptedResponse::Failed(reason) => Err(reason), v2::EncryptedResponse::Success(success) => Ok(Some(Self { + statement: statement.to_vec(), peer_statement_account_id: verified.signer, success: *success, })), @@ -336,6 +377,7 @@ async fn wait_for_v2_pairing_success( mut live_subscription: RpcSubscription, topic: [u8; 32], core_encryption_secret_key: [u8; 32], + last_processed_statement: Option>, spawner: Spawner, ) -> Result { let (query_tx, mut query_rx) = mpsc::unbounded(); @@ -349,7 +391,11 @@ async fn wait_for_v2_pairing_success( return Err("pairing statement-store live subscription ended".to_string()); }; let value = item.map_err(|err| format!("pairing statement-store live error: {err}"))?; - if let Some(success) = handle_v2_pairing_result(&value, core_encryption_secret_key)? { + if let Some(success) = handle_v2_pairing_result( + &value, + core_encryption_secret_key, + last_processed_statement.as_deref(), + )? { return Ok(success); } } @@ -365,11 +411,13 @@ async fn wait_for_v2_pairing_success( query_active = true; let rpc_client = rpc_client.clone(); let query_tx = query_tx.clone(); + let last_processed_statement = last_processed_statement.clone(); let fut = async move { let result = run_pairing_snapshot_query( rpc_client, topic, core_encryption_secret_key, + last_processed_statement, ).await; let _ = query_tx.unbounded_send(result); }; @@ -389,6 +437,7 @@ async fn run_pairing_snapshot_query( rpc_client: RpcClient, topic: [u8; 32], core_encryption_secret_key: [u8; 32], + last_processed_statement: Option>, ) -> Result, String> { let topics = [topic]; let mut subscription = statement_store_rpc::subscribe_match_all(&rpc_client, &topics) @@ -403,7 +452,11 @@ async fn run_pairing_snapshot_query( return Ok(None); }; let value = item.map_err(|err| format!("pairing statement-store query item failed: {err}"))?; - if let Some(success) = handle_v2_pairing_result(&value, core_encryption_secret_key)? { + if let Some(success) = handle_v2_pairing_result( + &value, + core_encryption_secret_key, + last_processed_statement.as_deref(), + )? { return Ok(Some(success)); } let page = parse_new_statements_result("query".to_string(), &value) @@ -422,10 +475,14 @@ async fn run_pairing_snapshot_query( fn handle_v2_pairing_result( value: &Value, core_encryption_secret_key: [u8; 32], + last_processed_statement: Option<&[u8]>, ) -> Result, String> { let page = parse_new_statements_result("pairing".to_string(), value).map_err(|err| err.to_string())?; for statement in page.statements { + if last_processed_statement == Some(statement.as_slice()) { + continue; + } if let Some(success) = PairingSuccess::from_v2_statement(&statement, core_encryption_secret_key)? { @@ -508,7 +565,7 @@ mod tests { } #[test] - fn request_login_clears_cancelled_pairing_device_identity_between_attempts() { + fn request_login_regenerates_unmarked_pairing_device_identity_between_attempts() { let platform = stub_platform(); let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); @@ -544,19 +601,19 @@ mod tests { pairing_device_from_deeplink(&deeplinks[1]) ); assert!( - !platform + platform .local_storage .lock() .expect("local storage mutex poisoned") .contains_key(&core_storage_test_key( CoreStorageKey::PairingDeviceIdentity )), - "cancelled pairing must clear the device identity so stale statement-store responses cannot be replayed" + "cancelled pairing keeps the latest identity; the next unmarked reuse regenerates it" ); } #[test] - fn request_login_reuses_stored_pairing_device_identity() { + fn request_login_reuses_marked_stored_pairing_device_identity() { let platform = stub_platform(); let identity = generate_pairing_device_identity().unwrap(); platform @@ -567,6 +624,14 @@ mod tests { core_storage_test_key(CoreStorageKey::PairingDeviceIdentity), identity.encode(), ); + platform + .local_storage + .lock() + .expect("local storage mutex poisoned") + .insert( + core_storage_test_key(CoreStorageKey::LastProcessedPairingStatement), + vec![0xde, 0xad], + ); let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); let host = Arc::new(host); @@ -598,14 +663,14 @@ mod tests { ) ); assert!( - !platform + platform .local_storage .lock() .expect("local storage mutex poisoned") .contains_key(&core_storage_test_key( CoreStorageKey::PairingDeviceIdentity )), - "cancelled pairing still clears a reused identity" + "cancelled pairing keeps the marked device identity" ); } @@ -861,6 +926,7 @@ mod tests { rpc_client, bootstrap.topic, bootstrap.encryption_secret_key, + None, )) .unwrap() .expect("snapshot query should return pairing success"); @@ -885,6 +951,38 @@ mod tests { ); } + #[test] + fn pairing_result_skips_last_processed_statement() { + let (host_config, _) = runtime_config("myapp.dot"); + let pairing_identity = generate_pairing_device_identity().unwrap(); + let bootstrap = + create_pairing_bootstrap_from_identity(&host_config, pairing_identity).unwrap(); + let statement = wallet_handshake_statement(&bootstrap.deeplink); + let page = serde_json::json!({ + "event": "newStatements", + "data": { + "statements": [format!("0x{}", hex::encode(&statement))], + "remaining": 0, + }, + }); + + let ignored = handle_v2_pairing_result( + &page, + bootstrap.encryption_secret_key, + Some(statement.as_slice()), + ) + .unwrap(); + assert!(ignored.is_none()); + + let accepted = handle_v2_pairing_result(&page, bootstrap.encryption_secret_key, None) + .unwrap() + .expect("unmarked statement should be accepted"); + assert_eq!( + accepted.peer_statement_account_id, + peer_statement_keypair().1 + ); + } + #[test] fn request_login_emits_login_failed_for_pre_pairing_errors() { let platform = Arc::new(StubPlatform { diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 5cce7283..51810153 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -33,7 +33,7 @@ use truapi::v01; use truapi::versioned::account::HostAccountGetAliasRequest; use truapi::versioned::resource_allocation::HostRequestResourceAllocationRequest; use truapi_platform::{ - AccountAccessReview, AuthPresenter, AuthState, ChainProvider, + AccountAccessReview, AuthPresenter, AuthState, BulletinAllowanceSigner, ChainProvider, CoreStorage as PlatformCoreStorage, CoreStorageKey, Features as PlatformFeatures, HostInfo, JsonRpcConnection, Navigation as PlatformNavigation, Notifications as PlatformNotifications, PairingHostConfig, Permissions as PlatformPermissions, PlatformInfo, PreimageHost, @@ -116,6 +116,8 @@ pub(crate) struct StubPlatform { pub(crate) chain_connect_error: Option<&'static str>, pub(crate) chain_connect_pending: bool, pub(crate) preimage_submits: Arc>>>, + pub(crate) preimage_submit_allowance_public_keys: Arc>>>, + pub(crate) preimage_submit_signatures: Arc>>>, pub(crate) local_storage: Arc>>>, /// When set, product/core storage reads fail with this reason. pub(crate) local_storage_error: Option<&'static str>, @@ -284,7 +286,7 @@ fn submitted_sso_request_from_submit( submit: &str, session: &SessionInfo, ) -> (String, RemoteMessage) { - let value: serde_json::Value = serde_json::from_str(&submit).unwrap(); + let value: serde_json::Value = serde_json::from_str(submit).unwrap(); let statement_hex = value["params"][0].as_str().unwrap(); let statement = hex::decode(statement_hex.strip_prefix("0x").unwrap_or(statement_hex)).unwrap(); let encrypted = crate::host_logic::statement_store::decode_statement_data(&statement) @@ -1228,11 +1230,26 @@ impl ThemeHost for StubPlatform { #[truapi_platform::async_trait] impl PreimageHost for StubPlatform { - async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + async fn submit_preimage( + &self, + value: Vec, + bulletin_allowance_signer: BulletinAllowanceSigner, + ) -> Result, v01::PreimageSubmitError> { self.preimage_submits .lock() .expect("preimage submit list mutex poisoned") .push(value.clone()); + self.preimage_submit_allowance_public_keys + .lock() + .expect("preimage allowance public key list mutex poisoned") + .push(bulletin_allowance_signer.public_key().to_vec()); + let signature = bulletin_allowance_signer + .sign(b"preimage-submit-test") + .map_err(|err| v01::PreimageSubmitError::Unknown { reason: err.reason })?; + self.preimage_submit_signatures + .lock() + .expect("preimage allowance signature list mutex poisoned") + .push(signature.to_vec()); Ok(value) } fn lookup_preimage( diff --git a/rust/crates/truapi-server/tests/common/mod.rs b/rust/crates/truapi-server/tests/common/mod.rs index 8d0743cd..5819f53c 100644 --- a/rust/crates/truapi-server/tests/common/mod.rs +++ b/rust/crates/truapi-server/tests/common/mod.rs @@ -6,9 +6,9 @@ use std::sync::Mutex; use futures::stream::{self, BoxStream}; use truapi::v01; use truapi_platform::{ - AuthPresenter, ChainProvider, CoreStorage, CoreStorageKey, Features, HostInfo, - JsonRpcConnection, Navigation, Notifications, PairingHostConfig, Permissions, PlatformInfo, - PreimageHost, ProductContext, ProductStorage, ThemeHost, UserConfirmation, + AuthPresenter, BulletinAllowanceSigner, ChainProvider, CoreStorage, CoreStorageKey, Features, + HostInfo, JsonRpcConnection, Navigation, Notifications, PairingHostConfig, Permissions, + PlatformInfo, PreimageHost, ProductContext, ProductStorage, ThemeHost, UserConfirmation, UserConfirmationReview, }; use truapi_server::frame::ProtocolMessage; @@ -190,7 +190,11 @@ impl ThemeHost for WireShapePlatform { #[truapi_platform::async_trait] impl PreimageHost for WireShapePlatform { - async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + async fn submit_preimage( + &self, + value: Vec, + _bulletin_allowance_signer: BulletinAllowanceSigner, + ) -> Result, v01::PreimageSubmitError> { Ok(value) } fn lookup_preimage( diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 4139b334..a8cd326d 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -30,7 +30,7 @@ pub mod latest { AccountId, AllocatableResource, AllocationOutcome, GenericError, HostSignPayloadData, NotificationId, OperationStartedResult, ProductAccountId, RawPayload, RemotePermission, RuntimeApi, RuntimeSpec, RuntimeType, StorageQueryItem, StorageQueryType, - StorageResultItem, ThemeVariant, + StorageResultItem, ThemeVariant, TxPayloadExtension, }; pub type LatestOf = ::Latest; From 4fb288eeafd65376f136882ba83c8b37d6465f0c Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 8 Jul 2026 09:45:18 +0200 Subject: [PATCH 47/56] fixup! feat(codegen): generate wasm bridge callbacks --- .../truapi-codegen/src/rust/wasm_bridge.rs | 35 ++++- .../truapi-codegen/src/ts/host_callbacks.rs | 133 ++++++++++++++---- .../tests/golden/host-callbacks-adapter.ts | 5 +- .../tests/golden/host-callbacks.ts | 19 ++- .../tests/golden/wasm_bridge.rs | 18 ++- .../tests/golden/worker-callbacks.ts | 10 +- rust/crates/truapi-server/src/wasm.rs | 34 ++++- .../src/wasm/generated_bridge.rs | 17 ++- 8 files changed, 223 insertions(+), 48 deletions(-) diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index d4c18d72..3d476692 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -36,9 +36,10 @@ pub fn generate_wasm_bridge( 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, + WasmPlatform, bulletin_allowance_signer_to_js, 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 @@ -524,6 +525,12 @@ fn js_arg_expr(name: &str, ty: &TypeRef, ctx: &BridgeCtx<'_>) -> Result if is_bytes(ty) { return Ok(format!("Uint8Array::from({name}.as_slice()).into()")); } + if is_callback_byte_type(ty) { + return Ok(format!("Uint8Array::from({name}.as_secret_bytes()).into()")); + } + if is_callback_signer_type(ty) { + return Ok(format!("bulletin_allowance_signer_to_js({name})")); + } if ctx.is_api_codec(ty) || ctx.is_local_codec(ty) { return Ok(format!( "Uint8Array::from({name}.encode().as_slice()).into()" @@ -822,7 +829,7 @@ fn collect_local_from_type<'a>( ) { match ty { TypeRef::Named { name, args } => { - if local.contains(name.as_str()) { + if local.contains(name.as_str()) && !is_callback_special_type_name(name) { out.insert(name); } for arg in args { @@ -840,3 +847,23 @@ fn collect_local_from_type<'a>( TypeRef::Primitive(_) | TypeRef::Generic(_) | TypeRef::Unit => {} } } + +fn is_callback_byte_type(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Named { name, .. } if is_callback_byte_type_name(name)) +} + +fn is_callback_signer_type(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Named { name, .. } if is_callback_signer_type_name(name)) +} + +fn is_callback_special_type_name(name: &str) -> bool { + is_callback_byte_type_name(name) || is_callback_signer_type_name(name) +} + +fn is_callback_byte_type_name(name: &str) -> bool { + name == "BulletinAllowanceKey" +} + +fn is_callback_signer_type_name(name: &str) -> bool { + name == "BulletinAllowanceSigner" +} diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index ac0f52b6..36dce99c 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -125,7 +125,16 @@ fn emit_host_callbacks( out.push('\n'); } - for type_def in &definition.types { + if has_callback_signer_type(definition) { + out.push_str(&emit_callback_signer_interface()); + out.push('\n'); + } + + for type_def in definition + .types + .iter() + .filter(|ty| !is_callback_special_type_name(&ty.name)) + { let rendered = match &type_def.kind { TypeDefKind::Enum(_) => emit_enum_type(type_def)?, _ => emit_struct_interface(type_def)?, @@ -256,6 +265,7 @@ fn emit_wasm_adapter( out, r#" import type {{ + BulletinAllowanceSigner, FlatHostCallbacks, RequiredHostCallbacks, }} from "./host-callbacks.js"; @@ -356,6 +366,21 @@ fn emit_worker_callbacks( "# ) .unwrap(); + if has_callback_signer_type(definition) { + writedoc!( + out, + r#" + import type {{ BulletinAllowanceSigner }} from "./host-callbacks.js"; + + export interface WorkerBulletinAllowanceSigner {{ + publicKey: Uint8Array; + signerId: number; + }} + + "# + ) + .unwrap(); + } emit_import_block(&mut out, true, "../runtime.js", &runtime_types); if !runtime_types.is_empty() { out.push('\n'); @@ -366,19 +391,22 @@ fn emit_worker_callbacks( out.push_str(&const_name_array("SUBSCRIPTION_NAMES", &subscriptions)); out.push_str("export type SubscriptionName = typeof SUBSCRIPTION_NAMES[number];\n\n"); - writedoc!( - out, - r#" - export interface WorkerCallbackBridge {{ - callbackRequest(name: CallbackName, args: readonly unknown[]): Promise; - startSubscription( - name: SubscriptionName, - payload: Uint8Array | null, - sendItem: (value: T) => void, - ): () => void; - "# - ) - .unwrap(); + out.push_str("export interface WorkerCallbackBridge {\n"); + out.push_str( + " callbackRequest(name: CallbackName, args: readonly unknown[]): Promise;\n", + ); + if has_callback_signer_type(definition) { + writeln!( + out, + " registerBulletinAllowanceSigner(signer: BulletinAllowanceSigner): WorkerBulletinAllowanceSigner;" + ) + .unwrap(); + } + out.push_str(" startSubscription(\n"); + out.push_str(" name: SubscriptionName,\n"); + out.push_str(" payload: Uint8Array | null,\n"); + out.push_str(" sendItem: (value: T) => void,\n"); + out.push_str(" ): () => void;\n"); for (trait_def, method) in &trait_object_callbacks { writeln!( out, @@ -388,14 +416,7 @@ fn emit_worker_callbacks( ) .unwrap(); } - writedoc!( - out, - r#" - }} - - "# - ) - .unwrap(); + out.push_str("}\n\n"); out.push_str(&emit_worker_callback_factory( "rawCallbacks", @@ -498,10 +519,24 @@ fn emit_worker_callback_entry(method: &PlatformMethod) -> Result { .map(|p| to_camel_case(&p.name)) .collect::>() .join(", "); - let arg_array = if args.is_empty() { + let arg_exprs = method + .params + .iter() + .map(|p| { + let name = to_camel_case(&p.name); + if matches!(&p.type_ref, TypeRef::Named { name, .. } if is_callback_signer_type_name(name)) + { + format!("bridge.registerBulletinAllowanceSigner({name})") + } else { + name + } + }) + .collect::>() + .join(", "); + let arg_array = if arg_exprs.is_empty() { "[]".to_string() } else { - format!("[{args}]") + format!("[{arg_exprs}]") }; if method.return_shape.is_async { Ok(format!( @@ -702,6 +737,10 @@ fn raw_param_ts( local_codec_types: &BTreeSet, ) -> String { match ty { + TypeRef::Named { name, .. } if is_callback_byte_type_name(name) => "Uint8Array".to_string(), + TypeRef::Named { name, .. } if is_callback_signer_type_name(name) => { + "BulletinAllowanceSigner".to_string() + } 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(), @@ -726,6 +765,10 @@ fn raw_ok_ts( local_codec_types: &BTreeSet, ) -> String { match ty { + TypeRef::Named { name, .. } if is_callback_byte_type_name(name) => "Uint8Array".to_string(), + TypeRef::Named { name, .. } if is_callback_signer_type_name(name) => { + "BulletinAllowanceSigner".to_string() + } 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(), @@ -807,6 +850,7 @@ fn adapter_arg( ) -> String { let name = to_camel_case(¶m.name); match ¶m.type_ref { + TypeRef::Named { name: ty, .. } if is_callback_special_type_name(ty) => name, TypeRef::Named { name: ty, .. } if codec_types.contains(ty) || local_codec_types.contains(ty) => { @@ -1033,7 +1077,7 @@ fn walk_type_def( fn collect_local_from_type(ty: &TypeRef, local: &BTreeSet, out: &mut BTreeSet) { match ty { TypeRef::Named { name, args } => { - if local.contains(name) { + if local.contains(name) && !is_callback_special_type_name(name) { out.insert(name.clone()); } for arg in args { @@ -1495,6 +1539,9 @@ fn collect_named_types(definition: &PlatformDefinition) -> BTreeSet { } } for type_def in &definition.types { + if is_callback_special_type_name(&type_def.name) { + continue; + } collect_from_type_def(type_def, &mut out); } // Filter out names defined locally (the capability trait interfaces and @@ -1548,6 +1595,12 @@ fn ts_type(ty: &TypeRef) -> Result { _ => bail!("Unsupported primitive type `{name}` in host callbacks generation"), }, TypeRef::Named { name, args } => { + if is_callback_byte_type_name(name) { + return Ok("Uint8Array".to_string()); + } + if is_callback_signer_type_name(name) { + return Ok("BulletinAllowanceSigner".to_string()); + } if args.is_empty() { Ok(name.clone()) } else { @@ -1585,6 +1638,36 @@ fn ts_type(ty: &TypeRef) -> Result { } } +fn is_callback_byte_type_name(name: &str) -> bool { + name == "BulletinAllowanceKey" +} + +fn is_callback_signer_type_name(name: &str) -> bool { + name == "BulletinAllowanceSigner" +} + +fn is_callback_special_type_name(name: &str) -> bool { + is_callback_byte_type_name(name) || is_callback_signer_type_name(name) +} + +fn has_callback_signer_type(definition: &PlatformDefinition) -> bool { + definition + .types + .iter() + .any(|ty| is_callback_signer_type_name(&ty.name)) +} + +fn emit_callback_signer_interface() -> String { + formatdoc! { + r#" + export interface BulletinAllowanceSigner {{ + publicKey: Uint8Array; + sign(input: Uint8Array): Promise; + }} + "# + } +} + fn render_jsdoc(indent: &str, docs: Option<&str>) -> String { let Some(docs) = docs else { return String::new(); 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 f56af427..ec8be71a 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -25,6 +25,7 @@ import { UserConfirmationReview, } from "./host-callbacks.js"; import type { + BulletinAllowanceSigner, FlatHostCallbacks, RequiredHostCallbacks, } from "./host-callbacks.js"; @@ -49,7 +50,7 @@ export interface RawCallbacks { cancelNotification(id: NotificationId): Promise; devicePermission(request: Uint8Array): Promise; remotePermission(request: Uint8Array): Promise; - submitPreimage(value: Uint8Array): Promise; + submitPreimage(value: Uint8Array, bulletinAllowanceSigner: BulletinAllowanceSigner): Promise; lookupPreimage(key: Uint8Array, sendItem: (item?: Uint8Array) => void): (() => void) | void; read(key: string): Promise; write(key: string, value: Uint8Array): Promise; @@ -75,7 +76,7 @@ export function createWasmRawCallbacks( 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), + submitPreimage: async (value, bulletinAllowanceSigner) => await callbacks.preimage.submitPreimage(value, bulletinAllowanceSigner), 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), diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index f86b2a36..dc66c51c 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -31,6 +31,11 @@ import type { ThemeVariant, } from "@parity/truapi"; +export interface BulletinAllowanceSigner { + publicKey: Uint8Array; + sign(input: Uint8Array): Promise; +} + /** * Review shown before a product asks to access another product account. */ @@ -105,7 +110,15 @@ export type CoreStorageKey = /** * Persisted authorization for one product-scoped permission request. */ - | { tag: "PermissionAuthorization"; value: { productId: string; request: PermissionAuthorizationRequest } }; + | { tag: "PermissionAuthorization"; value: { productId: string; request: PermissionAuthorizationRequest } } + /** + * Persisted allowance-slot keys for one paired SSO session. + */ + | { tag: "AllowanceKeys"; value: { sessionId: string } } + /** + * Last processed SSO pairing response statement for the pairing device. + */ + | { tag: "LastProcessedPairingStatement"; value?: undefined }; /** * Review shown before a transaction-creation request is sent to the paired wallet. @@ -279,7 +292,7 @@ export const AuthState: S.Codec = S.lazy((): S.Codec => S. * Storage is host-local; `storage.md` records the current status quo: * */ -export const CoreStorageKey: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({AuthSession: S._void, PairingDeviceIdentity: S._void, PermissionAuthorization: S.Struct({productId: S.str, request: PermissionAuthorizationRequest}) as S.Codec<{ productId: string; request: PermissionAuthorizationRequest }>})); +export const CoreStorageKey: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({AuthSession: S._void, PairingDeviceIdentity: S._void, PermissionAuthorization: S.Struct({productId: S.str, request: PermissionAuthorizationRequest}) as S.Codec<{ productId: string; request: PermissionAuthorizationRequest }>, AllowanceKeys: S.Struct({sessionId: S.str}) as S.Codec<{ sessionId: string }>, LastProcessedPairingStatement: S._void})); /** * Review shown before a transaction-creation request is sent to the paired wallet. @@ -518,7 +531,7 @@ export interface PreimageHost { /** * Submit the preimage and return its key. */ - submitPreimage?(value: Uint8Array): Promise; + submitPreimage?(value: Uint8Array, bulletinAllowanceSigner: BulletinAllowanceSigner): Promise; /** * Emits current value/miss immediately, then future updates. diff --git a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs index 50f2614b..eedd768b 100644 --- a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs @@ -11,9 +11,10 @@ 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, + WasmPlatform, bulletin_allowance_signer_to_js, 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 @@ -218,10 +219,17 @@ impl truapi_platform::Permissions for WasmPlatform { #[truapi_platform::async_trait] impl truapi_platform::PreimageHost for WasmPlatform { - async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + async fn submit_preimage( + &self, + value: Vec, + bulletin_allowance_signer: truapi_platform::BulletinAllowanceSigner, + ) -> Result, v01::PreimageSubmitError> { invoke_bytes_return( &self.bridge.submit_preimage, - vec![Uint8Array::from(value.as_slice()).into()], + vec![ + Uint8Array::from(value.as_slice()).into(), + bulletin_allowance_signer_to_js(bulletin_allowance_signer), + ], ) .await .map_err(|reason| v01::PreimageSubmitError::Unknown { reason }) diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts index 4a20a7d6..969695f3 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -7,6 +7,13 @@ import type { RawCallbacks } from "./host-callbacks-adapter.js"; +import type { BulletinAllowanceSigner } from "./host-callbacks.js"; + +export interface WorkerBulletinAllowanceSigner { + publicKey: Uint8Array; + signerId: number; +} + import type { ChainConnect, } from "../runtime.js"; @@ -38,6 +45,7 @@ export type SubscriptionName = typeof SUBSCRIPTION_NAMES[number]; export interface WorkerCallbackBridge { callbackRequest(name: CallbackName, args: readonly unknown[]): Promise; + registerBulletinAllowanceSigner(signer: BulletinAllowanceSigner): WorkerBulletinAllowanceSigner; startSubscription( name: SubscriptionName, payload: Uint8Array | null, @@ -69,7 +77,7 @@ function rawCallbacks(bridge: WorkerCallbackBridge): Required bridge.callbackRequest("remotePermission", [request]) as ReturnType, submitPreimage: (value, bulletinAllowanceSigner) => - bridge.callbackRequest("submitPreimage", [value, bulletinAllowanceSigner]) as ReturnType, + bridge.callbackRequest("submitPreimage", [value, bridge.registerBulletinAllowanceSigner(bulletinAllowanceSigner)]) as ReturnType, read: (key) => bridge.callbackRequest("read", [key]) as ReturnType, write: (key, value) => diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index 79dfd3a4..63f1735d 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -18,13 +18,13 @@ 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 js_sys::{Array, Function, Object, Reflect, Uint8Array}; use parity_scale_codec::Decode; use send_wrapper::SendWrapper; use truapi::v01; use truapi_platform::{ - ChainProvider, HostInfo, JsonRpcConnection, PairingHostConfig, PlatformInfo, ProductContext, - RuntimeConfigValidationError, + BulletinAllowanceSigner, ChainProvider, HostInfo, JsonRpcConnection, PairingHostConfig, + PlatformInfo, ProductContext, RuntimeConfigValidationError, }; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; @@ -344,6 +344,34 @@ fn invoke_optional_bytes_return( }) } +fn bulletin_allowance_signer_to_js(signer: BulletinAllowanceSigner) -> JsValue { + let object = Object::new(); + let public_key = signer.public_key(); + let public_key = Uint8Array::from(public_key.as_slice()); + Reflect::set(&object, &JsValue::from_str("publicKey"), &public_key) + .expect("setting publicKey on a new object should not fail"); + + let sign = Closure::wrap(Box::new(move |input: JsValue| -> js_sys::Promise { + let signer = signer.clone(); + wasm_bindgen_futures::future_to_promise(async move { + let input = input.dyn_into::().map_err(|_| { + JsValue::from_str("BulletinAllowanceSigner.sign expects Uint8Array") + })?; + let signature = signer + .sign(&input.to_vec()) + .map_err(|err| JsValue::from_str(&err.reason))?; + Ok(Uint8Array::from(signature.as_slice()).into()) + }) + }) as Box js_sys::Promise>); + Reflect::set(&object, &JsValue::from_str("sign"), sign.as_ref()) + .expect("setting sign on a new object should not fail"); + // The host callback owns the JS signer for the async tx submission. The + // object exposes no raw secret, only this Rust-backed signing capability. + sign.forget(); + + object.into() +} + fn decode_bytes(bytes: Vec, message: &str) -> Result { T::decode(&mut bytes.as_slice()).map_err(|_| message.to_string()) } diff --git a/rust/crates/truapi-server/src/wasm/generated_bridge.rs b/rust/crates/truapi-server/src/wasm/generated_bridge.rs index 50f2614b..e01cf767 100644 --- a/rust/crates/truapi-server/src/wasm/generated_bridge.rs +++ b/rust/crates/truapi-server/src/wasm/generated_bridge.rs @@ -11,9 +11,9 @@ 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, + WasmPlatform, bulletin_allowance_signer_to_js, 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 @@ -218,10 +218,17 @@ impl truapi_platform::Permissions for WasmPlatform { #[truapi_platform::async_trait] impl truapi_platform::PreimageHost for WasmPlatform { - async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + async fn submit_preimage( + &self, + value: Vec, + bulletin_allowance_signer: truapi_platform::BulletinAllowanceSigner, + ) -> Result, v01::PreimageSubmitError> { invoke_bytes_return( &self.bridge.submit_preimage, - vec![Uint8Array::from(value.as_slice()).into()], + vec![ + Uint8Array::from(value.as_slice()).into(), + bulletin_allowance_signer_to_js(bulletin_allowance_signer), + ], ) .await .map_err(|reason| v01::PreimageSubmitError::Unknown { reason }) From 3a64291dfee718c9c5718a1ca088b07639a8d8e1 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 8 Jul 2026 09:57:06 +0200 Subject: [PATCH 48/56] refactor(platform): derive allowance Debug --- rust/crates/truapi-platform/Cargo.toml | 2 +- rust/crates/truapi-platform/src/lib.rs | 23 ++++------------------- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/rust/crates/truapi-platform/Cargo.toml b/rust/crates/truapi-platform/Cargo.toml index 402cb3d1..cfbd0fc4 100644 --- a/rust/crates/truapi-platform/Cargo.toml +++ b/rust/crates/truapi-platform/Cargo.toml @@ -8,7 +8,7 @@ license = "MIT" [dependencies] truapi = { path = "../truapi" } async-trait = "0.1" -derive_more = { version = "2", features = ["display", "error"] } +derive_more = { version = "2", features = ["debug", "display", "error"] } futures = "0.3" parity-scale-codec = { version = "3", features = ["derive"] } unicode-normalization = "0.1" diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 392747c1..9e3a705e 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -613,8 +613,9 @@ pub trait ThemeHost: Send + Sync { } /// Secret key allocated for Bulletin preimage submission. -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone, derive_more::Debug, PartialEq, Eq)] pub struct BulletinAllowanceKey { + #[debug("{:?}", "")] secret: [u8; 64], } @@ -640,14 +641,6 @@ impl BulletinAllowanceKey { } } -impl core::fmt::Debug for BulletinAllowanceKey { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("BulletinAllowanceKey") - .field("secret", &"") - .finish() - } -} - /// Invalid Bulletin allowance key material. #[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] pub enum BulletinAllowanceKeyError { @@ -668,11 +661,12 @@ type BulletinAllowanceSignFn = dyn Fn(&[u8]) -> Result<[u8; 64], BulletinAllowanceSignError> + Send + Sync; /// Host-facing signer for Bulletin preimage submission. -#[derive(Clone)] +#[derive(Clone, derive_more::Debug)] pub struct BulletinAllowanceSigner { public_key: [u8; 32], /// Rust-owned signing capability passed to host code without exposing the /// raw allowance secret. + #[debug("{:?}", "")] sign: Arc, } @@ -699,15 +693,6 @@ impl BulletinAllowanceSigner { } } -impl core::fmt::Debug for BulletinAllowanceSigner { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("BulletinAllowanceSigner") - .field("public_key", &self.public_key) - .field("sign", &"") - .finish() - } -} - /// Bulletin allowance signing failed. #[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] #[display("{reason}")] From a99f0e5f7b1f51dc5c5bac12e53f47bd0db7b897 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 8 Jul 2026 10:30:17 +0200 Subject: [PATCH 49/56] fix(runtime): keep allowance signing in Rust --- rust/crates/truapi-platform/src/lib.rs | 2 +- .../src/host_logic/allowance_signer.rs | 19 +++++++++---------- .../truapi-server/src/host_logic/dotns.rs | 6 +++--- .../truapi-server/src/host_logic/entropy.rs | 2 +- .../truapi-server/src/host_logic/identity.rs | 2 +- .../src/host_logic/product_account.rs | 2 +- rust/crates/truapi-server/src/runtime.rs | 4 ++-- 7 files changed, 18 insertions(+), 19 deletions(-) diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 9e3a705e..c0351268 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -656,7 +656,7 @@ pub enum BulletinAllowanceKeyError { /// /// Rust owns the allowance key format and secret material. Host code only gets /// `public_key + sign(payload)`, enough for PAPI to build and submit the -/// Bulletin transaction without reintroducing Nova key parsing in dotli. +/// Bulletin transaction without reintroducing allowance key parsing in host code. type BulletinAllowanceSignFn = dyn Fn(&[u8]) -> Result<[u8; 64], BulletinAllowanceSignError> + Send + Sync; diff --git a/rust/crates/truapi-server/src/host_logic/allowance_signer.rs b/rust/crates/truapi-server/src/host_logic/allowance_signer.rs index 6651844a..9e414bea 100644 --- a/rust/crates/truapi-server/src/host_logic/allowance_signer.rs +++ b/rust/crates/truapi-server/src/host_logic/allowance_signer.rs @@ -11,9 +11,16 @@ pub(crate) fn bulletin_allowance_signer_from_key( ) -> Result { let secret = key.into_secret_bytes(); let public_key = public_key_from_allowance_secret(secret)?; + // The host receives only the allowance public key plus this Rust-backed + // signing capability while constructing the `TransactionStorage.store` + // extrinsic; the allowance secret stays in Rust. Ok(BulletinAllowanceSigner::new(public_key, move |payload| { - sign_with_allowance_secret(secret, payload) - .map_err(|reason| BulletinAllowanceSignError { reason }) + let secret = secret_key_from_allowance_secret(secret) + .map_err(|reason| BulletinAllowanceSignError { reason })?; + let public = secret.to_public(); + Ok(secret + .sign_simple(SR25519_SIGNING_CONTEXT, payload, &public) + .to_bytes()) })) } @@ -24,14 +31,6 @@ pub(crate) fn public_key_from_allowance_secret(secret: [u8; 64]) -> Result<[u8; .to_bytes()) } -fn sign_with_allowance_secret(secret: [u8; 64], payload: &[u8]) -> Result<[u8; 64], String> { - let secret = secret_key_from_allowance_secret(secret)?; - let public = secret.to_public(); - Ok(secret - .sign_simple(SR25519_SIGNING_CONTEXT, payload, &public) - .to_bytes()) -} - fn secret_key_from_allowance_secret(secret: [u8; 64]) -> Result { // Mobile allowance keys are `SlotAccountKey` values (`privateKey || nonce`) // and must use schnorrkel's canonical `SecretKey::from_bytes` path. Older diff --git a/rust/crates/truapi-server/src/host_logic/dotns.rs b/rust/crates/truapi-server/src/host_logic/dotns.rs index 67f93351..ab29a4d3 100644 --- a/rust/crates/truapi-server/src/host_logic/dotns.rs +++ b/rust/crates/truapi-server/src/host_logic/dotns.rs @@ -43,8 +43,8 @@ pub enum NavigateDecision { impl NavigateDecision { /// Canonical URL string for the three `Open*` variants; `None` for /// `Reject`. `DotName` and `Localhost` keep the dotns/localhost identity - /// visible so env-aware hosts (e.g. dotli rewriting `.dot` to `.dot.li`) - /// can re-parse and do their own assembly without losing information. + /// visible so env-aware hosts can rewrite `.dot` names for their active + /// environment and re-parse without losing information. pub fn canonical_url(&self) -> Option { match self { Self::DotName { identifier, path } => Some(join_url("https://", identifier, path)), @@ -63,7 +63,7 @@ fn join_url(scheme: &str, host: &str, path: &str) -> String { } } -/// Classify a URL the way dotli's `handleNavigateTo` does: try `.dot` first, +/// Classify a URL the way the host navigation handler does: try `.dot` first, /// then `localhost`, then normalize as external. pub fn parse_navigate(input: &str) -> NavigateDecision { let trimmed = input.trim(); diff --git a/rust/crates/truapi-server/src/host_logic/entropy.rs b/rust/crates/truapi-server/src/host_logic/entropy.rs index cbdd3fe2..1c9323d9 100644 --- a/rust/crates/truapi-server/src/host_logic/entropy.rs +++ b/rust/crates/truapi-server/src/host_logic/entropy.rs @@ -1,6 +1,6 @@ //! Product-scoped deterministic entropy derivation. //! -//! Matches dotli's product entropy contract: three keyed BLAKE2b-256 layers +//! Matches the host product entropy contract: three keyed BLAKE2b-256 layers //! over the session secret, product id, and caller key. //! Host-spec C.8 defines the RFC-0007 product entropy algorithm: //! diff --git a/rust/crates/truapi-server/src/host_logic/identity.rs b/rust/crates/truapi-server/src/host_logic/identity.rs index 4f798825..f175edb5 100644 --- a/rust/crates/truapi-server/src/host_logic/identity.rs +++ b/rust/crates/truapi-server/src/host_logic/identity.rs @@ -1,6 +1,6 @@ //! People-chain identity lookup for paired SSO sessions. //! -//! dotli's previous host-papp path read `Resources.Consumers[account]` from +//! The previous host-papp path read `Resources.Consumers[account]` from //! the People chain and used only the username fields. Keep this module narrow: //! it builds that storage key and decodes the leading username fields from the //! SCALE value. The record begins with a fixed identifier public key; credibility 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..cfc329aa 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -1,6 +1,6 @@ //! Product account derivation shared by all hosts. //! -//! Mirrors dotli's `packages/auth/src/account.ts`: derive an sr25519 public +//! Mirrors host product-account derivation: derive an sr25519 public //! key through soft HDKD junctions `["product", product_id, derivation_index]`. //! Host-spec C.5-C.7 define the product-account derivation, SS58 address, and //! `ProductAccountId` shape: diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index d93cdc6f..34831ce1 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -1562,8 +1562,8 @@ impl Chain for ProductRuntimeHost { // --------------------------------------------------------------------------- // Deferred product surfaces. // -// Payment and full account proof are explicitly out of current dotli parity, -// but products should still observe dotli's typed "not implemented" errors +// Payment and full account proof are explicitly out of current host parity, +// but products should still observe the host's typed "not implemented" errors // rather than a generic transport failure. // Chat and CoinPayment remain outside this milestone and keep their generated // trait defaults until another host/product needs real implementations. From 80135efcc71d06edc0ef2f926a35c2afa1f9392c Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 8 Jul 2026 14:52:02 +0200 Subject: [PATCH 50/56] fix(runtime): harden pairing failures --- rust/crates/truapi-server/src/runtime.rs | 5 +- .../truapi-server/src/runtime/sso_pairing.rs | 86 +++++++++++++++---- rust/crates/truapi-server/src/test_support.rs | 82 +++++++++++++++--- 3 files changed, 143 insertions(+), 30 deletions(-) diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 34831ce1..7004a228 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -312,6 +312,9 @@ impl ProductRuntimeHost { return false; }; let product_id = self.product_id(); + // Localhost products are development-only wildcards once a host admits + // them. Production hosts must reject localhost products before creating + // the product runtime. product_id == "localhost" || product_id.starts_with("localhost:") || dot_ns_identifier == product_id @@ -4053,7 +4056,7 @@ mod tests { .contains_key(&core_storage_test_key( CoreStorageKey::PairingDeviceIdentity )), - "logout keeps the pairing device identity; stale pairing responses are skipped by the processed-statement marker" + "logout may leave the old pairing identity in storage; the next login rotates it before presenting QR" ); assert_eq!( futures::executor::block_on(statuses.next()).unwrap(), diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index 1e833585..71cbd492 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -101,17 +101,16 @@ impl<'a> SsoPairingFlow<'a> { read_or_create_pairing_device_identity(self.host.platform.as_ref()) .await .map_err(|reason| self.fail_before_pairing(reason))?; - // Match legacy host-papp: keep the pairing device identity stable so - // the wallet can reuse the registered host statement-store slot, but - // also remember the last handled handshake statement. Statement-store - // subscriptions replay retained topic data, so after a reload/logout a - // stale Success must be skipped before the user re-authenticates. let last_processed_statement = read_last_processed_pairing_statement(self.host.platform.as_ref()) .await .map_err(|reason| self.fail_before_pairing(reason))?; - if reused_identity && last_processed_statement.is_none() { - debug!("regenerating unmarked pairing device identity"); + // Pairing success statements are retained by statement-store. Reusing a + // previous pairing identity means reusing its topic, where the only + // retained response may be the last processed success. Rotate before + // presenting QR so every explicit login waits on a fresh wallet scan. + if reused_identity { + debug!("regenerating stored pairing device identity"); pairing_identity = create_fresh_pairing_device_identity(self.host.platform.as_ref()) .await .map_err(|reason| self.fail_before_pairing(reason))?; @@ -343,12 +342,18 @@ struct PairingSuccess { success: v2::Success, } +enum PairingStatementOutcome { + Pending, + Failed(String), + Success(PairingSuccess), +} + impl PairingSuccess { #[instrument(skip_all, fields(runtime.method = "sso.pairing.decode_statement"))] fn from_v2_statement( statement: &[u8], core_encryption_secret_key: [u8; 32], - ) -> Result, String> { + ) -> Result { let verified = decode_verified_statement_data(statement, None).map_err(|err| err.to_string())?; let VersionedHandshakeResponse::V2 { @@ -360,9 +365,9 @@ impl PairingSuccess { public_key, &encrypted_message, )? { - v2::EncryptedResponse::Pending(_) => Ok(None), - v2::EncryptedResponse::Failed(reason) => Err(reason), - v2::EncryptedResponse::Success(success) => Ok(Some(Self { + v2::EncryptedResponse::Pending(_) => Ok(PairingStatementOutcome::Pending), + v2::EncryptedResponse::Failed(reason) => Ok(PairingStatementOutcome::Failed(reason)), + v2::EncryptedResponse::Success(success) => Ok(PairingStatementOutcome::Success(Self { statement: statement.to_vec(), peer_statement_account_id: verified.signer, success: *success, @@ -483,10 +488,10 @@ fn handle_v2_pairing_result( if last_processed_statement == Some(statement.as_slice()) { continue; } - if let Some(success) = - PairingSuccess::from_v2_statement(&statement, core_encryption_secret_key)? - { - return Ok(Some(success)); + match PairingSuccess::from_v2_statement(&statement, core_encryption_secret_key)? { + PairingStatementOutcome::Pending => {} + PairingStatementOutcome::Failed(reason) => return Err(reason), + PairingStatementOutcome::Success(success) => return Ok(Some(success)), } } @@ -613,7 +618,7 @@ mod tests { } #[test] - fn request_login_reuses_marked_stored_pairing_device_identity() { + fn request_login_regenerates_marked_stored_pairing_device_identity() { let platform = stub_platform(); let identity = generate_pairing_device_identity().unwrap(); platform @@ -655,7 +660,7 @@ mod tests { _ => None, }) .expect("pairing state should be emitted"); - assert_eq!( + assert_ne!( pairing_device_from_deeplink(&deeplink), ( identity.statement_store_public_key, @@ -670,7 +675,7 @@ mod tests { .contains_key(&core_storage_test_key( CoreStorageKey::PairingDeviceIdentity )), - "cancelled pairing keeps the marked device identity" + "cancelled pairing keeps the rotated identity; the next login rotates again" ); } @@ -686,7 +691,7 @@ mod tests { }; let statement = signed_test_statement(handshake.encode()); let notification = format!( - r#"{{"jsonrpc":"2.0","method":"statement_subscribeStatement","params":{{"subscription":"remote-sub","result":{{"event":"newStatements","data":{{"statements":["0x{}"],"remaining":0}}}}}}}}"#, + r#"{{"jsonrpc":"2.0","method":"statement_statement","params":{{"subscription":"remote-sub","result":{{"event":"newStatements","data":{{"statements":["0x{}"],"remaining":0}}}}}}}}"#, hex::encode(statement) ); let platform = Arc::new(StubPlatform { @@ -819,6 +824,49 @@ mod tests { ); } + #[test] + fn request_login_surfaces_wallet_failure_status() { + let session_writes = Arc::new(Mutex::new(Vec::new())); + let platform = Arc::new(StubPlatform { + pairing_failure_response: true, + session_writes: session_writes.clone(), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let err = futures::executor::block_on(host.request_login(&cx, request)).unwrap_err(); + + let expected_reason = + "The operation couldn't be completed. (SubstrateSdk.JSONRPCError error 1.)"; + assert_eq!( + err, + CallError::HostFailure { + reason: expected_reason.to_string() + } + ); + assert!( + session_writes + .lock() + .expect("session writes mutex poisoned") + .is_empty() + ); + let auth_states = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"); + assert!( + auth_states + .iter() + .any(|state| matches!(state, AuthState::LoginFailed { reason } if reason == expected_reason)), + "wallet failure should be surfaced to the modal: {auth_states:?}" + ); + } + #[test] fn request_login_clears_auth_session_when_cancelled_after_persist() { let session_writes = Arc::new(Mutex::new(Vec::new())); diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 51810153..652b4e41 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -104,6 +104,8 @@ pub(crate) struct StubPlatform { /// Set when the pending theme stream is dropped. pub(crate) theme_stream_dropped: Arc, pub(crate) pairing_success_response: bool, + /// Deliver a wallet failure status on the pairing subscription. + pub(crate) pairing_failure_response: bool, /// Deliver the pairing success statement only through a snapshot /// query page; the live subscription stays silent. pub(crate) pairing_success_via_query: bool, @@ -492,13 +494,22 @@ pub(crate) fn pairing_device_from_deeplink(deeplink: &str) -> ([u8; 32], [u8; 65 } pub(crate) fn wallet_handshake_statement(deeplink: &str) -> Vec { - let core_public_key = - P256PublicKey::from_sec1_bytes(&core_encryption_public_key_from_deeplink(deeplink)) - .expect("core encryption public key should decode"); - let wallet_ephemeral_secret = P256SecretKey::from_slice(&[3; 32]).unwrap(); - let wallet_ephemeral_public = wallet_ephemeral_secret.public_key().to_encoded_point(false); - let mut wallet_ephemeral_public_bytes = [0u8; 65]; - wallet_ephemeral_public_bytes.copy_from_slice(wallet_ephemeral_public.as_bytes()); + wallet_handshake_statement_with_response( + deeplink, + pairing::v2::EncryptedResponse::Success(Box::new(wallet_handshake_success())), + 0x44, + ) +} + +pub(crate) fn failed_wallet_handshake_statement(deeplink: &str, reason: &str) -> Vec { + wallet_handshake_statement_with_response( + deeplink, + pairing::v2::EncryptedResponse::Failed(reason.to_string()), + 0x45, + ) +} + +fn wallet_handshake_success() -> pairing::v2::Success { let wallet_persistent_public: [u8; 65] = P256SecretKey::from_slice(&[2; 32]) .unwrap() .public_key() @@ -506,14 +517,28 @@ pub(crate) fn wallet_handshake_statement(deeplink: &str) -> Vec { .as_bytes() .try_into() .unwrap(); - let answer = pairing::v2::EncryptedResponse::Success(Box::new(pairing::v2::Success { + pairing::v2::Success { identity_account_id: peer_statement_keypair().1, root_account_id: session_info().public_key, identity_chat_private_key: [0x77; 32], sso_enc_pub_key: wallet_persistent_public, device_enc_pub_key: wallet_persistent_public, root_entropy_source: [0x66; 32], - })); + } +} + +fn wallet_handshake_statement_with_response( + deeplink: &str, + answer: pairing::v2::EncryptedResponse, + nonce_seed: u8, +) -> Vec { + let core_public_key = + P256PublicKey::from_sec1_bytes(&core_encryption_public_key_from_deeplink(deeplink)) + .expect("core encryption public key should decode"); + let wallet_ephemeral_secret = P256SecretKey::from_slice(&[3; 32]).unwrap(); + let wallet_ephemeral_public = wallet_ephemeral_secret.public_key().to_encoded_point(false); + let mut wallet_ephemeral_public_bytes = [0u8; 65]; + wallet_ephemeral_public_bytes.copy_from_slice(wallet_ephemeral_public.as_bytes()); let shared_secret = diffie_hellman( wallet_ephemeral_secret.to_nonzero_scalar(), core_public_key.as_affine(), @@ -521,7 +546,7 @@ pub(crate) fn wallet_handshake_statement(deeplink: &str) -> Vec { let hkdf = Hkdf::::new(None, shared_secret.raw_secret_bytes()); let mut aes_key = [0u8; 32]; hkdf.expand(&[], &mut aes_key).unwrap(); - let nonce = [0x44; pairing::AES_GCM_NONCE_LEN]; + let nonce = [nonce_seed; pairing::AES_GCM_NONCE_LEN]; let cipher = Aes256Gcm::new_from_slice(&aes_key).unwrap(); let mut encrypted_message = nonce.to_vec(); encrypted_message.extend( @@ -848,6 +873,7 @@ struct RecordingConnection { sso_response_script: Option, auth_states: Arc>>, pairing_success_response: bool, + pairing_failure_response: bool, pairing_success_via_query: bool, } @@ -1042,6 +1068,41 @@ impl JsonRpcConnection for RecordingConnection { } })); } + if self.pairing_failure_response { + let auth_states = self.auth_states.clone(); + let sent = self.sent.clone(); + return Box::pin(stream::unfold(0, move |state| { + let auth_states = auth_states.clone(); + let sent = sent.clone(); + async move { + match state { + 0 => { + let id = wait_for_statement_subscribe_id(sent.clone(), 0).await; + Some((subscribe_ack_frame(&id, "pairing-sub"), 1)) + } + 1 => { + for _ in 0..100 { + if let Some(deeplink) = first_pairing_deeplink(&auth_states) { + return Some(( + new_statements_frame( + "pairing-sub", + vec![failed_wallet_handshake_statement( + &deeplink, + "The operation couldn't be completed. (SubstrateSdk.JSONRPCError error 1.)", + )], + ), + 2, + )); + } + futures_timer::Delay::new(Duration::from_millis(1)).await; + } + panic!("pairing deeplink was not presented"); + } + _ => futures::future::pending().await, + } + } + })); + } if self.pairing_success_response { let auth_states = self.auth_states.clone(); let sent = self.sent.clone(); @@ -1147,6 +1208,7 @@ impl ChainProvider for StubPlatform { sso_response_script: self.sso_response_script.clone(), auth_states: self.auth_states.clone(), pairing_success_response: self.pairing_success_response, + pairing_failure_response: self.pairing_failure_response, pairing_success_via_query: self.pairing_success_via_query, })) } From 3f775f307310442e0626fd9716c62c5bdcfdd6ff Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 8 Jul 2026 15:11:01 +0200 Subject: [PATCH 51/56] fix(runtime): simplify pairing outcome --- .../truapi-server/src/runtime/sso_pairing.rs | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index 71cbd492..ab6da4b5 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -336,24 +336,23 @@ async fn clear_auth_session(storage: &(impl CoreStorage + ?Sized)) { } } +/// Decoded wallet handshake success plus the statement metadata needed to +/// persist the authenticated session and remember the handled statement. struct PairingSuccess { statement: Vec, peer_statement_account_id: [u8; 32], success: v2::Success, } -enum PairingStatementOutcome { - Pending, - Failed(String), - Success(PairingSuccess), -} - impl PairingSuccess { + /// Decode one retained statement-store response for the current pairing + /// topic. `Ok(None)` means the wallet has not produced a final response for + /// this statement yet; wallet failure statuses are surfaced as `Err`. #[instrument(skip_all, fields(runtime.method = "sso.pairing.decode_statement"))] fn from_v2_statement( statement: &[u8], core_encryption_secret_key: [u8; 32], - ) -> Result { + ) -> Result, String> { let verified = decode_verified_statement_data(statement, None).map_err(|err| err.to_string())?; let VersionedHandshakeResponse::V2 { @@ -365,9 +364,9 @@ impl PairingSuccess { public_key, &encrypted_message, )? { - v2::EncryptedResponse::Pending(_) => Ok(PairingStatementOutcome::Pending), - v2::EncryptedResponse::Failed(reason) => Ok(PairingStatementOutcome::Failed(reason)), - v2::EncryptedResponse::Success(success) => Ok(PairingStatementOutcome::Success(Self { + v2::EncryptedResponse::Pending(_) => Ok(None), + v2::EncryptedResponse::Failed(reason) => Err(reason), + v2::EncryptedResponse::Success(success) => Ok(Some(Self { statement: statement.to_vec(), peer_statement_account_id: verified.signer, success: *success, @@ -488,10 +487,10 @@ fn handle_v2_pairing_result( if last_processed_statement == Some(statement.as_slice()) { continue; } - match PairingSuccess::from_v2_statement(&statement, core_encryption_secret_key)? { - PairingStatementOutcome::Pending => {} - PairingStatementOutcome::Failed(reason) => return Err(reason), - PairingStatementOutcome::Success(success) => return Ok(Some(success)), + if let Some(success) = + PairingSuccess::from_v2_statement(&statement, core_encryption_secret_key)? + { + return Ok(Some(success)); } } From 3c97cebe804d976ed818b0c822cc1da488ee56d7 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 8 Jul 2026 15:27:55 +0200 Subject: [PATCH 52/56] ci: format generated Rust codegen output --- scripts/codegen.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/codegen.sh b/scripts/codegen.sh index da696fd6..f0cf6688 100755 --- a/scripts/codegen.sh +++ b/scripts/codegen.sh @@ -39,6 +39,11 @@ cargo run -p truapi-codegen -- \ --explorer-output js/packages/truapi/src/explorer \ --codec-version 1 +rustfmt +nightly --edition 2024 \ + rust/crates/truapi-server/src/generated/dispatcher.rs \ + rust/crates/truapi-server/src/generated/wire_table.rs \ + rust/crates/truapi-server/src/wasm/generated_bridge.rs + node scripts/regen-explorer-versions.mjs npm exec --yes -- prettier --write \ From 41324066643a7996be148ef6d8e5a0e401e9c11b Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 8 Jul 2026 17:23:46 +0200 Subject: [PATCH 53/56] fixup! feat(codegen): generate wasm bridge callbacks --- scripts/codegen.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/codegen.sh b/scripts/codegen.sh index f0cf6688..7339965b 100755 --- a/scripts/codegen.sh +++ b/scripts/codegen.sh @@ -9,8 +9,8 @@ # --client-examples-output playground/test/generated/examples # --rust-output rust/crates/truapi-server/src/generated # --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-ts-output js/packages/truapi-host/src/generated +# --platform-wasm-adapter-output js/packages/truapi-host/src/generated # --platform-rust-output rust/crates/truapi-server/src/wasm # --codec-version 1 # @@ -33,8 +33,8 @@ cargo run -p truapi-codegen -- \ --client-examples-output playground/test/generated/examples \ --rust-output rust/crates/truapi-server/src/generated \ --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-ts-output js/packages/truapi-host/src/generated \ + --platform-wasm-adapter-output js/packages/truapi-host/src/generated \ --platform-rust-output rust/crates/truapi-server/src/wasm \ --explorer-output js/packages/truapi/src/explorer \ --codec-version 1 @@ -51,7 +51,7 @@ npm exec --yes -- prettier --write \ "js/packages/truapi/src/playground/**/*.ts" \ "js/packages/truapi/src/explorer/**/*.ts" \ "playground/test/generated/examples/**/*.ts" \ - "js/packages/truapi-host-wasm/src/generated/**/*.ts" + "js/packages/truapi-host/src/generated/**/*.ts" # Rebuild dist/ so downstream consumers (in particular the playground, # which picks up @parity/truapi via yarn 1.x file: snapshot) see the @@ -76,5 +76,5 @@ echo "Generated client at js/packages/truapi/src/generated/" 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 host-callbacks WASM adapter at js/packages/truapi-host/src/generated/" echo "Generated Rust WASM bridge at rust/crates/truapi-server/src/wasm/generated_bridge.rs" From f90c2335720db1db0abccfce1c2c821445ea4cb0 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 9 Jul 2026 10:50:35 +0200 Subject: [PATCH 54/56] ci: install rustfmt for codegen job --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5652e58..8f8fb07f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,7 @@ jobs: - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly with: toolchain: nightly + components: rustfmt - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 From 1e332927cf0c24e59ac3a8fd8aa212148cd7a42b Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 9 Jul 2026 17:59:10 +0200 Subject: [PATCH 55/56] refactor(codegen): remove flat host callbacks --- .../truapi-codegen/src/ts/host_callbacks.rs | 43 +------------------ .../tests/golden/host-callbacks-adapter.ts | 25 +---------- .../tests/golden/host-callbacks.ts | 3 -- 3 files changed, 2 insertions(+), 69 deletions(-) diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index 36dce99c..9be0437f 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -266,7 +266,6 @@ fn emit_wasm_adapter( r#" import type {{ BulletinAllowanceSigner, - FlatHostCallbacks, RequiredHostCallbacks, }} from "./host-callbacks.js"; @@ -290,9 +289,8 @@ fn emit_wasm_adapter( /** Adapt typed host callbacks into the raw SCALE callback surface the * WASM core invokes. */ export function createWasmRawCallbacks( - host: RequiredHostCallbacks | Required, + callbacks: RequiredHostCallbacks, ): RawCallbacks {{ - const callbacks = normalizeHostCallbacks(host); return {{ "#, ) @@ -310,8 +308,6 @@ fn emit_wasm_adapter( } } out.push_str(" };\n}\n"); - out.push('\n'); - out.push_str(&emit_normalize_host_callbacks(&traits)); Ok(out) } @@ -1448,11 +1444,6 @@ fn emit_host_callback_composites(composes: &[String], docs: Option<&str>) -> Str "{jsdoc}export interface HostCallbacks {{}}\n\nexport interface RequiredHostCallbacks {{}}\n" ); } - let extends = composes - .iter() - .map(|name| name.to_string()) - .collect::>() - .join(", "); let host_members = composes .iter() .map(|trait_name| format!(" {}: {};", callback_namespace(trait_name), trait_name)) @@ -1471,9 +1462,6 @@ fn emit_host_callback_composites(composes: &[String], docs: Option<&str>) -> Str .join("\n"); formatdoc! { r#" - /** @deprecated Use the namespaced `HostCallbacks` shape instead. */ - export interface FlatHostCallbacks extends {extends} {{}} - {jsdoc}export interface HostCallbacks {{ {host_members} }} @@ -1485,35 +1473,6 @@ fn emit_host_callback_composites(composes: &[String], docs: Option<&str>) -> Str } } -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 { let mut out: BTreeSet = BTreeSet::new(); for trait_def in &definition.traits { 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 ec8be71a..a389ed9c 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -26,7 +26,6 @@ import { } from "./host-callbacks.js"; import type { BulletinAllowanceSigner, - FlatHostCallbacks, RequiredHostCallbacks, } from "./host-callbacks.js"; @@ -61,9 +60,8 @@ export interface RawCallbacks { /** Adapt typed host callbacks into the raw SCALE callback surface the * WASM core invokes. */ export function createWasmRawCallbacks( - host: RequiredHostCallbacks | Required, + callbacks: RequiredHostCallbacks, ): RawCallbacks { - const callbacks = normalizeHostCallbacks(host); return { authStateChanged: async (state) => await callbacks.auth.authStateChanged(AuthState.dec(state)), chainConnect: chainConnectAdapter(callbacks.chain), @@ -85,24 +83,3 @@ export function createWasmRawCallbacks( confirmUserAction: async (review) => await callbacks.userConfirmation.confirmUserAction(UserConfirmationReview.dec(review)), }; } - -function normalizeHostCallbacks( - host: RequiredHostCallbacks | Required, -): RequiredHostCallbacks { - if ("auth" in host) { - return host; - } - return { - 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 dc66c51c..94b7814a 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -582,9 +582,6 @@ 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. */ From 437650a1309d5534c6ff54ec9ae3d4d149b50ac2 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 10 Jul 2026 00:40:51 +0200 Subject: [PATCH 56/56] fix(host-wasm): address review feedback Add wasm32 CI and make check coverage, avoid leaking signer callbacks, and reject compound codec callback boundaries during codegen. --- .github/workflows/ci.yml | 4 + Makefile | 1 + .../truapi-codegen/src/ts/host_callbacks.rs | 214 ++++++++++++++++++ rust/crates/truapi-server/src/wasm.rs | 6 +- 4 files changed, 221 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f8fb07f..e3afa820 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,12 +33,16 @@ jobs: - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # stable with: toolchain: stable + targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - name: cargo build run: cargo build --workspace --all-targets --all-features + - name: cargo check wasm32 + run: cargo check --target wasm32-unknown-unknown -p truapi-server + - name: cargo +nightly fmt --check run: cargo +nightly fmt --check diff --git a/Makefile b/Makefile index c8bc371c..f067ddbf 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,7 @@ test: ## Run Rust + TypeScript client tests. check: ## Full verification suite (build, fmt, clippy, test, TS tests, playground build + lint). cargo build --workspace + cargo check --target wasm32-unknown-unknown -p truapi-server cargo +nightly fmt --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index 9be0437f..c5f6a9c5 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -879,6 +879,8 @@ fn emit_adapter_entry( local_codec_types: &BTreeSet, platform_trait_names: &BTreeSet, ) -> Result { + validate_adapter_codec_boundary(method, codec_types, local_codec_types)?; + 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() { @@ -910,6 +912,99 @@ fn emit_adapter_entry( Ok(format!("{raw}: {impl_expr},")) } +fn validate_adapter_codec_boundary( + method: &PlatformMethod, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, +) -> Result<()> { + for param in &method.params { + validate_adapter_codec_boundary_type( + ¶m.type_ref, + codec_types, + local_codec_types, + &format!("parameter `{}`", param.name), + &method.name, + )?; + } + + match &method.return_shape.inner { + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + validate_adapter_codec_boundary_type( + ok, + codec_types, + local_codec_types, + "return value", + &method.name, + )?; + } + PlatformInner::Stream(item) => { + validate_adapter_codec_boundary_type( + stream_item(item), + codec_types, + local_codec_types, + "stream item", + &method.name, + )?; + } + PlatformInner::Unit | PlatformInner::TraitObject(_) => {} + } + + Ok(()) +} + +fn validate_adapter_codec_boundary_type( + ty: &TypeRef, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, + position: &str, + method_name: &str, +) -> Result<()> { + if contains_non_direct_codec_type(ty, codec_types, local_codec_types) { + bail!( + "unsupported compound codec type in host callback `{method_name}` {position}: {ty:?}" + ); + } + Ok(()) +} + +/// The WASM adapter only knows how to translate a direct codec payload: +/// `Codec` maps to raw `Uint8Array` and the adapter emits `Codec.dec/enc`. +/// Containers such as `Vec`, `Option`, or `(Codec, ...)` would +/// still be declared as raw bytes at the WASM boundary, but no generated code +/// knows how to encode or decode the container. Reject those shapes at codegen. +fn contains_non_direct_codec_type( + ty: &TypeRef, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, +) -> bool { + fn walk( + ty: &TypeRef, + nested: bool, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, + ) -> bool { + match ty { + TypeRef::Named { name, args } => { + if codec_types.contains(name) || local_codec_types.contains(name) { + nested + } else { + args.iter() + .any(|arg| walk(arg, true, codec_types, local_codec_types)) + } + } + TypeRef::Vec(inner) | TypeRef::Option(inner) | TypeRef::Array(inner, _) => { + walk(inner, true, codec_types, local_codec_types) + } + TypeRef::Tuple(items) => items + .iter() + .any(|item| walk(item, true, codec_types, local_codec_types)), + TypeRef::Primitive(_) | TypeRef::Generic(_) | TypeRef::Unit => false, + } + } + + walk(ty, false, codec_types, local_codec_types) +} + /// 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( @@ -1655,3 +1750,122 @@ fn render_ts_doc_line(line: &str) -> String { .replace("Ok(())", "success") .replace("None", "`undefined`") } + +#[cfg(test)] +mod tests { + use super::*; + + fn named(name: &str) -> TypeRef { + TypeRef::Named { + name: name.to_string(), + args: Vec::new(), + } + } + + fn codec_types() -> BTreeSet { + [ + "GenericError", + "HostFeatureSupportedRequest", + "HostFeatureSupportedResponse", + ] + .into_iter() + .map(str::to_string) + .collect() + } + + fn platform_with_method(method: PlatformMethod) -> PlatformDefinition { + PlatformDefinition { + traits: vec![PlatformTrait { + name: "Features".to_string(), + docs: None, + methods: vec![method], + }], + types: Vec::new(), + super_trait: None, + } + } + + fn method_with_return(ok: TypeRef) -> PlatformMethod { + PlatformMethod { + name: "feature_supported".to_string(), + docs: None, + params: vec![PlatformParam { + name: "request".to_string(), + type_ref: named("HostFeatureSupportedRequest"), + }], + return_shape: PlatformReturn { + is_async: true, + inner: PlatformInner::Result { + ok, + err: named("GenericError"), + }, + }, + has_default: false, + } + } + + fn method_with_param(param: TypeRef) -> PlatformMethod { + PlatformMethod { + name: "feature_supported".to_string(), + docs: None, + params: vec![PlatformParam { + name: "request".to_string(), + type_ref: param, + }], + return_shape: PlatformReturn { + is_async: true, + inner: PlatformInner::Result { + ok: named("HostFeatureSupportedResponse"), + err: named("GenericError"), + }, + }, + has_default: false, + } + } + + fn assert_rejects_compound_codec(method: PlatformMethod, expected: &str) { + let definition = platform_with_method(method); + let err = emit_wasm_adapter(&definition, &codec_types(), &BTreeSet::new()) + .expect_err("compound codec boundary should fail codegen") + .to_string(); + assert!( + err.contains("unsupported compound codec type"), + "unexpected error: {err}" + ); + assert!(err.contains(expected), "unexpected error: {err}"); + } + + #[test] + fn wasm_adapter_rejects_compound_codec_return_shapes() { + let codec = named("HostFeatureSupportedResponse"); + assert_rejects_compound_codec( + method_with_return(TypeRef::Vec(Box::new(codec.clone()))), + "return value", + ); + assert_rejects_compound_codec( + method_with_return(TypeRef::Option(Box::new(codec.clone()))), + "return value", + ); + assert_rejects_compound_codec( + method_with_return(TypeRef::Tuple(vec![codec])), + "return value", + ); + } + + #[test] + fn wasm_adapter_rejects_compound_codec_param_shapes() { + let codec = named("HostFeatureSupportedRequest"); + assert_rejects_compound_codec( + method_with_param(TypeRef::Vec(Box::new(codec.clone()))), + "parameter `request`", + ); + assert_rejects_compound_codec( + method_with_param(TypeRef::Option(Box::new(codec.clone()))), + "parameter `request`", + ); + assert_rejects_compound_codec( + method_with_param(TypeRef::Tuple(vec![codec])), + "parameter `request`", + ); + } +} diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index 63f1735d..d46710c4 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -363,11 +363,9 @@ fn bulletin_allowance_signer_to_js(signer: BulletinAllowanceSigner) -> JsValue { Ok(Uint8Array::from(signature.as_slice()).into()) }) }) as Box js_sys::Promise>); - Reflect::set(&object, &JsValue::from_str("sign"), sign.as_ref()) + let sign = sign.into_js_value(); + Reflect::set(&object, &JsValue::from_str("sign"), &sign) .expect("setting sign on a new object should not fail"); - // The host callback owns the JS signer for the async tx submission. The - // object exposes no raw secret, only this Rust-backed signing capability. - sign.forget(); object.into() }