From a3025502700c53f1dbbe3cd8a69b3491bc2de879 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Tue, 30 Jun 2026 09:15:06 +0200 Subject: [PATCH 1/8] 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. --- 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 ++++++ 11 files changed, 253 insertions(+), 9 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/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 4b836199a1534957cd9392cc49e2912c7f4afe10 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Tue, 30 Jun 2026 09:24:44 +0200 Subject: [PATCH 2/8] 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 b25162f3a9f334665592e5ed5dafa7a252a54ead Mon Sep 17 00:00:00 2001 From: pgherveou Date: Tue, 30 Jun 2026 09:24:45 +0200 Subject: [PATCH 3/8] feat(truapi-server): add Rust host runtime WASM host runtime that hosts implement: dispatcher, SCALE frames, subscription streams, chain runtime, host logic (sessions, SSO pairing, permissions, statement store, dotns) and the wasm bindings. Includes the committed generated dispatcher/wire-table under src/generated/. --- Cargo.lock | 3653 +++++++++++++++-- rust/crates/truapi-server/Cargo.toml | 66 + rust/crates/truapi-server/README.md | 33 + .../crates/truapi-server/src/chain_runtime.rs | 1590 +++++++ rust/crates/truapi-server/src/core.rs | 507 +++ rust/crates/truapi-server/src/dispatcher.rs | 272 ++ rust/crates/truapi-server/src/frame.rs | 433 ++ .../truapi-server/src/generated/dispatcher.rs | 2182 ++++++++++ .../crates/truapi-server/src/generated/mod.rs | 4 + .../truapi-server/src/generated/wire_table.rs | 746 ++++ rust/crates/truapi-server/src/host_core.rs | 217 + 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 +++ .../truapi-server/src/host_rpc_client.rs | 585 +++ rust/crates/truapi-server/src/lib.rs | 39 + 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/subscription.rs | 495 +++ rust/crates/truapi-server/src/test_support.rs | 1075 +++++ rust/crates/truapi-server/src/transport.rs | 12 + rust/crates/truapi-server/src/wasm.rs | 1158 ++++++ rust/crates/truapi-server/tests/common/mod.rs | 198 + .../truapi-server/tests/golden_frame.rs | 53 + .../tests/snapshots/golden-account-get.bin | Bin 0 -> 14 bytes .../tests/wasm_crypto_vectors.rs | 229 ++ .../truapi-server/tests/wire_result_shape.rs | 525 +++ .../tests/wire_table_ts_parity.rs | 228 + 46 files changed, 24760 insertions(+), 379 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/chain_runtime.rs create mode 100644 rust/crates/truapi-server/src/core.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/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 create mode 100644 rust/crates/truapi-server/src/host_core.rs 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/host_rpc_client.rs create mode 100644 rust/crates/truapi-server/src/lib.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/subscription.rs create mode 100644 rust/crates/truapi-server/src/test_support.rs create mode 100644 rust/crates/truapi-server/src/transport.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/golden_frame.rs create mode 100644 rust/crates/truapi-server/tests/snapshots/golden-account-get.bin create mode 100644 rust/crates/truapi-server/tests/wasm_crypto_vectors.rs create mode 100644 rust/crates/truapi-server/tests/wire_result_shape.rs create mode 100644 rust/crates/truapi-server/tests/wire_table_ts_parity.rs diff --git a/Cargo.lock b/Cargo.lock index 7a2c5a54..d0826169 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,6 +250,72 @@ 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.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + [[package]] name = "bitvec" version = "1.0.1" @@ -87,12 +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" @@ -139,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" @@ -160,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" @@ -179,113 +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 = "form_urlencoded" -version = "1.2.2" +name = "crunchy" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "percent-encoding", + "generic-array", + "rand_core", + "subtle", + "zeroize", ] [[package]] -name = "funty" -version = "2.0.0" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] [[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", @@ -293,569 +732,2973 @@ 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 = "hashbrown" -version = "0.17.0" +name = "derive_more-impl" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] [[package]] -name = "heck" -version = "0.5.0" +name = "digest" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] [[package]] -name = "hex" -version = "0.4.3" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", + "subtle", +] [[package]] -name = "icu_collections" -version = "2.2.0" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "icu_locale_core" -version = "2.2.0" +name = "downcast-rs" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +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", + "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 = "litemap" -version = "0.8.2" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "memchr" -version = "2.8.0" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] [[package]] -name = "parity-scale-codec" -version = "3.7.5" +name = "frame-metadata" +version = "23.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +checksum = "9ba5be0edbdb824843a0f9c6f0906ecfc66c5316218d74457003218b24909ed0" dependencies = [ - "arrayvec", - "bitvec", - "byte-slice-cast", - "const_format", - "impl-trait-for-tuples", - "parity-scale-codec-derive", - "rustversion", - "serde", + "cfg-if", + "parity-scale-codec", + "scale-info", ] [[package]] -name = "parity-scale-codec-derive" -version = "3.7.5" +name = "funty" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", ] [[package]] -name = "percent-encoding" -version = "2.3.2" +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_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.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", + "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 = "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", +] + +[[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 = "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" + +[[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 = "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 = "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", +] + +[[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", + "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", + "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", + "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 = "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 = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + +[[package]] +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", + "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", + "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", + "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 = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" +dependencies = [ + "cfg-if", + "digest 0.10.7", + "static_assertions", +] + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unicode-ident" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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" +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 = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "potential_utf" -version = "0.1.5" +name = "wasm-bindgen" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ - "zerovec", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "wasm-bindgen-futures" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ - "toml_edit", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "wasm-bindgen-macro" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ - "unicode-ident", + "quote", + "wasm-bindgen-macro-support", ] [[package]] -name = "quote" -version = "1.0.45" +name = "wasm-bindgen-macro-support" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ + "bumpalo", "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", ] [[package]] -name = "radium" -version = "0.7.0" +name = "wasm-bindgen-shared" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] [[package]] -name = "rustc_version" -version = "0.4.1" +name = "wasm-bindgen-test" +version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "af5ec93229ad9ccd0a545a516dec76dc276613f278f6a91aa6b463d5b33d42d0" dependencies = [ - "semver", + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "wasm-bindgen-test-macro" +version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "3c81b9fef827e575e0e54431736d1baa0d700315d8c62cfef1f61fa3aad0cbeb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "semver" -version = "1.0.28" +name = "wasm-bindgen-test-shared" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "4f4d8ae7ad5440360e9799dfd42857d126454a88441ddf72d288ef83fa47f527" [[package]] -name = "serde" -version = "1.0.228" +name = "wasmi" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "a19af97fcb96045dd1d6b4d23e2b4abdbbe81723dbc5c9f016eb52145b320063" dependencies = [ - "serde_core", - "serde_derive", + "arrayvec 0.7.6", + "multi-stash", + "smallvec", + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "wasmi_collections" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] +checksum = "e80d6b275b1c922021939d561574bf376613493ae2b61c6963b15db0e8813562" [[package]] -name = "serde_derive" -version = "1.0.228" +name = "wasmi_core" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "3a8c51482cc32d31c2c7ff211cd2bedd73c5bd057ba16a2ed0110e7a96097c33" dependencies = [ - "proc-macro2", - "quote", - "syn", + "downcast-rs", + "libm", ] [[package]] -name = "serde_json" -version = "1.0.149" +name = "wasmi_ir" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "6e431a14c186db59212a88516788bd68ed51f87aa1e08d1df742522867b5289a" dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "wasmi_core", ] [[package]] -name = "slab" -version = "0.4.12" +name = "wasmparser" +version = "0.221.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" +dependencies = [ + "bitflags", +] [[package]] -name = "smallvec" -version = "1.15.1" +name = "web-sys" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "stable_deref_trait" -version = "1.2.1" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "strsim" -version = "0.11.1" +name = "webpki-root-certs" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" +dependencies = [ + "webpki-root-certs 1.0.8", +] [[package]] -name = "syn" -version = "2.0.117" +name = "webpki-root-certs" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "rustls-pki-types", ] [[package]] -name = "synstructure" -version = "0.13.2" +name = "winapi-util" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-sys 0.61.2", ] [[package]] -name = "tap" -version = "1.0.1" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "tinystr" -version = "0.8.3" +name = "windows-sys" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "displaydoc", - "zerovec", + "windows-targets 0.42.2", ] [[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "serde_core", + "windows-targets 0.52.6", ] [[package]] -name = "toml_edit" -version = "0.25.11+spec-1.1.0" +name = "windows-sys" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", + "windows-targets 0.52.6", ] [[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "winnow", + "windows-link", ] [[package]] -name = "truapi" -version = "0.3.1" +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ - "derive_more", - "futures", - "hex", - "parity-scale-codec", - "truapi-macros", + "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-codegen" -version = "0.1.0" +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "anyhow", - "clap", - "convert_case 0.6.0", - "indoc", - "serde", - "serde_json", - "truapi", + "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 = "truapi-macros" -version = "0.1.0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" [[package]] -name = "truapi-platform" -version = "0.1.0" -dependencies = [ - "async-trait", - "derive_more", - "futures", - "parity-scale-codec", - "truapi", - "url", -] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "windows_aarch64_msvc" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" [[package]] -name = "unicode-segmentation" -version = "1.13.2" +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "windows_i686_gnu" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" [[package]] -name = "url" -version = "2.5.8" +name = "windows_i686_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] -name = "utf8_iter" -version = "1.0.4" +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "utf8parse" -version = "0.2.2" +name = "windows_i686_msvc" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" [[package]] -name = "windows-link" -version = "0.2.1" +name = "windows_i686_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "windows-sys" -version = "0.61.2" +name = "windows_x86_64_gnu" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] +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" @@ -881,6 +3724,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" @@ -904,6 +3759,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" @@ -925,6 +3800,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/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/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs new file mode 100644 index 00000000..bb8e2796 --- /dev/null +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -0,0 +1,1590 @@ +//! 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. + +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/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/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/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/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/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/host_rpc_client.rs b/rust/crates/truapi-server/src/host_rpc_client.rs new file mode 100644 index 00000000..7fab4c29 --- /dev/null +++ b/rust/crates/truapi-server/src/host_rpc_client.rs @@ -0,0 +1,585 @@ +//! `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. + +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 new file mode 100644 index 00000000..4597a94e --- /dev/null +++ b/rust/crates/truapi-server/src/lib.rs @@ -0,0 +1,39 @@ +//! 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. +//! +//! 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/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/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/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/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/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/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" + ); +} 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 1c8334aa2c69e350157a45bb24a58a2cd72ec6b2 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Tue, 30 Jun 2026 09:24:47 +0200 Subject: [PATCH 4/8] 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 | 234 +- 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 | 99 +- rust/crates/truapi-codegen/src/ts.rs | 394 +++- .../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 | 283 +++ scripts/codegen.sh | 14 +- 19 files changed, 8193 insertions(+), 74 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 diff --git a/Cargo.lock b/Cargo.lock index d0826169..f6aaa021 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -905,7 +905,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" dependencies = [ - "foldhash", + "foldhash 0.2.0", "libm", "portable-atomic", "siphasher", @@ -967,6 +967,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1134,6 +1140,19 @@ dependencies = [ "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" @@ -1210,6 +1229,15 @@ dependencies = [ "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" @@ -1218,7 +1246,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", "serde", "serde_core", ] @@ -1393,6 +1421,12 @@ dependencies = [ "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" @@ -1451,6 +1485,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] @@ -1673,6 +1709,12 @@ 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" @@ -2107,6 +2149,16 @@ 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" @@ -2155,6 +2207,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" @@ -2188,7 +2246,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -2208,7 +2266,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -2858,7 +2916,7 @@ dependencies = [ "futures", "futures-timer", "futures-util", - "getrandom", + "getrandom 0.2.17", "js-sys", "pin-project", "send_wrapper 0.6.0", @@ -2886,7 +2944,7 @@ dependencies = [ "finito", "frame-metadata", "futures", - "getrandom", + "getrandom 0.2.17", "hex", "impl-serde", "jsonrpsee", @@ -2929,6 +2987,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 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3156,6 +3227,7 @@ dependencies = [ "indoc", "serde", "serde_json", + "tempfile", "truapi", ] @@ -3193,7 +3265,7 @@ dependencies = [ "futures", "futures-timer", "futures-util", - "getrandom", + "getrandom 0.2.17", "hex", "hkdf", "js-sys", @@ -3346,6 +3418,24 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" 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 = [ + "wit-bindgen 0.57.1", +] + +[[package]] +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 = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + [[package]] name = "wasm-bindgen" version = "0.2.121" @@ -3440,6 +3530,28 @@ version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" 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" @@ -3453,7 +3565,7 @@ dependencies = [ "wasmi_collections", "wasmi_core", "wasmi_ir", - "wasmparser", + "wasmparser 0.221.3", ] [[package]] @@ -3490,6 +3602,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.98" @@ -3709,6 +3833,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" 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 2e8aa459..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 @@ -294,6 +316,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 +418,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 { @@ -424,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() @@ -436,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('_') @@ -844,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())); } @@ -985,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, @@ -1086,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, @@ -1288,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 = @@ -1310,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 eeec62a1..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() { @@ -1901,6 +2094,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) @@ -1963,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()), @@ -1975,6 +2174,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) @@ -2040,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), @@ -2051,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(_)); @@ -2061,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), @@ -2282,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 { @@ -2651,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..b26d9e6e --- /dev/null +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -0,0 +1,283 @@ +//! 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 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 +/// 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/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 b137a87df35c186ee97dced794655f8f97d55500 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Tue, 30 Jun 2026 09:25:44 +0200 Subject: [PATCH 5/8] feat(host-wasm): add @parity/truapi-host-wasm runtime New WASM-backed host runtime package embedding the Rust core, with web iframe and Web Worker entry points. Updates the @parity/truapi client (SCALE, sandbox, transport) and drops the obsolete explorer 0.3.2 codegen snapshot. --- .gitignore | 19 + .prettierrc | 5 +- js/packages/truapi-host-wasm/.gitignore | 11 + js/packages/truapi-host-wasm/README.md | 64 + js/packages/truapi-host-wasm/package.json | 49 + .../truapi-host-wasm/scripts/build-wasm.mjs | 63 + .../truapi-host-wasm/src/adapter-support.ts | 125 + js/packages/truapi-host-wasm/src/error.ts | 6 + .../src/host-callbacks-adapter.test.ts | 355 ++ js/packages/truapi-host-wasm/src/index.ts | 29 + js/packages/truapi-host-wasm/src/runtime.ts | 95 + .../truapi-host-wasm/src/test-support.ts | 40 + .../src/web/create-iframe-host.test.ts | 215 + .../src/web/create-iframe-host.ts | 147 + .../src/web/create-worker-host-runtime.ts | 780 ++++ js/packages/truapi-host-wasm/src/web/index.ts | 7 + .../src/web/worker-provider.test.ts | 658 +++ .../truapi-host-wasm/src/worker-protocol.ts | 160 + .../truapi-host-wasm/src/worker-runtime.ts | 433 ++ js/packages/truapi-host-wasm/tsconfig.json | 20 + js/packages/truapi/package.json | 2 +- js/packages/truapi/src/client.test.ts | 142 +- js/packages/truapi/src/client.ts | 32 +- .../codegen/versions/0.3.2/services.ts | 724 ---- .../explorer/codegen/versions/0.3.2/types.ts | 3836 ----------------- js/packages/truapi/src/sandbox.ts | 118 +- js/packages/truapi/src/scale.ts | 56 +- js/packages/truapi/src/transport.ts | 11 +- js/packages/truapi/tsconfig.json | 1 + package-lock.json | 31 + playground/tests/e2e/testing.spec.ts | 40 + 31 files changed, 3613 insertions(+), 4661 deletions(-) create mode 100644 js/packages/truapi-host-wasm/.gitignore create mode 100644 js/packages/truapi-host-wasm/README.md create mode 100644 js/packages/truapi-host-wasm/package.json create mode 100644 js/packages/truapi-host-wasm/scripts/build-wasm.mjs create mode 100644 js/packages/truapi-host-wasm/src/adapter-support.ts create mode 100644 js/packages/truapi-host-wasm/src/error.ts create mode 100644 js/packages/truapi-host-wasm/src/host-callbacks-adapter.test.ts create mode 100644 js/packages/truapi-host-wasm/src/index.ts create mode 100644 js/packages/truapi-host-wasm/src/runtime.ts create mode 100644 js/packages/truapi-host-wasm/src/test-support.ts create mode 100644 js/packages/truapi-host-wasm/src/web/create-iframe-host.test.ts create mode 100644 js/packages/truapi-host-wasm/src/web/create-iframe-host.ts create mode 100644 js/packages/truapi-host-wasm/src/web/create-worker-host-runtime.ts create mode 100644 js/packages/truapi-host-wasm/src/web/index.ts create mode 100644 js/packages/truapi-host-wasm/src/web/worker-provider.test.ts create mode 100644 js/packages/truapi-host-wasm/src/worker-protocol.ts create mode 100644 js/packages/truapi-host-wasm/src/worker-runtime.ts create mode 100644 js/packages/truapi-host-wasm/tsconfig.json delete mode 100644 js/packages/truapi/src/explorer/codegen/versions/0.3.2/services.ts delete mode 100644 js/packages/truapi/src/explorer/codegen/versions/0.3.2/types.ts create mode 100644 playground/tests/e2e/testing.spec.ts diff --git a/.gitignore b/.gitignore index 9138e1a8..620568d2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,12 @@ lerna-debug.log* node_modules target +# Gradle (Android workspace at repo root) +/.gradle/ +/build/ +/android/*/build/ +local.properties + # Environment / secrets (never commit real env files; keep example templates) .env .env.* @@ -39,3 +45,16 @@ playground/public/static.files # Auto-generated by truapi-codegen (typecheck fixtures for rustdoc ts blocks) playground/test/generated/ + +# Auto-generated FFI / WASM binding outputs +android/truapi-host/src/main/kotlin/generated/ +ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +ios/truapi-host/Sources/truapi_serverFFI/ +rust/crates/truapi-server/pkg/ +js/packages/truapi/src/generated/ +js/packages/truapi/dist/generated/ +js/packages/truapi-host/src/generated/ +js/packages/truapi-host/dist/generated/ +js/packages/truapi-host-wasm/src/generated/ +js/packages/truapi-host-wasm/dist/generated/ +js/packages/truapi-host-wasm/dist/wasm/ diff --git a/.prettierrc b/.prettierrc index 8224a16a..7d4a0046 100644 --- a/.prettierrc +++ b/.prettierrc @@ -10,7 +10,10 @@ "endOfLine": "lf", "overrides": [ { - "files": "js/packages/truapi/src/**/*.test.ts", + "files": [ + "js/packages/truapi/src/**/*.test.ts", + "js/packages/truapi-host-wasm/src/**/*.test.ts" + ], "options": { "tabWidth": 4, "printWidth": 100 diff --git a/js/packages/truapi-host-wasm/.gitignore b/js/packages/truapi-host-wasm/.gitignore new file mode 100644 index 00000000..288deac9 --- /dev/null +++ b/js/packages/truapi-host-wasm/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +*.tsbuildinfo +# Ignore compiled TS output (top-level + the web/ and electron/ entry subdirs) +# Generated WASM artifacts under dist/wasm/ are ignored by the repo root. +dist/**/*.js +dist/**/*.d.ts +dist/**/*.js.map +dist/**/*.d.ts.map +dist/generated/ +# Codegen output from truapi-codegen --platform-ts-output. +src/generated/ diff --git a/js/packages/truapi-host-wasm/README.md b/js/packages/truapi-host-wasm/README.md new file mode 100644 index 00000000..c80822c1 --- /dev/null +++ b/js/packages/truapi-host-wasm/README.md @@ -0,0 +1,64 @@ +# @parity/truapi-host-wasm + +WASM-backed TrUAPI host runtime. It embeds the `truapi-server` Rust core (compiled to WASM) +behind a Web Worker provider, plus per-environment integration entry points. It is the +counterpart to the native Android/iOS host shells. + +## Entry points + +The package exposes tree-shakeable subpath exports — import only what your environment needs: + +| Import | Provides | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `@parity/truapi-host-wasm` | Shared runtime types plus generated typed host callback contracts. | +| `@parity/truapi-host-wasm/web` | Browser host: `createIframeHost` (iframe MessageChannel handshake) and `createWebWorkerProvider`. | +| `@parity/truapi-host-wasm/worker-runtime` | Web Worker entrypoint (import with your bundler's `?worker` suffix) so the WASM core runs off the page main thread. | +| `@parity/truapi-host-wasm/wasm/web` | The raw browser `wasm-bindgen` glue, if you need to instantiate the core yourself. | + +## Generated WASM artefacts + +The ignored bundle under `dist/wasm/web/` is built with host-owned chain access. +Hosts wire their JSON-RPC provider through `chainConnect`; if they omit it, +chain calls fail with the core's standard unavailable error. The bundled WASM is +about 1 MB (release build with `wasm-opt`). + +Build them after editing `rust/crates/truapi-server` and before packaging, publishing, or running +tests that load the raw WASM bundle (requires `wasm-pack` on PATH): + +```bash +npm run build:wasm # or `make wasm` from the repo root +``` + +## Example — browser (Web Worker) + +```ts +import HostWorker from "@parity/truapi-host-wasm/worker-runtime?worker"; +import { createWebWorkerProvider } from "@parity/truapi-host-wasm/web"; + +const provider = await createWebWorkerProvider(new HostWorker(), callbacks, { + runtimeConfig, +}); +``` + +`@parity/truapi-host-wasm/web` also exports `createIframeHost` for the protocol-iframe +MessageChannel handshake. + +## Publishing + +The npm publish workflow is not wired yet. A release-process discussion is needed before adding a +publish job to `.github/workflows/`. Until then, consumers depend on the package via the workspace +`file:` link or by publishing locally with `npm pack`. + +## Architecture + +```text +JS host code + protocol handlers / typed callbacks + (types from @parity/truapi-host-wasm) + | + v +createWebWorkerProvider + | + v + truapi-server WASM core +``` diff --git a/js/packages/truapi-host-wasm/package.json b/js/packages/truapi-host-wasm/package.json new file mode 100644 index 00000000..c9125f87 --- /dev/null +++ b/js/packages/truapi-host-wasm/package.json @@ -0,0 +1,49 @@ +{ + "name": "@parity/truapi-host-wasm", + "version": "0.1.0", + "description": "WASM-backed TrUAPI host runtime: embeds the Rust core, with web iframe and Web Worker entry points", + "license": "MIT", + "author": "Parity Technologies ", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "sideEffects": [ + "./dist/worker-runtime.js", + "./dist/wasm/**" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./web": { + "types": "./dist/web/index.d.ts", + "import": "./dist/web/index.js" + }, + "./worker-runtime": { + "types": "./dist/worker-runtime.d.ts", + "import": "./dist/worker-runtime.js" + }, + "./wasm/web": { + "types": "./dist/wasm/web/truapi_server.d.ts", + "import": "./dist/wasm/web/truapi_server.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -b", + "build:wasm": "node scripts/build-wasm.mjs", + "test": "bun test" + }, + "dependencies": { + "@parity/truapi": "file:../truapi" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "neverthrow": "^8.2.0", + "typescript": "^5.7" + } +} diff --git a/js/packages/truapi-host-wasm/scripts/build-wasm.mjs b/js/packages/truapi-host-wasm/scripts/build-wasm.mjs new file mode 100644 index 00000000..97583e4a --- /dev/null +++ b/js/packages/truapi-host-wasm/scripts/build-wasm.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// Rebuild the browser truapi-server WASM artefacts generated under +// `dist/wasm/web/`. wasm-pack is required. + +import { execFile } from "node:child_process"; +import { rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pkgRoot = resolve(__dirname, ".."); +const repoRoot = resolve(pkgRoot, "../../.."); +const rustCrate = resolve(repoRoot, "rust/crates/truapi-server"); +const wasmProfile = process.env.TRUAPI_WASM_PROFILE ?? "release"; + +function args(target, outDir) { + const command = [ + "build", + "--target", + target, + "--out-dir", + outDir, + "--out-name", + "truapi_server", + ]; + if (wasmProfile === "dev") { + command.push("--dev"); + } else if (wasmProfile === "profiling") { + command.push("--profiling"); + } else if (wasmProfile !== "release") { + throw new Error( + `Unsupported TRUAPI_WASM_PROFILE=${wasmProfile}; expected release, dev, or profiling`, + ); + } + command.push(rustCrate, "--no-default-features"); + return command; +} + +async function build(target, subdir) { + const outDir = resolve(pkgRoot, "dist/wasm", subdir); + process.stdout.write( + `wasm-pack build --target ${target} --${wasmProfile} → ${outDir}\n`, + ); + try { + await execFileAsync("wasm-pack", args(target, outDir), { cwd: repoRoot }); + } catch (err) { + if (err?.code === "ENOENT") { + console.error( + "wasm-pack is required. Install it with `cargo install wasm-pack` " + + "or see https://rustwasm.github.io/wasm-pack/installer/", + ); + process.exit(1); + } + throw err; + } + // wasm-pack writes a nested `.gitignore: *`; the repo-level ignore already + // owns generated WASM outputs. + await rm(resolve(outDir, ".gitignore"), { force: true }); +} + +await build("web", "web"); diff --git a/js/packages/truapi-host-wasm/src/adapter-support.ts b/js/packages/truapi-host-wasm/src/adapter-support.ts new file mode 100644 index 00000000..ec4a2052 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/adapter-support.ts @@ -0,0 +1,125 @@ +// Hand-written runtime support for the generated `createWasmRawCallbacks` +// adapter (`./generated/host-callbacks-adapter.ts`). The adapter is mechanical +// (decode params, call the typed host callback, read the result); the pieces +// here are the genuinely bespoke runtime plumbing it leans on: stream driving +// and the chain-connection handle. + +import { type GenericError, type Result } from "@parity/truapi"; +import { hexToBytes } from "@parity/truapi/scale"; + +import type { ChainConnect, ChainConnection } from "./runtime.js"; +import type { HostCallbacks } from "./generated/host-callbacks.js"; + +type WireResult = + | { success: true; value: T } + | { success: false; value: E }; + +type StreamResult = Result | WireResult; + +type MaybeAsyncIterable = AsyncIterable | Iterable; + +/** + * Normalize both generated `Result` values and the plain + * `{ success, value }` envelope used by some JS fixtures into a raw item. + */ +function unwrapStreamResult(item: StreamResult): T { + if ("success" in item) { + if (item.success === false) { + throw new Error(item.value.reason); + } + return item.value; + } + if (item.isErr()) { + throw new Error(item.error.reason); + } + return item.value; +} + +/** + * Accept sync and async host streams behind one async-iterator interface. + * Host callbacks often use async iterables in production, while tests can use + * small synchronous fixtures without a custom wrapper. + */ +function toAsyncIterator(stream: MaybeAsyncIterable): AsyncIterator { + const asyncIterable = stream as AsyncIterable; + if (typeof asyncIterable[Symbol.asyncIterator] === "function") { + return asyncIterable[Symbol.asyncIterator](); + } + const iterator = (stream as Iterable)[Symbol.iterator](); + const asyncIterator: AsyncIterator = { + next: async () => iterator.next(), + }; + if (iterator.return) { + asyncIterator.return = async () => iterator.return!(); + } + return asyncIterator; +} + +/** + * Drain an async iterator into a sink until disposed. This is used for + * callback streams where the Rust core owns cancellation but JS owns the + * iterator and any transport cleanup behind `return()`. + */ +function pumpIterator( + iterator: AsyncIterator, + onItem: (value: T) => void, + label: string, +): () => void { + let stopped = false; + void (async () => { + try { + while (!stopped) { + const next = await iterator.next(); + if (next.done) return; + onItem(next.value); + } + } catch (err) { + console.error(`[truapi host callbacks] ${label} failed:`, err); + } + })(); + return () => { + stopped = true; + void iterator.return?.(); + }; +} + +/** + * Drive a typed host stream of `Result` items into the core's `sendItem` + * sink, unwrapping each `Result` (or throwing on its error). Returns a + * disposer that stops iteration. + */ +export function driveResultStream( + stream: MaybeAsyncIterable>, + sendItem: (value: T) => void, +): () => void { + return pumpIterator( + toAsyncIterator(stream), + (value) => sendItem(unwrapStreamResult(value)), + "subscription", + ); +} + +/** + * Bridge the typed `ChainProvider.connect` callback onto the raw + * `chainConnect` the WASM core invokes: decode the genesis hash, pump the + * connection's `responses()` stream into `onResponse`, and expose + * `send`/`close`. + */ +export function chainConnectAdapter( + host: Pick, +): ChainConnect { + return async (genesisHash, onResponse): Promise => { + const connection = await host.connect(hexToBytes(genesisHash)); + const iterator = connection.responses()[Symbol.asyncIterator](); + const stopResponses = pumpIterator(iterator, onResponse, "chain responses"); + return { + send(request: string): void { + connection.send(request); + }, + close(): void { + stopResponses(); + connection.close(); + }, + }; + }; +} diff --git a/js/packages/truapi-host-wasm/src/error.ts b/js/packages/truapi-host-wasm/src/error.ts new file mode 100644 index 00000000..5dc5674e --- /dev/null +++ b/js/packages/truapi-host-wasm/src/error.ts @@ -0,0 +1,6 @@ +/** Coerce an unknown thrown value into a human-readable message string. */ +export function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + return JSON.stringify(err) ?? String(err); +} diff --git a/js/packages/truapi-host-wasm/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host-wasm/src/host-callbacks-adapter.test.ts new file mode 100644 index 00000000..2a2c2740 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/host-callbacks-adapter.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, it } from "bun:test"; +import { ok } from "neverthrow"; + +import { + HostDevicePermissionRequest, + HostDevicePermissionResponse, + HostFeatureSupportedRequest, + HostFeatureSupportedResponse, + HostPushNotificationRequest, + HostPushNotificationResponse, + RemotePermissionRequest, + RemotePermissionResponse, + ThemeVariant, +} from "@parity/truapi"; +import type { HostSignPayloadData } from "@parity/truapi"; + +import { createWasmRawCallbacks } from "./generated/host-callbacks-adapter.js"; +import { CoreStorageKey, UserConfirmationReview } from "./generated/host-callbacks.js"; +import { makeHostCallbacks, settle } from "./test-support.js"; + +// The generated `createWasmRawCallbacks` adapter speaks the symmetric SCALE +// byte boundary: codec-typed requests arrive as `Uint8Array` and are decoded +// for the typed host callback; codec-typed responses are SCALE-encoded back to +// `Uint8Array`. Primitives, strings, byte blobs and the local `AuthState` pass +// through unchanged. + +const GENESIS = `0x${"11".repeat(32)}` as `0x${string}`; +const PRODUCT_ACCOUNT = { + dotNsIdentifier: "playground.dot", + derivationIndex: 0, +}; +const SIGN_PAYLOAD: HostSignPayloadData = { + blockHash: GENESIS, + blockNumber: "0x01", + era: "0x00", + genesisHash: GENESIS, + method: "0x0102", + nonce: "0x00", + specVersion: "0x01", + tip: "0x00", + transactionVersion: "0x01", + signedExtensions: [], + version: 4, + assetId: undefined, + metadataHash: undefined, + mode: undefined, +}; + +describe("createWasmRawCallbacks", () => { + it("decodes requests and encodes typed responses", async () => { + const writes: [string, number[]][] = []; + const clears: string[] = []; + const cancelled: number[] = []; + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + pushNotification: async (notification) => ({ + id: notification.text.length, + }), + cancelNotification: async (id) => { + cancelled.push(id); + }, + devicePermission: async (request) => ({ + granted: request === "Camera", + }), + remotePermission: async (request) => ({ + granted: request.permission.tag === "ChainSubmit", + }), + featureSupported: async (request) => ({ + supported: request.tag === "Chain" && request.value.genesisHash === GENESIS, + }), + read: async (key) => new TextEncoder().encode(`read:${key}`), + write: async (key, value) => { + writes.push([key, [...value]]); + }, + clear: async (key) => { + clears.push(key); + }, + }), + ); + + expect( + HostPushNotificationResponse.dec( + await raw.pushNotification!( + HostPushNotificationRequest.enc({ + text: "hello", + deeplink: undefined, + scheduledAt: undefined, + }), + ), + ).id, + ).toBe(5); + expect( + HostDevicePermissionResponse.dec( + await raw.devicePermission!(HostDevicePermissionRequest.enc("Camera")), + ).granted, + ).toBe(true); + expect( + RemotePermissionResponse.dec( + await raw.remotePermission!( + RemotePermissionRequest.enc({ + permission: { tag: "ChainSubmit" }, + }), + ), + ).granted, + ).toBe(true); + expect( + HostFeatureSupportedResponse.dec( + await raw.featureSupported!( + HostFeatureSupportedRequest.enc({ + tag: "Chain", + value: { genesisHash: GENESIS }, + }), + ), + ).supported, + ).toBe(true); + expect(await raw.read!("session")).toEqual(new TextEncoder().encode("read:session")); + + await raw.write!("session", new Uint8Array([1, 2, 3])); + await raw.clear!("session"); + await raw.cancelNotification?.(9); + + expect(writes).toEqual([["session", [1, 2, 3]]]); + expect(clears).toEqual(["session"]); + expect(cancelled).toEqual([9]); + }); + + it("bridges lifecycle, confirmations, and preimage callbacks", async () => { + const calls: unknown[][] = []; + async function* preimages() { + yield ok(undefined); + yield ok(new Uint8Array([4, 5, 6])); + } + + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + authStateChanged: (state) => { + calls.push(["authStateChanged", state]); + }, + readCoreStorage: async (key) => + key.tag === "AuthSession" ? new Uint8Array([1, 2, 3]) : undefined, + writeCoreStorage: async (key, value) => { + calls.push(["writeCoreStorage", key, [...value]]); + }, + clearCoreStorage: async (key) => { + calls.push(["clearCoreStorage", key]); + }, + confirmUserAction: async (review) => { + switch (review.tag) { + case "SignPayload": + return ( + review.value.tag === "Product" && + review.value.value.account.dotNsIdentifier === "playground.dot" && + review.value.value.payload.method === "0x0102" + ); + case "SignRaw": + return ( + review.value.tag === "Product" && + review.value.value.payload.tag === "Bytes" && + review.value.value.payload.value.bytes === "0x0304" + ); + case "CreateTransaction": + return ( + review.value.tag === "Product" && + review.value.value.signer.derivationIndex === 0 && + review.value.value.callData === "0x0506" + ); + case "AccountAlias": + return ( + review.value.requestingProductId === "playground.dot" && + review.value.targetProductId === "wallet.dot" + ); + case "ResourceAllocation": + return review.value.resources[0]?.tag === "StatementStoreAllowance"; + case "PreimageSubmit": + calls.push(["confirmUserAction:PreimageSubmit", review.value.size]); + return review.value.size === 42n; + } + }, + submitPreimage: async (value) => { + calls.push(["submitPreimage", [...value]]); + return new Uint8Array([7, 8, 9]); + }, + lookupPreimage: (key) => { + calls.push(["lookupPreimage", [...key]]); + return preimages(); + }, + }), + ); + + const preimageEvents: (number[] | null)[] = []; + const disposePreimages = raw.lookupPreimage!(new Uint8Array([9]), (value) => + preimageEvents.push(value ? [...value] : null), + ); + + raw.authStateChanged?.({ + tag: "Pairing", + value: { deeplink: "polkadotapp://example" }, + }); + const authSessionKey = CoreStorageKey.enc({ tag: "AuthSession" }); + expect(await raw.readCoreStorage!(authSessionKey)).toEqual(new Uint8Array([1, 2, 3])); + await raw.writeCoreStorage!(authSessionKey, new Uint8Array([3, 2, 1])); + await raw.clearCoreStorage!(authSessionKey); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "SignPayload", + value: { + tag: "Product", + value: { + account: PRODUCT_ACCOUNT, + payload: SIGN_PAYLOAD, + }, + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "SignRaw", + value: { + tag: "Product", + value: { + account: PRODUCT_ACCOUNT, + payload: { + tag: "Bytes", + value: { bytes: "0x0304" }, + }, + }, + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "CreateTransaction", + value: { + tag: "Product", + value: { + signer: PRODUCT_ACCOUNT, + genesisHash: GENESIS, + callData: "0x0506", + extensions: [], + txExtVersion: 0, + }, + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "AccountAlias", + value: { + requestingProductId: "playground.dot", + targetProductId: "wallet.dot", + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "ResourceAllocation", + value: { + resources: [{ tag: "StatementStoreAllowance" }], + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "PreimageSubmit", + value: { size: 42n }, + }), + ), + ).toBe(true); + expect(await raw.submitPreimage!(new Uint8Array([6]))).toEqual(new Uint8Array([7, 8, 9])); + + await settle(); + await settle(); + + expect(preimageEvents).toEqual([null, [4, 5, 6]]); + expect(calls).toEqual([ + ["lookupPreimage", [9]], + ["authStateChanged", { tag: "Pairing", value: { deeplink: "polkadotapp://example" } }], + ["writeCoreStorage", { tag: "AuthSession", value: undefined }, [3, 2, 1]], + ["clearCoreStorage", { tag: "AuthSession", value: undefined }], + ["confirmUserAction:PreimageSubmit", 42n], + ["submitPreimage", [6]], + ]); + + disposePreimages?.(); + }); + + it("adapts typed result subscriptions", async () => { + async function* themes() { + yield ok("Dark"); + yield ok("Light"); + } + + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + subscribeTheme: () => themes(), + }), + ); + const seen: ThemeVariant[] = []; + const dispose = raw.subscribeTheme?.((theme) => seen.push(ThemeVariant.dec(theme!))); + + await settle(); + await settle(); + + expect(seen).toEqual(["Dark", "Light"]); + dispose?.(); + }); + + it("bridges typed chain connections", async () => { + const sent: string[] = []; + const responses = ['{"jsonrpc":"2.0","id":1,"result":"ok"}']; + let closes = 0; + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + connect: async (genesisHash) => { + expect([...genesisHash]).toEqual(Array(32).fill(0x11)); + return { + send(request) { + sent.push(request); + }, + async *responses() { + yield* responses; + }, + close() { + closes += 1; + }, + }; + }, + }), + ); + + expect(typeof raw.chainConnect).toBe("function"); + const received: string[] = []; + const connection = await raw.chainConnect!(GENESIS, (json) => received.push(json)); + expect(connection).toBeTruthy(); + + connection!.send('{"jsonrpc":"2.0","id":1,"method":"system_health"}'); + await settle(); + + expect(sent).toEqual(['{"jsonrpc":"2.0","id":1,"method":"system_health"}']); + expect(received).toEqual(responses); + connection!.close(); + expect(closes).toBe(1); + }); +}); diff --git a/js/packages/truapi-host-wasm/src/index.ts b/js/packages/truapi-host-wasm/src/index.ts new file mode 100644 index 00000000..6236052a --- /dev/null +++ b/js/packages/truapi-host-wasm/src/index.ts @@ -0,0 +1,29 @@ +export type { Payload, ProtocolMessage, WireProvider } from "@parity/truapi"; + +export { encodeCoreStorageKey } from "./runtime.js"; + +export type { + AuthState, + Awaitable, + ChainConnect, + ChainConnection, + ChainProvider, + CoreAdmin, + CoreStorage, + CoreStorageKey, + Features, + HostCallbacks, + LogLevel, + Navigation, + Notifications, + PermissionAuthorizationRequest, + PermissionAuthorizationStatus, + Permissions, + PreimageHost, + ProductStorage, + PlatformJsonRpcConnection, + SessionUiInfo, + ThemeHost, + TrUApiHostCoreProvider, + HostCoreRuntimeConfig, +} from "./runtime.js"; diff --git a/js/packages/truapi-host-wasm/src/runtime.ts b/js/packages/truapi-host-wasm/src/runtime.ts new file mode 100644 index 00000000..89f93cf1 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/runtime.ts @@ -0,0 +1,95 @@ +import type { WireProvider } from "@parity/truapi"; +import { CoreStorageKey as GeneratedCoreStorageKey } from "./generated/host-callbacks.js"; +import type { CoreAdmin, CoreStorageKey } from "./generated/host-callbacks.js"; + +// The typed capability interfaces below come straight from the +// `truapi-platform` Rust crate via `truapi-codegen --platform-ts-output`. +// They are the host-author-facing surface: each method takes/returns +// typed wrappers (`HostDevicePermissionRequest`, etc.) rather than raw +// SCALE bytes. `createWebWorkerProvider` adapts this typed surface into +// the byte-oriented callback bridge consumed by the WASM core. +export type { + AuthState, + ChainProvider, + CoreAdmin, + CoreStorage, + CoreStorageKey, + Features, + HostCallbacks, + JsonRpcConnection as PlatformJsonRpcConnection, + Navigation, + Notifications, + PermissionAuthorizationRequest, + PermissionAuthorizationStatus, + Permissions, + PreimageHost, + ProductStorage, + SessionUiInfo, + ThemeHost, +} from "./generated/host-callbacks.js"; + +/** Encode a typed core-storage slot for hosts that need an opaque backing key. */ +export function encodeCoreStorageKey(key: CoreStorageKey): Uint8Array { + return GeneratedCoreStorageKey.enc(key); +} + +/** + * Async-or-sync return. Synchronous hosts (e.g. the dotli main-thread + * shell hitting localStorage) can return a plain value; the WASM bridge + * awaits every return so an `async` impl also works. + */ +export type Awaitable = T | Promise; + +/** + * Open a JSON-RPC connection for `genesisHash`. The wasm bridge passes + * `onResponse` so the host can push JSON-RPC replies back asynchronously. + * Returning `null` (or throwing) tells the core no provider is available. + */ +export type ChainConnect = ( + genesisHash: string, + onResponse: (json: string) => void, +) => Awaitable; + +/** + * Per-connection handle returned by `chainConnect`. `send` forwards a + * SCALE-encoded JSON-RPC request; `close` tears the connection down. + */ +export interface ChainConnection { + send(request: string): void; + close(): void; +} + +/** + * Verbosity threshold for the wasm core's `tracing` output. The Rust core + * parses the string; known values are `off`, `error`, `warn`, `info`, `debug`, + * and `trace`. + */ +export type LogLevel = string; + +export interface HostCoreRuntimeConfig { + productId: string; + host: { + name: string; + icon?: string; + version?: string; + }; + platform?: { + type?: string; + version?: string; + }; + people: { + genesisHash: string | Uint8Array; + }; + pairing: { + deeplinkScheme: string; + }; +} + +export interface TrUApiHostCoreProvider extends WireProvider, CoreAdmin { + /** + * Re-tune the wasm core's log level at runtime. Present on runtimes that + * keep a live channel to the core (e.g. the Web Worker provider); absent on + * one-shot constructions that only accept `logLevel` up front. + */ + setLogLevel?(level: LogLevel): void; +} diff --git a/js/packages/truapi-host-wasm/src/test-support.ts b/js/packages/truapi-host-wasm/src/test-support.ts new file mode 100644 index 00000000..7f5c5cde --- /dev/null +++ b/js/packages/truapi-host-wasm/src/test-support.ts @@ -0,0 +1,40 @@ +import type { HostCallbacks } from "./generated/host-callbacks.js"; + +/** `HostCallbacks` with every optional member required, for exhaustive test fixtures. */ +export type CompleteHostCallbacks = Required; + +/** Default no-op host callbacks with optional per-test overrides. */ +export function makeHostCallbacks( + overrides: Partial = {}, +): CompleteHostCallbacks { + return { + navigateTo: async () => {}, + pushNotification: async () => ({ id: 0 }), + cancelNotification: async () => {}, + devicePermission: async () => ({ granted: false }), + remotePermission: async () => ({ granted: false }), + featureSupported: async () => ({ supported: false }), + readCoreStorage: async () => undefined, + writeCoreStorage: async () => {}, + clearCoreStorage: async () => {}, + read: async () => undefined, + write: async () => {}, + clear: async () => {}, + authStateChanged: () => {}, + confirmUserAction: async () => false, + submitPreimage: async () => new Uint8Array(), + async *lookupPreimage() {}, + async *subscribeTheme() {}, + connect: async () => ({ + send() {}, + async *responses() {}, + close() {}, + }), + ...overrides, + }; +} + +/** Resolve after the current microtask/immediate queue, letting pending async work run. */ +export function settle(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} diff --git a/js/packages/truapi-host-wasm/src/web/create-iframe-host.test.ts b/js/packages/truapi-host-wasm/src/web/create-iframe-host.test.ts new file mode 100644 index 00000000..9a9108bc --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/create-iframe-host.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; + +import { createIframeHost } from "./index.js"; + +// Verify that `createIframeHost` hands a MessagePort back through `onPort`, +// constructs an iframe with the expected attributes, and posts the +// `truapi-init` handshake after the iframe reports readiness. + +function setupFakeDom() { + // Track listeners on the synthetic `window` and the iframe so the + // test can simulate the iframe `load` event after construction. + const iframeListeners = new Map void>(); + const windowListeners = new Map void>(); + const windowRemove = mock((_name: string, _fn: unknown) => {}); + const contentPostMessage = mock((_body: unknown, _origin: string) => {}); + + const contentWindow = { + postMessage: contentPostMessage, + }; + + const iframe = { + style: {} as Record, + setAttribute: mock((_name: string, _value: string) => {}), + addEventListener: (name: string, fn: (event: unknown) => void) => { + iframeListeners.set(name, fn); + }, + removeEventListener: () => {}, + remove: mock(() => {}), + referrerPolicy: "", + credentialless: false, + allow: "", + src: "", + contentWindow, + }; + + const container = { + appendChild: mock((_child: unknown) => {}), + }; + + // Spy on both MessageChannel ports so dispose() teardown is observable. + const port1 = { postMessage: mock(() => {}), close: mock(() => {}) }; + const port2 = { postMessage: mock(() => {}), close: mock(() => {}) }; + globalThis.MessageChannel = class { + port1 = port1; + port2 = port2; + } as unknown as typeof MessageChannel; + + globalThis.document = { + createElement: (tag: string) => { + expect(tag).toBe("iframe"); + return iframe as unknown as HTMLIFrameElement; + }, + } as unknown as Document; + globalThis.window = { + location: { href: "http://localhost:5174/" }, + addEventListener: (name: string, fn: (event: unknown) => void) => { + windowListeners.set(name, fn); + }, + removeEventListener: windowRemove, + } as unknown as Window & typeof globalThis; + + return { + iframe, + container, + contentPostMessage, + contentWindow, + iframeListeners, + windowListeners, + windowRemove, + port1, + port2, + }; +} + +function teardownFakeDom() { + delete (globalThis as { document?: unknown }).document; + delete (globalThis as { window?: unknown }).window; + delete (globalThis as { MessageChannel?: unknown }).MessageChannel; +} + +describe("createIframeHost", () => { + let dom: ReturnType; + + beforeEach(() => { + dom = setupFakeDom(); + }); + + afterEach(() => { + teardownFakeDom(); + }); + + it("hands back a MessagePort and configures the iframe", () => { + const { iframe, container, iframeListeners, windowRemove, port1, port2 } = dom; + + let receivedPort: MessagePort | null = null; + const host = createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: container as unknown as HTMLElement, + allow: "camera; cross-origin-isolated", + onPort: (port) => { + receivedPort = port; + }, + }); + + expect(receivedPort).toBeTruthy(); + expect(typeof receivedPort!.postMessage).toBe("function"); + expect(container.appendChild.mock.calls.length).toBe(1); + expect(host.iframe).toBe(iframe as unknown as HTMLIFrameElement); + expect(iframe.credentialless).toBe(true); + expect(iframe.allow).toBe("camera; cross-origin-isolated"); + expect(iframe.src).toBe("http://localhost:5174/"); + // port transfer waits for explicit iframe readiness + expect(iframeListeners.has("load")).toBe(false); + + host.dispose(); + expect(iframe.remove.mock.calls.length).toBe(1); + // dispose removes the window message listener + expect(windowRemove.mock.calls.length).toBe(1); + expect(windowRemove.mock.calls[0][0]).toBe("message"); + // host + product ports closed on dispose + expect(port1.close.mock.calls.length).toBe(1); + expect(port2.close.mock.calls.length).toBe(1); + }); + + it("sends truapi-init on a same-origin product-ready message", () => { + const { contentPostMessage, windowListeners, contentWindow } = dom; + + createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: { appendChild: () => {} } as unknown as HTMLElement, + onPort: () => {}, + }); + + const onMessage = windowListeners.get("message"); + expect(onMessage).toBeTruthy(); + + // Wrong source is dropped. + onMessage!({ + source: { other: true }, + origin: "http://localhost:5174", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(0); + + // Wrong origin is dropped. + onMessage!({ + source: contentWindow, + origin: "http://evil.example", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(0); + + // Correct source + origin triggers the init handshake. + onMessage!({ + source: contentWindow, + origin: "http://localhost:5174", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(1); + const [body, origin] = contentPostMessage.mock.calls[0]; + expect(body).toEqual({ type: "truapi-init" }); + expect(origin).toBe("*"); + + // The handshake is idempotent across repeated ready events too. + onMessage!({ + source: contentWindow, + origin: "http://localhost:5174", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(1); + }); + + it("accepts product-ready from a credentialless opaque origin", () => { + const { contentPostMessage, windowListeners, contentWindow } = dom; + + createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: { appendChild: () => {} } as unknown as HTMLElement, + onPort: () => {}, + }); + + const onMessage = windowListeners.get("message"); + expect(onMessage).toBeTruthy(); + + onMessage!({ + source: contentWindow, + origin: "null", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(1); + const [, origin] = contentPostMessage.mock.calls[0]; + expect(origin).toBe("*"); + }); + + it("rejects a mismatched allowedOrigin", () => { + expect(() => + createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: { appendChild: () => {} } as unknown as HTMLElement, + onPort: () => {}, + allowedOrigin: "http://localhost:9999", + }), + ).toThrow(/origin policy mismatch/); + }); + + it("rejects non-http(s) iframe URLs", () => { + expect(() => + createIframeHost({ + iframeUrl: "file:///etc/passwd", + container: { appendChild: () => {} } as unknown as HTMLElement, + onPort: () => {}, + }), + ).toThrow(/only allows http\(s\)/); + }); +}); diff --git a/js/packages/truapi-host-wasm/src/web/create-iframe-host.ts b/js/packages/truapi-host-wasm/src/web/create-iframe-host.ts new file mode 100644 index 00000000..b5e3ce36 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/create-iframe-host.ts @@ -0,0 +1,147 @@ +/** + * Options for `createIframeHost`. + */ +export interface IframeHostOptions { + /** URL of the product iframe. */ + iframeUrl: string; + /** Container element the iframe is appended to. */ + container: HTMLElement; + /** + * Called with one end of the MessageChannel once the iframe has loaded. + * Hosts typically pipe this into a `WireProvider` (e.g. via + * `createMessagePortProvider` from `@parity/truapi`). + */ + onPort: (port: MessagePort) => void; + /** + * Optional explicit allow-list origin. Defaults to the origin of + * `iframeUrl`. Throws if it disagrees with the iframe URL's origin. + */ + allowedOrigin?: string; + /** Optional iframe Permissions Policy allow attribute. */ + allow?: string; + /** Override the default iframe sandbox attribute. */ + sandbox?: string; +} + +/** + * Handle returned by `createIframeHost`. + */ +export interface IframeHost { + iframe: HTMLIFrameElement; + dispose: () => void; +} + +const DEFAULT_IFRAME_SANDBOX = "allow-forms allow-same-origin allow-scripts"; +type CredentiallessIframe = HTMLIFrameElement & { credentialless?: boolean }; + +function resolveAllowedOrigin( + iframeUrl: string, + allowedOrigin?: string, +): string { + const targetUrl = new URL(iframeUrl, window.location.href); + if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") { + throw new Error( + `Iframe host only allows http(s) product URLs, received ${targetUrl.protocol}`, + ); + } + + if (!allowedOrigin) { + return targetUrl.origin; + } + + const normalizedOrigin = new URL(allowedOrigin).origin; + if (normalizedOrigin !== targetUrl.origin) { + throw new Error( + `Iframe host origin policy mismatch, expected ${normalizedOrigin}, got ${targetUrl.origin}`, + ); + } + + return normalizedOrigin; +} + +/** + * Embed a product iframe and transfer a `MessagePort` into it. The host + * keeps the other end and passes it to a `WireProvider` (typically via + * `createMessagePortProvider`). All product traffic flows over the + * MessageChannel. + */ +export function createIframeHost(options: IframeHostOptions): IframeHost { + const { + iframeUrl, + container, + onPort, + allowedOrigin, + allow, + sandbox = DEFAULT_IFRAME_SANDBOX, + } = options; + + const channel = new MessageChannel(); + const hostPort = channel.port1; + const productPort = channel.port2; + const targetOrigin = resolveAllowedOrigin(iframeUrl, allowedOrigin); + + // Hand the host-side port to the caller immediately so it can wire up + // a provider before the iframe finishes loading. Queued postMessage + // calls are delivered once the channel is started by the provider. + onPort(hostPort); + + const iframe = document.createElement("iframe"); + iframe.style.width = "100%"; + iframe.style.height = "100%"; + iframe.style.border = "none"; + // COEP hosts need credentialless product iframes when the product origin + // does not serve matching embedder headers. + const credentiallessIframe = iframe as CredentiallessIframe; + credentiallessIframe.credentialless = true; + if (allow !== undefined) { + iframe.allow = allow; + } + iframe.setAttribute("sandbox", sandbox); + iframe.referrerPolicy = "no-referrer"; + iframe.src = iframeUrl; + const initTargetOrigin = credentiallessIframe.credentialless + ? "*" + : targetOrigin; + + let initSent = false; + const sendInit = (): void => { + if (initSent) return; + const contentWindow = iframe.contentWindow; + if (!contentWindow) return; + initSent = true; + contentWindow.postMessage({ type: "truapi-init" }, initTargetOrigin, [ + productPort, + ]); + }; + + const onWindowMessage = (event: MessageEvent): void => { + if (event.source !== iframe.contentWindow) return; + if (event.origin !== targetOrigin && event.origin !== "null") return; + if (event.data?.type === "truapi-ready") { + sendInit(); + } + }; + window.addEventListener("message", onWindowMessage); + + container.appendChild(iframe); + + return { + iframe, + dispose() { + window.removeEventListener("message", onWindowMessage); + try { + hostPort.close(); + } catch { + // already closed + } + try { + productPort.close(); + } catch { + // already closed + } + iframe.remove(); + }, + }; +} + +export type { WireProvider } from "@parity/truapi"; diff --git a/js/packages/truapi-host-wasm/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host-wasm/src/web/create-worker-host-runtime.ts new file mode 100644 index 00000000..f662ec8e --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/create-worker-host-runtime.ts @@ -0,0 +1,780 @@ +import type { + ChainConnection, + HostCallbacks, + LogLevel, + PermissionAuthorizationRequest, + PermissionAuthorizationStatus, + TrUApiHostCoreProvider, + HostCoreRuntimeConfig, +} from "../index.js"; +import { PermissionAuthorizationRequest as PermissionAuthorizationRequestCodec } from "../generated/host-callbacks.js"; +import { createWasmRawCallbacks } from "../generated/host-callbacks-adapter.js"; +import type { RawCallbacks } from "../generated/host-callbacks-adapter.js"; +import type { + CallbackName, + MainToWorker, + SubscriptionName, + WorkerToMain, +} from "../worker-protocol.js"; +import { bytesToHex } from "@parity/truapi/scale"; +import { startRawSubscription } from "../generated/worker-callbacks.js"; +import { errorMessage } from "../error.js"; + +interface WorkerProviderState { + worker: Worker; + rawCallbacks: RawCallbacks; + listeners: Set<(message: Uint8Array) => void>; + closeListeners: Set<(error: Error) => void>; + subscriptionDisposers: Map void>; + chainConnections: Map; + pendingDisconnects: Map< + number, + { resolve: () => void; reject: (error: Error) => void } + >; + pendingPermissionAuthorizationStatuses: Map< + number, + { + resolve: (status: PermissionAuthorizationStatus) => void; + reject: (error: Error) => void; + } + >; + pendingPermissionAuthorizationStatusBatches: Map< + number, + { + resolve: (statuses: PermissionAuthorizationStatus[]) => void; + reject: (error: Error) => void; + } + >; + pendingSetPermissionAuthorizationStatuses: Map< + number, + { resolve: () => void; reject: (error: Error) => void } + >; + closedError: Error | null; + logLevel: LogLevel; + disposed: boolean; +} + +function debugLoggingEnabled(state: WorkerProviderState): boolean { + return state.logLevel === "debug" || state.logLevel === "trace"; +} + +let nextDisconnectRequestId = 0; +let nextPermissionAuthorizationRequestId = 0; + +function encodePermissionAuthorizationRequest( + request: PermissionAuthorizationRequest, +): Uint8Array { + return PermissionAuthorizationRequestCodec.enc(request); +} + +/** localStorage key the dev log level is persisted under, so it survives reloads. */ +const DEV_LOG_LEVEL_KEY = "truapi:logLevel"; + +/** Read the persisted dev log level. Returns null when unset. */ +function readPersistedLogLevel(): LogLevel | null { + return globalThis.localStorage?.getItem(DEV_LOG_LEVEL_KEY) ?? null; +} + +/** Persist the dev log level so it re-applies on the next reload. */ +function persistLogLevel(level: LogLevel): void { + globalThis.localStorage?.setItem(DEV_LOG_LEVEL_KEY, level); +} + +let devLogLevelOverride: LogLevel | null = readPersistedLogLevel(); +const devGlobalProviders = new Set(); +interface TrUApiDevConsole { + setLogLevel(level: LogLevel): void; + getLogLevel(): LogLevel | null; +} + +function handleCallbackRequest( + state: WorkerProviderState, + msg: { + requestId: number; + name: CallbackName; + args: readonly unknown[]; + }, +): void { + // Own-property guard: `msg.name` is worker-supplied, never walk the + // prototype chain with it. + const fn = Object.hasOwn(state.rawCallbacks, msg.name) + ? ( + state.rawCallbacks as unknown as Record< + string, + (...args: readonly unknown[]) => unknown + > + )[msg.name] + : undefined; + if (!fn) { + const reply: MainToWorker = { + kind: "callbackResponse", + requestId: msg.requestId, + ok: false, + error: `unknown callback: ${msg.name}`, + }; + state.worker.postMessage(reply); + return; + } + Promise.resolve() + .then(() => fn(...msg.args)) + .then( + (value) => { + const reply: MainToWorker = { + kind: "callbackResponse", + requestId: msg.requestId, + ok: true, + value, + }; + state.worker.postMessage(reply); + }, + (err) => { + const reply: MainToWorker = { + kind: "callbackResponse", + requestId: msg.requestId, + ok: false, + error: errorMessage(err), + }; + state.worker.postMessage(reply); + }, + ); +} + +function handleSubscriptionStart( + state: WorkerProviderState, + msg: { + subId: number; + name: SubscriptionName; + payload: Uint8Array | null; + }, +): void { + const sendItem = (value?: unknown): void => { + if (state.disposed) return; + const post: MainToWorker = { + kind: "subscriptionItem", + subId: msg.subId, + value, + }; + state.worker.postMessage(post); + }; + let dispose: (() => void) | void = undefined; + try { + dispose = startRawSubscription( + state.rawCallbacks, + msg.name, + msg.payload, + sendItem, + ); + } catch (err) { + console.error(`[truapi worker] ${msg.name} threw on start:`, err); + return; + } + if (typeof dispose === "function") { + state.subscriptionDisposers.set(msg.subId, dispose); + } +} + +function handleSubscriptionStop( + state: WorkerProviderState, + msg: { subId: number }, +): void { + const dispose = state.subscriptionDisposers.get(msg.subId); + if (!dispose) return; + state.subscriptionDisposers.delete(msg.subId); + try { + dispose(); + } catch (err) { + console.warn("[truapi worker] subscription dispose threw:", err); + } +} + +async function handleChainConnectStart( + state: WorkerProviderState, + msg: { connId: number; genesisHash: string }, +): Promise { + const chainConnect = state.rawCallbacks.chainConnect; + const onResponse = (json: string): void => { + if (state.disposed) return; + const post: MainToWorker = { + kind: "chainResponse", + connId: msg.connId, + json, + }; + state.worker.postMessage(post); + }; + try { + const conn = await chainConnect(msg.genesisHash, onResponse); + if (!conn) { + const reply: MainToWorker = { + kind: "chainConnectAck", + connId: msg.connId, + ok: false, + error: `chainConnect returned null for genesisHash ${msg.genesisHash}`, + }; + state.worker.postMessage(reply); + return; + } + state.chainConnections.set(msg.connId, conn); + const reply: MainToWorker = { + kind: "chainConnectAck", + connId: msg.connId, + ok: true, + }; + state.worker.postMessage(reply); + } catch (err) { + const reply: MainToWorker = { + kind: "chainConnectAck", + connId: msg.connId, + ok: false, + error: errorMessage(err), + }; + state.worker.postMessage(reply); + } +} + +function handleChainSend( + state: WorkerProviderState, + msg: { connId: number; request: string }, +): void { + const conn = state.chainConnections.get(msg.connId); + if (!conn) return; + try { + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] chainSend", msg.connId, msg.request); + } + conn.send(msg.request); + } catch (err) { + console.warn("[truapi worker] chain send threw:", err); + } +} + +function handleChainClose( + state: WorkerProviderState, + msg: { connId: number }, +): void { + const conn = state.chainConnections.get(msg.connId); + if (!conn) return; + state.chainConnections.delete(msg.connId); + try { + conn.close(); + } catch (err) { + console.warn("[truapi worker] chain close threw:", err); + } +} + +interface PendingEntry { + resolve: (value: T) => void; + reject: (error: Error) => void; +} + +/** Settle and remove the pending entry for `requestId`, no-op if already gone. */ +function settlePending( + map: Map>, + requestId: number, + result: { ok: true; value: T } | { ok: false; error: string }, +): void { + const pending = map.get(requestId); + if (!pending) return; + map.delete(requestId); + if (result.ok) { + pending.resolve(result.value); + } else { + pending.reject(new Error(result.error)); + } +} + +/** Reject every pending entry in `map` with `error`, then drain the map. */ +function rejectAll(map: Map>, error: Error): void { + for (const pending of map.values()) { + pending.reject(error); + } + map.clear(); +} + +function handleDisconnectResponse( + state: WorkerProviderState, + msg: + | { requestId: number; ok: true } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingDisconnects, + msg.requestId, + msg.ok ? { ok: true, value: undefined } : { ok: false, error: msg.error }, + ); +} + +function handlePermissionAuthorizationStatusResponse( + state: WorkerProviderState, + msg: + | { + requestId: number; + ok: true; + status: PermissionAuthorizationStatus; + } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingPermissionAuthorizationStatuses, + msg.requestId, + msg.ok ? { ok: true, value: msg.status } : { ok: false, error: msg.error }, + ); +} + +function handlePermissionAuthorizationStatusesResponse( + state: WorkerProviderState, + msg: + | { + requestId: number; + ok: true; + statuses: PermissionAuthorizationStatus[]; + } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingPermissionAuthorizationStatusBatches, + msg.requestId, + msg.ok + ? { ok: true, value: msg.statuses } + : { ok: false, error: msg.error }, + ); +} + +function handleSetPermissionAuthorizationStatusResponse( + state: WorkerProviderState, + msg: + | { requestId: number; ok: true } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingSetPermissionAuthorizationStatuses, + msg.requestId, + msg.ok ? { ok: true, value: undefined } : { ok: false, error: msg.error }, + ); +} + +function rejectPendingDisconnects( + state: WorkerProviderState, + error: Error, +): void { + rejectAll(state.pendingDisconnects, error); +} + +function rejectPendingPermissionAuthorizationRequests( + state: WorkerProviderState, + error: Error, +): void { + rejectAll(state.pendingPermissionAuthorizationStatuses, error); + rejectAll(state.pendingPermissionAuthorizationStatusBatches, error); + rejectAll(state.pendingSetPermissionAuthorizationStatuses, error); +} + +/** + * Register a pending request, post its message to the worker, and resolve when + * the matching response arrives. Short-circuits to `disposedFallback` once the + * provider is disposed; rolls back the pending entry if `postMessage` throws. + */ +function sendWorkerRequest( + state: WorkerProviderState, + pending: Map>, + nextId: () => number, + disposedFallback: T, + buildMessage: (requestId: number) => MainToWorker, +): Promise { + if (state.disposed) return Promise.resolve(disposedFallback); + return new Promise((resolve, reject) => { + const requestId = nextId(); + pending.set(requestId, { resolve, reject }); + try { + state.worker.postMessage(buildMessage(requestId)); + } catch (err) { + pending.delete(requestId); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); +} + +/** + * Shared terminal teardown for both `dispose()` and worker faults: rejects + * pending disconnects, runs subscription disposers, closes chain connections, + * and terminates the worker. A fault additionally notifies close listeners. + */ +function teardown( + state: WorkerProviderState, + error: Error, + fault: boolean, +): void { + if (state.disposed) return; + state.disposed = true; + state.closedError = error; + rejectPendingDisconnects(state, error); + rejectPendingPermissionAuthorizationRequests(state, error); + for (const fn of state.subscriptionDisposers.values()) { + try { + fn(); + } catch { + // ignore during teardown + } + } + state.subscriptionDisposers.clear(); + for (const conn of state.chainConnections.values()) { + try { + conn.close(); + } catch { + // ignore during teardown + } + } + state.chainConnections.clear(); + if (fault) { + state.worker.terminate(); + } else { + try { + const post: MainToWorker = { kind: "dispose" }; + state.worker.postMessage(post); + } catch { + // ignore if worker already gone + } + // Give the worker a tick to free the core before terminating. + setTimeout(() => state.worker.terminate(), 0); + } + for (const listener of [...state.closeListeners]) listener(error); + state.listeners.clear(); + state.closeListeners.clear(); +} + +export interface CreateWebWorkerProviderOptions { + /** Wasm core log level. Default: `"off"`. */ + logLevel?: LogLevel; + /** Static product/pairing config passed to the Rust core. */ + runtimeConfig: HostCoreRuntimeConfig; + /** + * Milliseconds to wait for the worker to report `ready` before rejecting + * and terminating it. Default: 30000. + */ + initTimeoutMs?: number; +} + +export type WebWorkerHostCallbacks = Required; + +/** + * Spawn the truapi-server WASM in `worker` and bridge it into a + * `WireProvider`. + * + * The caller is responsible for instantiating the Worker, Vite users + * typically import the worker entry-point with `?worker`: + * + * ```ts + * import HostWorker from "@parity/truapi-host-wasm/worker-runtime?worker"; + * const worker = new HostWorker(); + * const provider = await createWebWorkerProvider(worker, callbacks, { + * runtimeConfig, + * }); + * ``` + * + * Resolves once the worker reports `ready` and rejects if the WASM + * fails to load. + */ +export function createWebWorkerProvider( + worker: Worker, + host: WebWorkerHostCallbacks, + options: CreateWebWorkerProviderOptions, +): Promise { + const callbacks = createWasmRawCallbacks(host); + + return new Promise((resolve, reject) => { + const state: WorkerProviderState = { + worker, + rawCallbacks: callbacks, + listeners: new Set(), + closeListeners: new Set(), + subscriptionDisposers: new Map(), + chainConnections: new Map(), + pendingDisconnects: new Map(), + pendingPermissionAuthorizationStatuses: new Map(), + pendingPermissionAuthorizationStatusBatches: new Map(), + pendingSetPermissionAuthorizationStatuses: new Map(), + closedError: null, + logLevel: devLogLevelOverride ?? options.logLevel ?? "off", + disposed: false, + }; + + const onMessage = (ev: MessageEvent): void => { + const msg = ev.data; + switch (msg.kind) { + case "loaded": + break; + case "ready": + break; + case "fatalError": + console.error("[truapi worker]", msg.error); + notifyFault(new Error(`worker fatal error: ${msg.error}`)); + break; + case "frameError": + console.error("[truapi worker]", msg.error); + notifyFault(new Error(`worker frame error: ${msg.error}`)); + break; + case "disposeError": + console.warn("[truapi worker] dispose:", msg.error); + break; + case "frame": + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] frame <-", bytesToHex(msg.bytes)); + } + for (const listener of [...state.listeners]) listener(msg.bytes); + break; + case "disconnectSessionResponse": + handleDisconnectResponse(state, msg); + break; + case "permissionAuthorizationStatusResponse": + handlePermissionAuthorizationStatusResponse(state, msg); + break; + case "permissionAuthorizationStatusesResponse": + handlePermissionAuthorizationStatusesResponse(state, msg); + break; + case "setPermissionAuthorizationStatusResponse": + handleSetPermissionAuthorizationStatusResponse(state, msg); + break; + case "callbackRequest": + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] callbackRequest", msg.name); + } + handleCallbackRequest(state, msg); + break; + case "subscriptionStart": + handleSubscriptionStart(state, msg); + break; + case "subscriptionStop": + handleSubscriptionStop(state, msg); + break; + case "chainConnectStart": + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] chainConnectStart", msg.connId); + } + void handleChainConnectStart(state, msg); + break; + case "chainSend": + handleChainSend(state, msg); + break; + case "chainClose": + handleChainClose(state, msg); + break; + default: { + const { kind } = msg as { kind?: unknown }; + console.warn( + `[truapi worker] unknown worker message kind: ${String(kind)}`, + ); + } + } + }; + + const notifyFault = (error: Error): void => { + teardown(state, error, true); + }; + + const onError = (e: ErrorEvent): void => { + cleanupInit(); + worker.terminate(); + reject(new Error(`worker init failed: ${e.message}`)); + }; + + const onInitMessageError = (): void => { + cleanupInit(); + worker.terminate(); + reject(new Error("worker message could not be deserialized during init")); + }; + + const onRuntimeError = (e: ErrorEvent): void => { + console.error("[truapi worker]", e.message); + notifyFault(new Error(`worker error: ${e.message}`)); + }; + + const onMessageError = (): void => { + notifyFault(new Error("worker message could not be deserialized")); + }; + + const onInitMessage = (ev: MessageEvent): void => { + const msg = ev.data; + if (msg.kind === "loaded") { + const init: MainToWorker = { + kind: "init", + logLevel: devLogLevelOverride ?? options.logLevel ?? "off", + runtimeConfig: options.runtimeConfig, + }; + worker.postMessage(init); + } else if (msg.kind === "ready") { + cleanupInit(); + worker.addEventListener("message", onMessage); + // Surface a post-init worker fault (uncaught throw, OOM, killed + // worker) to close listeners for the provider's lifetime. + worker.addEventListener("error", onRuntimeError); + worker.addEventListener("messageerror", onMessageError); + const provider = buildProvider(state); + exposeDevGlobal(provider); + resolve(provider); + } else if (msg.kind === "fatalError") { + cleanupInit(); + worker.terminate(); + reject(new Error(`worker init reported error: ${msg.error}`)); + } + }; + + const cleanupInit = (): void => { + clearTimeout(initTimeout); + worker.removeEventListener("error", onError); + worker.removeEventListener("messageerror", onInitMessageError); + worker.removeEventListener("message", onInitMessage); + }; + + const timeoutMs = options.initTimeoutMs ?? 30_000; + const initTimeout = setTimeout(() => { + cleanupInit(); + worker.terminate(); + reject(new Error(`worker init timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + worker.addEventListener("error", onError); + worker.addEventListener("messageerror", onInitMessageError); + worker.addEventListener("message", onInitMessage); + }); +} + +function buildProvider(state: WorkerProviderState): TrUApiHostCoreProvider { + const provider: TrUApiHostCoreProvider = { + postMessage(bytes: Uint8Array): void { + if (state.disposed) return; + const post: MainToWorker = { kind: "frame", bytes }; + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] frame ->", bytesToHex(bytes)); + } + state.worker.postMessage(post); + }, + subscribe(callback) { + state.listeners.add(callback); + return () => { + state.listeners.delete(callback); + }; + }, + subscribeClose(callback) { + if (state.closedError) { + callback(state.closedError); + return () => {}; + } + state.closeListeners.add(callback); + return () => { + state.closeListeners.delete(callback); + }; + }, + disconnectSession(): Promise { + return sendWorkerRequest( + state, + state.pendingDisconnects, + () => ++nextDisconnectRequestId, + undefined, + (requestId) => ({ kind: "disconnectSession", requestId }), + ); + }, + cancelPairing(): void { + if (state.disposed) return; + const post: MainToWorker = { kind: "cancelPairing" }; + state.worker.postMessage(post); + }, + notifySessionStoreChanged(): void { + if (state.disposed) return; + const post: MainToWorker = { kind: "notifySessionStoreChanged" }; + state.worker.postMessage(post); + }, + getPermissionAuthorizationStatus( + request: PermissionAuthorizationRequest, + ): Promise { + return sendWorkerRequest( + state, + state.pendingPermissionAuthorizationStatuses, + () => ++nextPermissionAuthorizationRequestId, + "NotDetermined", + (requestId) => ({ + kind: "getPermissionAuthorizationStatus", + requestId, + request: encodePermissionAuthorizationRequest(request), + }), + ); + }, + getPermissionAuthorizationStatuses( + requests: PermissionAuthorizationRequest[], + ): Promise { + return sendWorkerRequest( + state, + state.pendingPermissionAuthorizationStatusBatches, + () => ++nextPermissionAuthorizationRequestId, + requests.map(() => "NotDetermined"), + (requestId) => ({ + kind: "getPermissionAuthorizationStatuses", + requestId, + requests: requests.map(encodePermissionAuthorizationRequest), + }), + ); + }, + setPermissionAuthorizationStatus( + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ): Promise { + return sendWorkerRequest( + state, + state.pendingSetPermissionAuthorizationStatuses, + () => ++nextPermissionAuthorizationRequestId, + undefined, + (requestId) => ({ + kind: "setPermissionAuthorizationStatus", + requestId, + request: encodePermissionAuthorizationRequest(request), + status, + }), + ); + }, + setLogLevel(level: LogLevel): void { + if (state.disposed) return; + state.logLevel = level; + const post: MainToWorker = { kind: "setLogLevel", level }; + state.worker.postMessage(post); + }, + dispose() { + devGlobalProviders.delete(provider); + teardown(state, new Error("provider disposed"), false); + }, + }; + return provider; +} + +/** + * Publish `globalThis.__truapi.setLogLevel(level)` so a developer can re-tune + * the wasm core's verbosity live from the browser console without a reload. The + * level is persisted to `localStorage["truapi:logLevel"]` and re-applied on the + * next load, so it survives refreshes. Pair with the DevTools console "Verbose" + * level to surface debug/trace. + */ +function exposeDevGlobal(provider: TrUApiHostCoreProvider): void { + devGlobalProviders.add(provider); + if (devLogLevelOverride !== null) { + provider.setLogLevel?.(devLogLevelOverride); + } + publishDevGlobal(); +} + +function publishDevGlobal(): void { + const target = globalThis as { + __truapi?: TrUApiDevConsole; + }; + target.__truapi = { + setLogLevel(level: LogLevel): void { + devLogLevelOverride = level; + persistLogLevel(level); + for (const provider of [...devGlobalProviders]) { + provider.setLogLevel?.(level); + } + console.info(`[truapi worker] logLevel=${level}`); + }, + getLogLevel(): LogLevel | null { + return devLogLevelOverride; + }, + }; +} + +publishDevGlobal(); diff --git a/js/packages/truapi-host-wasm/src/web/index.ts b/js/packages/truapi-host-wasm/src/web/index.ts new file mode 100644 index 00000000..430c7098 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/index.ts @@ -0,0 +1,7 @@ +export type { IframeHost, IframeHostOptions } from "./create-iframe-host.js"; +export { createIframeHost } from "./create-iframe-host.js"; +export type { + CreateWebWorkerProviderOptions, + WebWorkerHostCallbacks, +} from "./create-worker-host-runtime.js"; +export { createWebWorkerProvider } from "./create-worker-host-runtime.js"; diff --git a/js/packages/truapi-host-wasm/src/web/worker-provider.test.ts b/js/packages/truapi-host-wasm/src/web/worker-provider.test.ts new file mode 100644 index 00000000..a7f98d9c --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/worker-provider.test.ts @@ -0,0 +1,658 @@ +import { describe, expect, it } from "bun:test"; +import { ok } from "neverthrow"; + +import { HostPushNotificationRequest, HostPushNotificationResponse } from "@parity/truapi"; +import type { GenericError, Result, ThemeVariant } from "@parity/truapi"; + +import { createWasmRawCallbacks } from "../generated/host-callbacks-adapter.js"; +import { CoreStorageKey } from "../generated/host-callbacks.js"; +import type { AuthState, HostCallbacks } from "../generated/host-callbacks.js"; +import type { HostCoreRuntimeConfig } from "../runtime.js"; +import { makeHostCallbacks, settle } from "../test-support.js"; +import { createWebWorkerProvider } from "./index.js"; +import type { CreateWebWorkerProviderOptions } from "./index.js"; + +type WorkerMessage = Record; + +/** Minimal `Worker` stand-in that records posted messages and lets a test + * drive the `message`/`error`/`messageerror` events by hand. */ +class FakeWorker { + listeners = new Map void>>(); + messages: WorkerMessage[] = []; + terminated = false; + + addEventListener(name: string, fn: (event: unknown) => void) { + const listeners = this.listeners.get(name) ?? new Set(); + listeners.add(fn); + this.listeners.set(name, listeners); + } + + removeEventListener(name: string, fn: (event: unknown) => void) { + this.listeners.get(name)?.delete(fn); + } + + postMessage(message: WorkerMessage) { + this.messages.push(message); + } + + terminate() { + this.terminated = true; + } + + emit(message: WorkerMessage) { + for (const listener of this.listeners.get("message") ?? []) { + listener({ data: message }); + } + } + + emitError(message: string) { + for (const listener of this.listeners.get("error") ?? []) { + listener({ message }); + } + } + + emitMessageError() { + for (const listener of this.listeners.get("messageerror") ?? []) { + listener({ data: null }); + } + } +} + +/** Coerce the `FakeWorker` to the `Worker` shape the provider expects. */ +function asWorker(worker: FakeWorker): Worker { + return worker as unknown as Worker; +} + +function runtimeConfig(overrides: Partial = {}): HostCoreRuntimeConfig { + return { + productId: "dotli.dot", + host: { + name: "Polkadot Web", + icon: "https://dot.li/dotli.png", + version: "0.5.0", + }, + platform: { + type: "node", + version: process.versions.node, + }, + people: { + genesisHash: "0xa22a2424d2cbf561eaecf7da8b1b548fa9d1939f60265e942b1049616a012f71", + }, + pairing: { + deeplinkScheme: "polkadotapp", + }, + ...overrides, + }; +} + +type ReadyOptions = Partial & { + createWebWorkerProvider?: typeof createWebWorkerProvider; +}; + +async function readyProvider(worker: FakeWorker, options: ReadyOptions = {}) { + const { + createWebWorkerProvider: createProvider = createWebWorkerProvider, + runtimeConfig: cfg = runtimeConfig(), + ...rest + } = options; + const providerPromise = createProvider(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: cfg, + ...rest, + }); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + return providerPromise; +} + +/** Typed view of the dev console the worker runtime publishes on `globalThis`. */ +type TruapiDevConsole = { + setLogLevel(level: string): void; + getLogLevel(): string | null; +}; +const devGlobal = globalThis as typeof globalThis & { + __truapi?: TruapiDevConsole; +}; + +describe("createWebWorkerProvider", () => { + it("initializes the worker without a callback manifest", async () => { + const worker = new FakeWorker(); + const config = runtimeConfig(); + const providerPromise = createWebWorkerProvider(asWorker(worker), makeHostCallbacks(), { + logLevel: "debug", + runtimeConfig: config, + }); + + worker.emit({ kind: "loaded" }); + expect(worker.messages.length).toBe(1); + expect(worker.messages[0]).toEqual({ + kind: "init", + logLevel: "debug", + runtimeConfig: config, + }); + + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + expect(typeof provider.disconnectSession).toBe("function"); + expect(typeof provider.cancelPairing).toBe("function"); + expect(typeof provider.notifySessionStoreChanged).toBe("function"); + + provider.dispose(); + }); + + it("dev global setLogLevel updates every live worker provider", async () => { + const previous = devGlobal.__truapi; + delete devGlobal.__truapi; + const firstWorker = new FakeWorker(); + const secondWorker = new FakeWorker(); + const first = await readyProvider(firstWorker); + const second = await readyProvider(secondWorker); + + devGlobal.__truapi!.setLogLevel("debug"); + + expect(firstWorker.messages.at(-1)).toEqual({ + kind: "setLogLevel", + level: "debug", + }); + expect(secondWorker.messages.at(-1)).toEqual({ + kind: "setLogLevel", + level: "debug", + }); + expect(devGlobal.__truapi!.getLogLevel()).toBe("debug"); + + devGlobal.__truapi!.setLogLevel("off"); + first.dispose(); + second.dispose(); + if (previous === undefined) { + delete devGlobal.__truapi; + } else { + devGlobal.__truapi = previous; + } + }); + + it("dev global setLogLevel applies to providers created later", async () => { + const previous = devGlobal.__truapi; + delete devGlobal.__truapi; + const moduleUrl = `./create-worker-host-runtime.js?dev-global-${Date.now()}`; + const { createWebWorkerProvider: freshCreateWebWorkerProvider } = (await import( + moduleUrl + )) as typeof import("./create-worker-host-runtime.js"); + + expect(typeof devGlobal.__truapi!.setLogLevel).toBe("function"); + devGlobal.__truapi!.setLogLevel("trace"); + + const firstWorker = new FakeWorker(); + const first = await readyProvider(firstWorker, { + createWebWorkerProvider: freshCreateWebWorkerProvider, + }); + first.dispose(); + + const secondWorker = new FakeWorker(); + const second = await readyProvider(secondWorker, { + createWebWorkerProvider: freshCreateWebWorkerProvider, + }); + + expect(secondWorker.messages[0].kind).toBe("init"); + expect(secondWorker.messages[0].logLevel).toBe("trace"); + expect(secondWorker.messages.at(-1)).toEqual({ + kind: "setLogLevel", + level: "trace", + }); + + second.dispose(); + devGlobal.__truapi!.setLogLevel("off"); + if (previous === undefined) { + delete devGlobal.__truapi; + } else { + devGlobal.__truapi = previous; + } + }); + + it("dev global setLogLevel persists the level to localStorage", async () => { + const previousGlobal = devGlobal.__truapi; + const previousStorage = globalThis.localStorage; + delete devGlobal.__truapi; + const store = new Map(); + globalThis.localStorage = { + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + setItem: (key: string, value: string) => store.set(key, String(value)), + } as unknown as Storage; + + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + + devGlobal.__truapi!.setLogLevel("debug"); + expect(store.get("truapi:logLevel")).toBe("debug"); + + devGlobal.__truapi!.setLogLevel("off"); + expect(store.get("truapi:logLevel")).toBe("off"); + + provider.dispose(); + globalThis.localStorage = previousStorage; + if (previousGlobal === undefined) { + delete devGlobal.__truapi; + } else { + devGlobal.__truapi = previousGlobal; + } + }); + + it("resolves disconnect responses", async () => { + const worker = new FakeWorker(); + const providerPromise = createWebWorkerProvider(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: runtimeConfig(), + }); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + + const disconnect = provider.disconnectSession(); + const msg = worker.messages.at(-1)!; + expect(msg.kind).toBe("disconnectSession"); + expect(typeof msg.requestId).toBe("number"); + + worker.emit({ + kind: "disconnectSessionResponse", + requestId: msg.requestId, + ok: true, + }); + await disconnect; + + provider.dispose(); + }); + + it("dispatches callback requests to host hooks", async () => { + const worker = new FakeWorker(); + let clears = 0; + const authSessionKey = CoreStorageKey.enc({ tag: "AuthSession" }); + const providerPromise = createWebWorkerProvider( + asWorker(worker), + makeHostCallbacks({ + clearCoreStorage: async (key) => { + expect(key).toEqual({ tag: "AuthSession", value: undefined }); + clears += 1; + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + + worker.emit({ + kind: "callbackRequest", + requestId: 7, + name: "clearCoreStorage", + args: [authSessionKey], + }); + await settle(); + + expect(clears).toBe(1); + expect(worker.messages.at(-1)).toEqual({ + kind: "callbackResponse", + requestId: 7, + ok: true, + value: undefined, + }); + + provider.dispose(); + }); + + it("reports unknown callback requests", async () => { + const worker = new FakeWorker(); + const providerPromise = createWebWorkerProvider(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: runtimeConfig(), + }); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + + worker.emit({ + kind: "callbackRequest", + requestId: 11, + name: "someFutureCallback", + args: [new Uint8Array([1, 2, 3])], + }); + await settle(); + + expect(worker.messages.at(-1)).toEqual({ + kind: "callbackResponse", + requestId: 11, + ok: false, + error: "unknown callback: someFutureCallback", + }); + + provider.dispose(); + }); + + it("forwards authStateChanged callback requests", async () => { + const worker = new FakeWorker(); + const states: AuthState[] = []; + const providerPromise = createWebWorkerProvider( + asWorker(worker), + makeHostCallbacks({ + authStateChanged: (state) => { + states.push(state); + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + + worker.emit({ + kind: "callbackRequest", + requestId: 3, + name: "authStateChanged", + args: [ + { + tag: "Connected", + value: { + publicKey: new Uint8Array([1, 2]), + liteUsername: "alice", + }, + }, + ], + }); + await settle(); + + expect(states).toEqual([ + { + tag: "Connected", + value: { + publicKey: new Uint8Array([1, 2]), + liteUsername: "alice", + }, + }, + ]); + expect(worker.messages.at(-1)).toEqual({ + kind: "callbackResponse", + requestId: 3, + ok: true, + value: undefined, + }); + + provider.dispose(); + }); + + it("posts cancelPairing to the worker", async () => { + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + + provider.cancelPairing(); + + expect(worker.messages.at(-1)).toEqual({ kind: "cancelPairing" }); + provider.dispose(); + }); + + it("posts notifySessionStoreChanged to the worker", async () => { + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + + provider.notifySessionStoreChanged(); + + expect(worker.messages.at(-1)).toEqual({ + kind: "notifySessionStoreChanged", + }); + provider.dispose(); + }); + + it("worker fault terminates the worker and runs the full teardown", async () => { + const worker = new FakeWorker(); + let subscriptionDisposes = 0; + let chainResponseStops = 0; + let chainCloses = 0; + const providerPromise = createWebWorkerProvider( + asWorker(worker), + makeHostCallbacks({ + // Manual async iterables whose `return()` records disposal; the + // provider disposes subscriptions and closes chain connections + // on a worker fault. + subscribeTheme: () => + ({ + [Symbol.asyncIterator]() { + return this; + }, + next: () => new Promise(() => {}), + return: async () => { + subscriptionDisposes += 1; + return { done: true, value: undefined }; + }, + }) as unknown as AsyncIterable>, + connect: async () => ({ + send() {}, + responses: () => + ({ + [Symbol.asyncIterator]() { + return this; + }, + next: () => new Promise(() => {}), + return: async () => { + chainResponseStops += 1; + return { done: true, value: undefined }; + }, + }) as unknown as AsyncIterable, + close() { + chainCloses += 1; + }, + }), + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + + worker.emit({ + kind: "subscriptionStart", + subId: 1, + name: "subscribeTheme", + payload: null, + }); + worker.emit({ + kind: "chainConnectStart", + connId: 1, + genesisHash: "0xab", + }); + await settle(); + await settle(); + + const closes: Error[] = []; + provider.subscribeClose!((error) => closes.push(error)); + + worker.emitError("boom"); + await settle(); + await settle(); + + expect(worker.terminated).toBe(true); + expect(subscriptionDisposes).toBe(1); + expect(chainResponseStops).toBe(1); + expect(chainCloses).toBe(1); + expect(closes.length).toBe(1); + expect(closes[0].message).toMatch(/boom/); + + // The fault teardown is terminal; a second fault is a no-op. + worker.emitError("again"); + expect(closes.length).toBe(1); + + let lateClose: Error | null = null; + provider.subscribeClose!((error) => { + lateClose = error; + }); + expect(lateClose).toBeInstanceOf(Error); + expect(lateClose!.message).toMatch(/boom/); + }); + + it("worker fatalError during init rejects provider creation", async () => { + const worker = new FakeWorker(); + const providerPromise = createWebWorkerProvider(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: runtimeConfig(), + }); + + worker.emit({ kind: "fatalError", error: "bad wasm" }); + + await expect(providerPromise).rejects.toThrow(/worker init reported error: bad wasm/); + expect(worker.terminated).toBe(true); + }); + + it("worker frameError after init closes the provider", async () => { + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + const closes: Error[] = []; + provider.subscribeClose!((error) => closes.push(error)); + + worker.emit({ kind: "frameError", error: "bad frame" }); + + expect(worker.terminated).toBe(true); + expect(closes.length).toBe(1); + expect(closes[0].message).toMatch(/worker frame error: bad frame/); + + let lateClose: Error | null = null; + provider.subscribeClose!((error) => { + lateClose = error; + }); + expect(lateClose).toBeInstanceOf(Error); + }); + + it("routes payload-carrying subscriptions by name", async () => { + const worker = new FakeWorker(); + const keys: Uint8Array[] = []; + const providerPromise = createWebWorkerProvider( + asWorker(worker), + makeHostCallbacks({ + lookupPreimage: async function* (key) { + keys.push(key); + yield ok(new Uint8Array([1])); + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + + worker.emit({ + kind: "subscriptionStart", + subId: 4, + name: "lookupPreimage", + payload: new Uint8Array([9, 9]), + }); + + await settle(); + await settle(); + expect(keys).toEqual([new Uint8Array([9, 9])]); + expect(worker.messages.at(-1)).toEqual({ + kind: "subscriptionItem", + subId: 4, + value: new Uint8Array([1]), + }); + + provider.dispose(); + }); + + it("never falls through unknown subscription names to another callback", async () => { + const worker = new FakeWorker(); + let preimageStarts = 0; + const providerPromise = createWebWorkerProvider( + asWorker(worker), + makeHostCallbacks({ + lookupPreimage: (() => { + preimageStarts += 1; + return () => {}; + }) as unknown as HostCallbacks["lookupPreimage"], + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + + worker.emit({ + kind: "subscriptionStart", + subId: 5, + name: "someFutureSubscribe", + payload: new Uint8Array([1, 2, 3]), + }); + + expect(preimageStarts).toBe(0); + expect(worker.messages.some((m) => m.kind === "subscriptionItem")).toBe(false); + + provider.dispose(); + }); + + it("does not dispatch a payload-carrying subscription without payload", async () => { + const worker = new FakeWorker(); + let preimageStarts = 0; + const providerPromise = createWebWorkerProvider( + asWorker(worker), + makeHostCallbacks({ + lookupPreimage: (() => { + preimageStarts += 1; + return () => {}; + }) as unknown as HostCallbacks["lookupPreimage"], + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + + worker.emit({ + kind: "subscriptionStart", + subId: 6, + name: "lookupPreimage", + payload: null, + }); + + expect(preimageStarts).toBe(0); + + provider.dispose(); + }); + + it("rejects when init times out", async () => { + const worker = new FakeWorker(); + const providerPromise = createWebWorkerProvider(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: runtimeConfig(), + initTimeoutMs: 20, + }); + worker.emit({ kind: "loaded" }); + await expect(providerPromise).rejects.toThrow(/worker init timed out after 20ms/); + expect(worker.terminated).toBe(true); + }); + + it("rejects on messageerror during init", async () => { + const worker = new FakeWorker(); + const providerPromise = createWebWorkerProvider(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: runtimeConfig(), + }); + worker.emitMessageError(); + await expect(providerPromise).rejects.toThrow(/could not be deserialized/); + expect(worker.terminated).toBe(true); + }); + + it("decodes raw v01 push notification payloads", async () => { + let notification: HostPushNotificationRequest | undefined; + const callbacks = createWasmRawCallbacks( + makeHostCallbacks({ + pushNotification: async (request) => { + notification = request; + return { id: 42 }; + }, + }), + ); + + const encoded = await callbacks.pushNotification!( + HostPushNotificationRequest.enc({ + text: "Hello!", + deeplink: undefined, + scheduledAt: undefined, + }), + ); + + expect(HostPushNotificationResponse.dec(encoded).id).toBe(42); + expect(notification).toEqual({ + text: "Hello!", + deeplink: undefined, + scheduledAt: undefined, + }); + }); +}); diff --git a/js/packages/truapi-host-wasm/src/worker-protocol.ts b/js/packages/truapi-host-wasm/src/worker-protocol.ts new file mode 100644 index 00000000..f3dbf9c5 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/worker-protocol.ts @@ -0,0 +1,160 @@ +// Wire format between the main thread (`createWebWorkerProvider`) and the +// Web Worker that hosts the truapi-server WASM core. +// +// Main window / host JS +// ┌─────────────────────────────────────────────────────────────────┐ +// │ createWebWorkerProvider │ +// │ host callbacks: storage, DOM prompts, chain provider, logging │ +// └───────────────┬─────────────────────────────────────────────────┘ +// │ MainToWorker: init, frame, callbackResponse, +// │ subscriptionItem, chainResponse +// v +// Dedicated Worker +// ┌─────────────────────────────────────────────────────────────────┐ +// │ truapi-server WASM HostCore │ +// │ generated raw-callback proxy │ +// └───────────────┬─────────────────────────────────────────────────┘ +// │ WorkerToMain: frame, callbackRequest, +// │ subscriptionStart, chainConnect +// v +// Main window dispatches those requests to the actual host callbacks. +// +// Frames (`kind: 'frame'`) carry SCALE-encoded `ProtocolMessage` bytes +// untouched in either direction. Everything else is a control message +// for callback dispatch, subscription bookkeeping, or chain connections. +// +// Frame bytes cross the boundary by structured clone, deliberately not as +// transferables: the sender keeps using its buffer (the worker side posts +// views into WASM memory) and frames are small, so the copy is the simpler +// safe choice. + +import type { LogLevel, PermissionAuthorizationStatus } from "./runtime.js"; +import type { + CallbackName, + SubscriptionName, +} from "./generated/worker-callbacks.js"; +/** + * Generated callback-name unions used by the worker transport. They keep the + * hand-written protocol aligned with the Rust platform callback catalog. + */ +export type { + CallbackName, + SubscriptionName, +} from "./generated/worker-callbacks.js"; + +/** + * Positional arguments for a callback. The wasm core calls each callback + * at a fixed arity; a uniform `unknown[]` keeps the wire protocol simple. + */ +export type CallbackArgs = readonly unknown[]; + +/** + * Messages posted by the main window to the WASM worker. These either control + * worker/core lifecycle, forward encoded TrUAPI frames into the core, or return + * host callback/subscription/chain responses requested by the worker. + */ +export type MainToWorker = + | { + kind: "init"; + logLevel: LogLevel; + runtimeConfig: unknown; + } + | { kind: "setLogLevel"; level: LogLevel } + | { kind: "frame"; bytes: Uint8Array } + | { kind: "disconnectSession"; requestId: number } + | { kind: "cancelPairing" } + | { kind: "notifySessionStoreChanged" } + | { + kind: "getPermissionAuthorizationStatus"; + requestId: number; + request: Uint8Array; + } + | { + kind: "getPermissionAuthorizationStatuses"; + requestId: number; + requests: Uint8Array[]; + } + | { + kind: "setPermissionAuthorizationStatus"; + requestId: number; + request: Uint8Array; + status: PermissionAuthorizationStatus; + } + | { kind: "callbackResponse"; requestId: number; ok: true; value: unknown } + | { kind: "callbackResponse"; requestId: number; ok: false; error: string } + | { kind: "subscriptionItem"; subId: number; value: unknown } + | { kind: "chainConnectAck"; connId: number; ok: true } + | { kind: "chainConnectAck"; connId: number; ok: false; error: string } + | { kind: "chainResponse"; connId: number; json: string } + | { kind: "dispose" }; + +/** + * Messages posted by the WASM worker back to the main window. These either + * report worker lifecycle/errors, emit encoded TrUAPI frames from the core, or + * request host callbacks, subscriptions, and chain-provider operations. + */ +export type WorkerToMain = + | { kind: "loaded" } + | { kind: "ready" } + | { kind: "fatalError"; error: string } + | { kind: "frameError"; error: string } + | { kind: "disposeError"; error: string } + | { kind: "frame"; bytes: Uint8Array } + | { kind: "disconnectSessionResponse"; requestId: number; ok: true } + | { + kind: "disconnectSessionResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "permissionAuthorizationStatusResponse"; + requestId: number; + ok: true; + status: PermissionAuthorizationStatus; + } + | { + kind: "permissionAuthorizationStatusResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "permissionAuthorizationStatusesResponse"; + requestId: number; + ok: true; + statuses: PermissionAuthorizationStatus[]; + } + | { + kind: "permissionAuthorizationStatusesResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "setPermissionAuthorizationStatusResponse"; + requestId: number; + ok: true; + } + | { + kind: "setPermissionAuthorizationStatusResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "callbackRequest"; + requestId: number; + name: CallbackName; + args: CallbackArgs; + } + | { + kind: "subscriptionStart"; + subId: number; + name: SubscriptionName; + payload: Uint8Array | null; + } + | { kind: "subscriptionStop"; subId: number } + | { kind: "chainConnectStart"; connId: number; genesisHash: string } + | { kind: "chainSend"; connId: number; request: string } + | { kind: "chainClose"; connId: number }; diff --git a/js/packages/truapi-host-wasm/src/worker-runtime.ts b/js/packages/truapi-host-wasm/src/worker-runtime.ts new file mode 100644 index 00000000..77a1d4bc --- /dev/null +++ b/js/packages/truapi-host-wasm/src/worker-runtime.ts @@ -0,0 +1,433 @@ +/// +// Worker entrypoint. Loads the web-targeted truapi-server WASM bundle and +// bridges every host callback over postMessage. The main thread keeps the +// state that needs DOM access (localStorage, prompts) while the core dispatcher +// runs here off the page main thread. + +import type { + MainToWorker, + SubscriptionName, + WorkerToMain, +} from "./worker-protocol.js"; +import { + createWorkerRawCallbacks, + type CallbackName, +} from "./generated/worker-callbacks.js"; +import { errorMessage } from "./error.js"; + +type PermissionAuthorizationStatus = + | "NotDetermined" + | "Denied" + | "Authorized"; + +interface WorkerHostCore { + receiveFrame(frame: Uint8Array): Promise; + disconnectSession(): Promise; + cancelPairing(): void; + notifySessionStoreChanged(): void; + permissionAuthorizationStatus(request: Uint8Array): Promise; + permissionAuthorizationStatuses(requests: Uint8Array[]): Promise; + setPermissionAuthorizationStatus( + request: Uint8Array, + status: string, + ): Promise; + dispose(): void; + free(): void; +} + +interface WasmModuleShape { + default: (input?: unknown) => Promise; + WasmHostCore: new ( + callbacks: unknown, + runtimeConfig: unknown, + ) => WorkerHostCore; + setLogLevel?: (level: string) => void; +} + +// Resolved at runtime, the wasm-pack artifact lives outside `src/` so a +// static import would leak into the TS rootDir. The relative path is +// resolved against `dist/worker-runtime.js` once compiled. Indirected +// through a variable so TS skips the static module-existence check. +const WASM_WEB_PATH = "./wasm/web/truapi_server.js"; +const wasmModulePromise = import( + /* @vite-ignore */ WASM_WEB_PATH +) as Promise; + +const ctx = self as unknown as DedicatedWorkerGlobalScope; + +function postToMain(msg: WorkerToMain): void { + ctx.postMessage(msg); +} + +let nextRequestId = 0; +const pendingCallbacks = new Map< + number, + (result: { ok: true; value: unknown } | { ok: false; error: string }) => void +>(); + +let nextSubId = 0; +const subscriptionItemListeners = new Map void>(); + +let nextConnId = 0; +type ChainConnectAck = { ok: true } | { ok: false; error: string }; +const chainConnectAcks = new Map void>(); +const chainResponseListeners = new Map void>(); + +function callbackRequest( + name: CallbackName, + args: readonly unknown[], +): Promise { + return new Promise((resolve, reject) => { + const requestId = ++nextRequestId; + pendingCallbacks.set(requestId, (r) => { + if (r.ok) resolve(r.value); + else reject(new Error(r.error)); + }); + postToMain({ kind: "callbackRequest", requestId, name, args }); + }); +} + +function startSubscription( + name: SubscriptionName, + payload: Uint8Array | null, + sendItem: (value: T) => void, +): () => void { + const subId = ++nextSubId; + subscriptionItemListeners.set(subId, sendItem as (value: unknown) => void); + postToMain({ kind: "subscriptionStart", subId, name, payload }); + return () => { + subscriptionItemListeners.delete(subId); + postToMain({ kind: "subscriptionStop", subId }); + }; +} + +interface WorkerChainConnection { + send(request: string): void; + close(): void; +} + +/** + * Worker-side half of the host chain-connect bridge. + * + * The Rust core runs in this worker but owns no socket. When it needs chain + * access (chainHead v1 for People-chain identity / statement-store SSO) it + * calls this; the actual transport lives on the host main thread and is reached + * over postMessage. The data crossing here is JSON-RPC strings, not SCALE: only + * the product<->core wire is SCALE. + * + * per-tab / sandboxed core-owned (this Web Worker) host-owned (main thread) + * +-------------------+ SCALE +--------------------------+ +--------------------------------+ + * | Product (iframe) |<------->| truapi-server WASM core | | host.connect() (ChainProvider) | + * | speaks TrUAPI | frames | chainHead v1, SSO, | | host-owned JSON-RPC transport | + * | never sees chains | | People-chain identity | | remote RPC, native client, ... | + * +-------------------+ +--------------------------+ +--------------------------------+ + * | ^ JSON-RPC strings (not SCALE) ^ | + * chainConnect() | | onResponse(json) connect | | responses() + * (this fn) v | | v + * worker-runtime.ts <======== postMessage ========> create-worker-host-runtime.ts + * chainConnectStart / chainSend / chainClose --> handleChainConnect* -> host.connect() + * chainConnectAck / chainResponse <-- (pumped from connection.responses()) + * + * Allocates a `connId`, posts `chainConnectStart`, and resolves a + * `{ send, close }` handle once the main thread acks. `send` posts `chainSend`, + * `close` posts `chainClose`, and every `chainResponse` for this `connId` is + * delivered to `onResponse`. + */ +function chainConnect( + genesisHash: string, + onResponse: (json: string) => void, +): Promise { + const connId = ++nextConnId; + return new Promise((resolve, reject) => { + chainConnectAcks.set(connId, (ack) => { + if (!ack.ok) { + chainResponseListeners.delete(connId); + reject(new Error(ack.error)); + return; + } + resolve({ + send(request: string) { + postToMain({ kind: "chainSend", connId, request }); + }, + close() { + chainResponseListeners.delete(connId); + postToMain({ kind: "chainClose", connId }); + }, + }); + }); + chainResponseListeners.set(connId, onResponse); + postToMain({ kind: "chainConnectStart", connId, genesisHash }); + }); +} + +/** + * Build the callback object passed to the WASM core. Most entries are + * generated proxy functions that bounce from the worker to the main window; + * `emitFrame` is filled here because it is the core-to-provider data path. + */ +function buildRawCallbacks() { + const callbacks = createWorkerRawCallbacks({ + callbackRequest, + startSubscription, + chainConnect, + }); + callbacks.emitFrame = (frame: Uint8Array): void => { + postToMain({ kind: "frame", bytes: frame }); + }; + callbacks.dispose = (): void => { + // Main thread terminates the worker, no separate cleanup needed here. + }; + return callbacks; +} + +let core: WorkerHostCore | null = null; +let wasm: WasmModuleShape | null = null; + +(async () => { + try { + wasm = await wasmModulePromise; + await wasm.default(); + postToMain({ kind: "loaded" }); + } catch (err) { + postToMain({ kind: "fatalError", error: errorMessage(err) }); + } +})(); + +ctx.addEventListener("message", (ev: MessageEvent) => { + const msg = ev.data; + switch (msg.kind) { + case "init": + if (!wasm) { + postToMain({ + kind: "fatalError", + error: "init received before WASM loaded", + }); + break; + } + if (core) { + postToMain({ + kind: "fatalError", + error: "init: core already initialized", + }); + break; + } + wasm.setLogLevel?.(msg.logLevel); + try { + core = new wasm.WasmHostCore(buildRawCallbacks(), msg.runtimeConfig); + postToMain({ kind: "ready" }); + } catch (err) { + postToMain({ kind: "fatalError", error: `init: ${errorMessage(err)}` }); + } + break; + case "setLogLevel": + wasm?.setLogLevel?.(msg.level); + break; + case "frame": + void handleFrame(msg.bytes); + break; + case "disconnectSession": + void handleDisconnectSession(msg.requestId); + break; + case "cancelPairing": + core?.cancelPairing(); + break; + case "notifySessionStoreChanged": + core?.notifySessionStoreChanged(); + break; + case "getPermissionAuthorizationStatus": + void handleGetPermissionAuthorizationStatus(msg.requestId, msg.request); + break; + case "getPermissionAuthorizationStatuses": + void handleGetPermissionAuthorizationStatuses( + msg.requestId, + msg.requests, + ); + break; + case "setPermissionAuthorizationStatus": + void handleSetPermissionAuthorizationStatus( + msg.requestId, + msg.request, + msg.status, + ); + break; + case "callbackResponse": { + const cb = pendingCallbacks.get(msg.requestId); + if (cb) { + pendingCallbacks.delete(msg.requestId); + cb( + msg.ok + ? { ok: true, value: msg.value } + : { ok: false, error: msg.error }, + ); + } + break; + } + case "subscriptionItem": { + const listener = subscriptionItemListeners.get(msg.subId); + if (listener) listener(msg.value); + break; + } + case "chainConnectAck": { + const cb = chainConnectAcks.get(msg.connId); + if (cb) { + chainConnectAcks.delete(msg.connId); + cb(msg.ok ? { ok: true } : { ok: false, error: msg.error }); + } + break; + } + case "chainResponse": { + const listener = chainResponseListeners.get(msg.connId); + if (listener) listener(msg.json); + break; + } + case "dispose": + try { + core?.dispose(); + core?.free(); + } catch (err) { + postToMain({ kind: "disposeError", error: errorMessage(err) }); + } + core = null; + break; + default: { + const { kind } = msg as { kind?: unknown }; + console.warn( + `[truapi worker-runtime] unknown message kind: ${String(kind)}`, + ); + } + } +}); + +async function handleDisconnectSession(requestId: number): Promise { + if (!core) { + postToMain({ + kind: "disconnectSessionResponse", + requestId, + ok: false, + error: "disconnectSession received before core is ready", + }); + return; + } + try { + await core.disconnectSession(); + postToMain({ kind: "disconnectSessionResponse", requestId, ok: true }); + } catch (err) { + postToMain({ + kind: "disconnectSessionResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +async function handleGetPermissionAuthorizationStatus( + requestId: number, + request: Uint8Array, +): Promise { + if (!core) { + postToMain({ + kind: "permissionAuthorizationStatusResponse", + requestId, + ok: false, + error: "permissionAuthorizationStatus received before core is ready", + }); + return; + } + try { + const status = await core.permissionAuthorizationStatus(request); + postToMain({ + kind: "permissionAuthorizationStatusResponse", + requestId, + ok: true, + status: status as PermissionAuthorizationStatus, + }); + } catch (err) { + postToMain({ + kind: "permissionAuthorizationStatusResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +async function handleGetPermissionAuthorizationStatuses( + requestId: number, + requests: Uint8Array[], +): Promise { + if (!core) { + postToMain({ + kind: "permissionAuthorizationStatusesResponse", + requestId, + ok: false, + error: "permissionAuthorizationStatuses received before core is ready", + }); + return; + } + try { + const statuses = await core.permissionAuthorizationStatuses(requests); + postToMain({ + kind: "permissionAuthorizationStatusesResponse", + requestId, + ok: true, + statuses: statuses as PermissionAuthorizationStatus[], + }); + } catch (err) { + postToMain({ + kind: "permissionAuthorizationStatusesResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +async function handleSetPermissionAuthorizationStatus( + requestId: number, + request: Uint8Array, + status: PermissionAuthorizationStatus, +): Promise { + if (!core) { + postToMain({ + kind: "setPermissionAuthorizationStatusResponse", + requestId, + ok: false, + error: "setPermissionAuthorizationStatus received before core is ready", + }); + return; + } + try { + await core.setPermissionAuthorizationStatus(request, status); + postToMain({ + kind: "setPermissionAuthorizationStatusResponse", + requestId, + ok: true, + }); + } catch (err) { + postToMain({ + kind: "setPermissionAuthorizationStatusResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +async function handleFrame(bytes: Uint8Array): Promise { + if (!core) { + postToMain({ + kind: "frameError", + error: "frame received before core is ready", + }); + return; + } + try { + await core.receiveFrame(bytes); + } catch (err) { + postToMain({ + kind: "frameError", + error: errorMessage(err), + }); + } +} diff --git a/js/packages/truapi-host-wasm/tsconfig.json b/js/packages/truapi-host-wasm/tsconfig.json new file mode 100644 index 00000000..9d2dcad8 --- /dev/null +++ b/js/packages/truapi-host-wasm/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "composite": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM", "WebWorker"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/test-support.ts"], + "references": [{ "path": "../truapi" }] +} diff --git a/js/packages/truapi/package.json b/js/packages/truapi/package.json index 6b8e2f61..db961565 100644 --- a/js/packages/truapi/package.json +++ b/js/packages/truapi/package.json @@ -66,7 +66,7 @@ }, "scripts": { "ensure-generated": "./scripts/ensure-generated.sh", - "build": "tsc", + "build": "tsc -b", "prebuild": "npm run ensure-generated", "codegen": "cargo run -p truapi-codegen -- --input ../../../target/doc/truapi.json --output src/generated --playground-output src/playground --explorer-output src/explorer", "typecheck": "npm run build", diff --git a/js/packages/truapi/src/client.test.ts b/js/packages/truapi/src/client.test.ts index 2f8d2023..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", () => { @@ -86,8 +115,29 @@ 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 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", () => { @@ -131,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); @@ -227,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, }), }, }), @@ -245,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); }); @@ -260,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", @@ -277,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/js/packages/truapi/src/client.ts b/js/packages/truapi/src/client.ts index fc13022d..f45481f4 100644 --- a/js/packages/truapi/src/client.ts +++ b/js/packages/truapi/src/client.ts @@ -13,13 +13,15 @@ import { type WireProvider, } from "./transport.js"; import { + CallError, indexedTaggedUnion, Result, _void, + type CallErrorValue, type Codec, type ResultPayload, } from "./scale.js"; -import { TRUAPI_CODEC_VERSION, TRUAPI_VERSION } from "./generated/client.js"; +import { TRUAPI_CODEC_VERSION } from "./generated/client.js"; import * as T from "./generated/types.js"; import * as W from "./generated/wire-table.js"; @@ -29,13 +31,12 @@ export type { Subscription, TrUApiTransport }; * Version overrides used when constructing a transport. */ export interface CreateTransportOptions { - /** - * Highest TrUAPI protocol version exposed by the transport. - */ - truapiVersion?: number; - /** * SCALE codec version advertised during host handshake negotiation. + * + * @deprecated TODO(shared-core-wire): remove this override with + * `TrUApiTransport.codecVersion` once generated handshake requests use + * `TRUAPI_CODEC_VERSION` directly. */ codecVersion?: number; } @@ -51,7 +52,10 @@ function protocolVersionTag(version: number): `V${number}` { return `V${version}` as `V${number}`; } -type HandshakeResponse = ResultPayload; +type HandshakeResponse = ResultPayload< + undefined, + CallErrorValue +>; const HANDSHAKE_WIRE_VERSION = 1; /** @@ -63,7 +67,7 @@ function handshakeResponseCodec( return indexedTaggedUnion({ [protocolVersionTag(version)]: [ version - 1, - Result(_void, T.HostHandshakeError), + Result(_void, CallError(T.VersionedHostHandshakeError)), ] as const, }) as Codec<{ tag: `V${number}`; value: HandshakeResponse }>; } @@ -90,8 +94,14 @@ function encodeUnsupportedHandshakeResponse(version: number): Uint8Array { value: { success: false, value: { - tag: "UnsupportedProtocolVersion", - value: undefined, + tag: "Domain", + value: { + tag: "V1", + value: { + tag: "UnsupportedProtocolVersion", + value: undefined, + }, + }, }, }, }); @@ -140,7 +150,6 @@ export function createTransport( provider: WireProvider, options: CreateTransportOptions = {}, ): TrUApiTransport { - const truapiVersion = options.truapiVersion ?? TRUAPI_VERSION; const codecVersion = options.codecVersion ?? TRUAPI_CODEC_VERSION; let idCounter = 0; let closedError: Error | null = null; @@ -305,7 +314,6 @@ export function createTransport( } return { - truapiVersion, codecVersion, /** * Send one request frame and resolve with the typed Ok/Err outcome diff --git a/js/packages/truapi/src/explorer/codegen/versions/0.3.2/services.ts b/js/packages/truapi/src/explorer/codegen/versions/0.3.2/services.ts deleted file mode 100644 index 8916c096..00000000 --- a/js/packages/truapi/src/explorer/codegen/versions/0.3.2/services.ts +++ /dev/null @@ -1,724 +0,0 @@ -// Auto-generated by truapi-codegen. Do not edit. -import type { ServiceInfo } from "../../../../playground/services-types.js"; - -export const services: ServiceInfo[] = [ - { - name: "Account", - methods: [ - { - name: "connection_status_subscribe", - type: "subscription", - signature: - "connectionStatusSubscribe(): ObservableLike", - docUrl: - "api/account/trait.Account.html#method.connection_status_subscribe", - description: "Subscribe to account connection status changes.", - responseType: "host-account-connection-status-subscribe-item", - }, - { - name: "get_account", - type: "unary", - signature: - "getAccount(request: HostAccountGetRequest): Promise>", - docUrl: "api/account/trait.Account.html#method.get_account", - description: "Retrieve a product-scoped account.", - requestDescription: "HostAccountGetRequest", - requestType: "host-account-get-request", - responseType: "host-account-get-response", - errorType: "host-account-get-error", - }, - { - name: "get_account_alias", - type: "unary", - signature: - "getAccountAlias(request: HostAccountGetAliasRequest): Promise>", - docUrl: "api/account/trait.Account.html#method.get_account_alias", - description: "Retrieve a contextual alias for a product account.", - requestDescription: "HostAccountGetAliasRequest", - requestType: "host-account-get-alias-request", - responseType: "host-account-get-alias-response", - errorType: "host-account-get-error", - }, - { - name: "create_account_proof", - type: "unary", - signature: - "createAccountProof(request: HostAccountCreateProofRequest): Promise>", - docUrl: "api/account/trait.Account.html#method.create_account_proof", - description: "Generate a ring VRF proof for a product account.", - requestDescription: "HostAccountCreateProofRequest", - requestType: "host-account-create-proof-request", - responseType: "host-account-create-proof-response", - errorType: "host-account-create-proof-error", - }, - { - name: "get_legacy_accounts", - type: "unary", - signature: - "getLegacyAccounts(): Promise>", - docUrl: "api/account/trait.Account.html#method.get_legacy_accounts", - description: "List non-product accounts the user owns.", - responseType: "host-get-legacy-accounts-response", - errorType: "host-account-get-error", - }, - { - name: "get_user_id", - type: "unary", - signature: - "getUserId(): Promise>", - docUrl: "api/account/trait.Account.html#method.get_user_id", - description: "Fetch the user's primary identity.", - responseType: "host-get-user-id-response", - errorType: "host-get-user-id-error", - }, - { - name: "request_login", - type: "unary", - signature: - "requestLogin(request: HostRequestLoginRequest): Promise>", - docUrl: "api/account/trait.Account.html#method.request_login", - description: - 'Request the host to present the login flow to the user.\n\nProducts should call this in response to a user action (e.g. tapping a\n"Sign in" button), not automatically on load.', - requestDescription: "HostRequestLoginRequest", - requestType: "host-request-login-request", - responseType: "host-request-login-response", - errorType: "host-request-login-error", - }, - ], - }, - { - name: "Chain", - methods: [ - { - name: "follow_head_subscribe", - type: "subscription", - signature: - "followHeadSubscribe(request: RemoteChainHeadFollowRequest): ObservableLike", - docUrl: "api/chain/trait.Chain.html#method.follow_head_subscribe", - description: "Follow the chain head and receive block events.", - requestDescription: "RemoteChainHeadFollowRequest", - requestType: "remote-chain-head-follow-request", - responseType: "remote-chain-head-follow-item", - }, - { - name: "get_head_header", - type: "unary", - signature: - "getHeadHeader(request: RemoteChainHeadHeaderRequest): Promise>", - docUrl: "api/chain/trait.Chain.html#method.get_head_header", - description: "Fetch a block header.", - requestDescription: "RemoteChainHeadHeaderRequest", - requestType: "remote-chain-head-header-request", - responseType: "remote-chain-head-header-response", - errorType: "generic-error", - }, - { - name: "get_head_body", - type: "unary", - signature: - "getHeadBody(request: RemoteChainHeadBodyRequest): Promise>", - docUrl: "api/chain/trait.Chain.html#method.get_head_body", - description: "Fetch a block body.", - requestDescription: "RemoteChainHeadBodyRequest", - requestType: "remote-chain-head-body-request", - responseType: "remote-chain-head-body-response", - errorType: "generic-error", - }, - { - name: "get_head_storage", - type: "unary", - signature: - "getHeadStorage(request: RemoteChainHeadStorageRequest): Promise>", - docUrl: "api/chain/trait.Chain.html#method.get_head_storage", - description: "Query runtime storage at a specific block.", - requestDescription: "RemoteChainHeadStorageRequest", - requestType: "remote-chain-head-storage-request", - responseType: "remote-chain-head-storage-response", - errorType: "generic-error", - }, - { - name: "call_head", - type: "unary", - signature: - "callHead(request: RemoteChainHeadCallRequest): Promise>", - docUrl: "api/chain/trait.Chain.html#method.call_head", - description: "Invoke a runtime call at a specific block.", - requestDescription: "RemoteChainHeadCallRequest", - requestType: "remote-chain-head-call-request", - responseType: "remote-chain-head-call-response", - errorType: "generic-error", - }, - { - name: "unpin_head", - type: "unary", - signature: - "unpinHead(request: RemoteChainHeadUnpinRequest): Promise>", - docUrl: "api/chain/trait.Chain.html#method.unpin_head", - description: "Release pinned blocks.", - requestDescription: "RemoteChainHeadUnpinRequest", - requestType: "remote-chain-head-unpin-request", - errorType: "generic-error", - }, - { - name: "continue_head", - type: "unary", - signature: - "continueHead(request: RemoteChainHeadContinueRequest): Promise>", - docUrl: "api/chain/trait.Chain.html#method.continue_head", - description: "Continue a paused chain-head operation.", - requestDescription: "RemoteChainHeadContinueRequest", - requestType: "remote-chain-head-continue-request", - errorType: "generic-error", - }, - { - name: "stop_head_operation", - type: "unary", - signature: - "stopHeadOperation(request: RemoteChainHeadStopOperationRequest): Promise>", - docUrl: "api/chain/trait.Chain.html#method.stop_head_operation", - description: "Stop a chain-head operation.", - requestDescription: "RemoteChainHeadStopOperationRequest", - requestType: "remote-chain-head-stop-operation-request", - errorType: "generic-error", - }, - { - name: "get_spec_genesis_hash", - type: "unary", - signature: - "getSpecGenesisHash(request: RemoteChainSpecGenesisHashRequest): Promise>", - docUrl: "api/chain/trait.Chain.html#method.get_spec_genesis_hash", - description: "Fetch the canonical genesis hash for a chain.", - requestDescription: "RemoteChainSpecGenesisHashRequest", - requestType: "remote-chain-spec-genesis-hash-request", - responseType: "remote-chain-spec-genesis-hash-response", - errorType: "generic-error", - }, - { - name: "get_spec_chain_name", - type: "unary", - signature: - "getSpecChainName(request: RemoteChainSpecChainNameRequest): Promise>", - docUrl: "api/chain/trait.Chain.html#method.get_spec_chain_name", - description: "Fetch the display name of a chain.", - requestDescription: "RemoteChainSpecChainNameRequest", - requestType: "remote-chain-spec-chain-name-request", - responseType: "remote-chain-spec-chain-name-response", - errorType: "generic-error", - }, - { - name: "get_spec_properties", - type: "unary", - signature: - "getSpecProperties(request: RemoteChainSpecPropertiesRequest): Promise>", - docUrl: "api/chain/trait.Chain.html#method.get_spec_properties", - description: "Fetch the JSON-encoded properties of a chain.", - requestDescription: "RemoteChainSpecPropertiesRequest", - requestType: "remote-chain-spec-properties-request", - responseType: "remote-chain-spec-properties-response", - errorType: "generic-error", - }, - { - name: "broadcast_transaction", - type: "unary", - signature: - "broadcastTransaction(request: RemoteChainTransactionBroadcastRequest): Promise>", - docUrl: "api/chain/trait.Chain.html#method.broadcast_transaction", - description: "Broadcast a signed transaction.", - requestDescription: "RemoteChainTransactionBroadcastRequest", - requestType: "remote-chain-transaction-broadcast-request", - responseType: "remote-chain-transaction-broadcast-response", - errorType: "generic-error", - }, - { - name: "stop_transaction", - type: "unary", - signature: - "stopTransaction(request: RemoteChainTransactionStopRequest): Promise>", - docUrl: "api/chain/trait.Chain.html#method.stop_transaction", - description: "Stop a transaction broadcast.", - requestDescription: "RemoteChainTransactionStopRequest", - requestType: "remote-chain-transaction-stop-request", - errorType: "generic-error", - }, - ], - }, - { - name: "Chat", - methods: [ - { - name: "create_room", - type: "unary", - signature: - "createRoom(request: HostChatCreateRoomRequest): Promise>", - docUrl: "api/chat/trait.Chat.html#method.create_room", - description: "Create a chat room.", - requestDescription: "HostChatCreateRoomRequest", - requestType: "host-chat-create-room-request", - responseType: "host-chat-create-room-response", - errorType: "host-chat-create-room-error", - }, - { - name: "register_bot", - type: "unary", - signature: - "registerBot(request: HostChatRegisterBotRequest): Promise>", - docUrl: "api/chat/trait.Chat.html#method.register_bot", - description: "Register a chat bot.", - requestDescription: "HostChatRegisterBotRequest", - requestType: "host-chat-register-bot-request", - responseType: "host-chat-register-bot-response", - errorType: "host-chat-register-bot-error", - }, - { - name: "list_subscribe", - type: "subscription", - signature: "listSubscribe(): ObservableLike", - docUrl: "api/chat/trait.Chat.html#method.list_subscribe", - description: "Subscribe to the list of chat rooms.", - responseType: "host-chat-list-subscribe-item", - }, - { - name: "post_message", - type: "unary", - signature: - "postMessage(request: HostChatPostMessageRequest): Promise>", - docUrl: "api/chat/trait.Chat.html#method.post_message", - description: "Post a message to a chat room.", - requestDescription: "HostChatPostMessageRequest", - requestType: "host-chat-post-message-request", - responseType: "host-chat-post-message-response", - errorType: "host-chat-post-message-error", - }, - { - name: "action_subscribe", - type: "subscription", - signature: - "actionSubscribe(): ObservableLike", - docUrl: "api/chat/trait.Chat.html#method.action_subscribe", - description: "Subscribe to received chat actions.", - responseType: "host-chat-action-subscribe-item", - }, - { - name: "custom_message_render_subscribe", - type: "subscription", - signature: - "customMessageRenderSubscribe(request: ProductChatCustomMessageRenderSubscribeRequest): ObservableLike", - docUrl: - "api/chat/trait.Chat.html#method.custom_message_render_subscribe", - description: - "Subscribe to custom message render requests from the host. Each\nemitted item is a [`CustomRendererNode`](crate::v01::CustomRendererNode)\ntree describing the rendered UI.", - requestDescription: "ProductChatCustomMessageRenderSubscribeRequest", - requestType: "product-chat-custom-message-render-subscribe-request", - responseType: "custom-renderer-node", - }, - ], - }, - { - name: "Entropy", - methods: [ - { - name: "derive", - type: "unary", - signature: - "derive(request: HostDeriveEntropyRequest): Promise>", - docUrl: "api/entropy/trait.Entropy.html#method.derive", - description: "Derive deterministic entropy.", - requestDescription: "HostDeriveEntropyRequest", - requestType: "host-derive-entropy-request", - responseType: "host-derive-entropy-response", - errorType: "host-derive-entropy-error", - }, - ], - }, - { - name: "Local Storage", - methods: [ - { - name: "read", - type: "unary", - signature: - "read(request: HostLocalStorageReadRequest): Promise>", - docUrl: "api/local_storage/trait.LocalStorage.html#method.read", - description: "Read a value by key.", - requestDescription: "HostLocalStorageReadRequest", - requestType: "host-local-storage-read-request", - responseType: "host-local-storage-read-response", - errorType: "host-local-storage-read-error", - }, - { - name: "write", - type: "unary", - signature: - "write(request: HostLocalStorageWriteRequest): Promise>", - docUrl: "api/local_storage/trait.LocalStorage.html#method.write", - description: "Write a value to a key.", - requestDescription: "HostLocalStorageWriteRequest", - requestType: "host-local-storage-write-request", - errorType: "host-local-storage-read-error", - }, - { - name: "clear", - type: "unary", - signature: - "clear(request: HostLocalStorageClearRequest): Promise>", - docUrl: "api/local_storage/trait.LocalStorage.html#method.clear", - description: "Clear a value by key.", - requestDescription: "HostLocalStorageClearRequest", - requestType: "host-local-storage-clear-request", - errorType: "host-local-storage-read-error", - }, - ], - }, - { - name: "Notifications", - methods: [ - { - name: "send_push_notification", - type: "unary", - signature: - "sendPushNotification(request: HostPushNotificationRequest): Promise>", - docUrl: - "api/notifications/trait.Notifications.html#method.send_push_notification", - description: - "Send a push notification to the user.\n\nReturns a [`NotificationId`](crate::v01::NotificationId) that can be\npassed to [`cancel_push_notification`](Self::cancel_push_notification)\nto retract a scheduled notification. When `scheduled_at` is set the host\npersists the notification across restarts and fires it through the\nplatform-native scheduler. See [RFC 0019].\n\n[RFC 0019]: https://github.com/paritytech/truapi/blob/main/docs/rfcs/0019-scheduled-notifications.md", - requestDescription: "HostPushNotificationRequest", - requestType: "host-push-notification-request", - responseType: "host-push-notification-response", - errorType: "host-push-notification-error", - }, - { - name: "cancel_push_notification", - type: "unary", - signature: - "cancelPushNotification(request: HostPushNotificationCancelRequest): Promise>", - docUrl: - "api/notifications/trait.Notifications.html#method.cancel_push_notification", - description: - "Cancels a previously issued push notification.\n\nCancellation is idempotent: returns `Ok(())` whether the notification is\nstill pending, already fired, or was never issued. See [RFC 0019].\n\n[RFC 0019]: https://github.com/paritytech/truapi/blob/main/docs/rfcs/0019-scheduled-notifications.md", - requestDescription: "HostPushNotificationCancelRequest", - requestType: "host-push-notification-cancel-request", - errorType: "generic-error", - }, - ], - }, - { - name: "Payment", - methods: [ - { - name: "balance_subscribe", - type: "subscription", - signature: - "balanceSubscribe(request: HostPaymentBalanceSubscribeRequest): ObservableLike", - docUrl: "api/payment/trait.Payment.html#method.balance_subscribe", - description: "Subscribe to payment balance updates.", - requestDescription: "HostPaymentBalanceSubscribeRequest", - requestType: "host-payment-balance-subscribe-request", - responseType: "host-payment-balance-subscribe-item", - errorType: "host-payment-balance-subscribe-error", - }, - { - name: "top_up", - type: "unary", - signature: - "topUp(request: HostPaymentTopUpRequest): Promise>", - docUrl: "api/payment/trait.Payment.html#method.top_up", - description: "Top up the user's payment balance.", - requestDescription: "HostPaymentTopUpRequest", - requestType: "host-payment-top-up-request", - errorType: "host-payment-top-up-error", - }, - { - name: "request", - type: "unary", - signature: - "request(request: HostPaymentRequest): Promise>", - docUrl: "api/payment/trait.Payment.html#method.request", - description: "Request a payment from the user.", - requestDescription: "HostPaymentRequest", - requestType: "host-payment-request", - responseType: "host-payment-response", - errorType: "host-payment-error", - }, - { - name: "status_subscribe", - type: "subscription", - signature: - "statusSubscribe(request: HostPaymentStatusSubscribeRequest): ObservableLike", - docUrl: "api/payment/trait.Payment.html#method.status_subscribe", - description: - "Subscribe to payment lifecycle updates for a specific payment.", - requestDescription: "HostPaymentStatusSubscribeRequest", - requestType: "host-payment-status-subscribe-request", - responseType: "host-payment-status-subscribe-item", - errorType: "host-payment-status-subscribe-error", - }, - ], - }, - { - name: "Permissions", - methods: [ - { - name: "request_device_permission", - type: "unary", - signature: - "requestDevicePermission(request: HostDevicePermissionRequest): Promise>", - docUrl: - "api/permissions/trait.Permissions.html#method.request_device_permission", - description: "Request a device-capability permission from the user.", - requestDescription: - "Enum values: Notifications / Camera / Microphone / Bluetooth / NFC / Location / Clipboard / OpenUrl / Biometrics", - requestType: "host-device-permission-request", - responseType: "host-device-permission-response", - errorType: "generic-error", - }, - { - name: "request_remote_permission", - type: "unary", - signature: - "requestRemotePermission(request: RemotePermissionRequest): Promise>", - docUrl: - "api/permissions/trait.Permissions.html#method.request_remote_permission", - description: "Request a remote-operation permission.", - requestDescription: "RemotePermissionRequest", - requestType: "remote-permission-request", - responseType: "remote-permission-response", - errorType: "generic-error", - }, - ], - }, - { - name: "Preimage", - methods: [ - { - name: "lookup_subscribe", - type: "subscription", - signature: - "lookupSubscribe(request: RemotePreimageLookupSubscribeRequest): ObservableLike", - docUrl: "api/preimage/trait.Preimage.html#method.lookup_subscribe", - description: "Subscribe to preimage lookups for a given key.", - requestDescription: "RemotePreimageLookupSubscribeRequest", - requestType: "remote-preimage-lookup-subscribe-request", - responseType: "remote-preimage-lookup-subscribe-item", - }, - { - name: "submit", - type: "unary", - signature: - "submit(request: HexString): Promise>", - docUrl: "api/preimage/trait.Preimage.html#method.submit", - description: - "Submit a preimage. Returns the preimage key (hash) on success.", - requestDescription: "HexString", - errorType: "preimage-submit-error", - }, - ], - }, - { - name: "Resource Allocation", - methods: [ - { - name: "request", - type: "unary", - signature: - "request(request: HostRequestResourceAllocationRequest): Promise>", - docUrl: - "api/resource_allocation/trait.ResourceAllocation.html#method.request", - description: "Request the host to pre-allocate one or more resources.", - requestDescription: "HostRequestResourceAllocationRequest", - requestType: "host-request-resource-allocation-request", - responseType: "host-request-resource-allocation-response", - errorType: "resource-allocation-error", - }, - ], - }, - { - name: "Signing", - methods: [ - { - name: "create_transaction", - type: "unary", - signature: - "createTransaction(request: ProductAccountTxPayload): Promise>", - docUrl: "api/signing/trait.Signing.html#method.create_transaction", - description: "Construct a signed transaction for a product account.", - requestDescription: "ProductAccountTxPayload", - requestType: "product-account-tx-payload", - responseType: "host-create-transaction-response", - errorType: "host-create-transaction-error", - }, - { - name: "create_transaction_with_legacy_account", - type: "unary", - signature: - "createTransactionWithLegacyAccount(request: LegacyAccountTxPayload): Promise>", - docUrl: - "api/signing/trait.Signing.html#method.create_transaction_with_legacy_account", - description: - "Construct a signed transaction for a non-product (legacy) account.", - requestDescription: "LegacyAccountTxPayload", - requestType: "legacy-account-tx-payload", - responseType: "host-create-transaction-with-legacy-account-response", - errorType: "host-create-transaction-error", - }, - { - name: "sign_raw_with_legacy_account", - type: "unary", - signature: - "signRawWithLegacyAccount(request: HostSignRawWithLegacyAccountRequest): Promise>", - docUrl: - "api/signing/trait.Signing.html#method.sign_raw_with_legacy_account", - description: "Sign raw bytes with a non-product account.", - requestDescription: "HostSignRawWithLegacyAccountRequest", - requestType: "host-sign-raw-with-legacy-account-request", - responseType: "host-sign-payload-response", - errorType: "host-sign-payload-error", - }, - { - name: "sign_payload_with_legacy_account", - type: "unary", - signature: - "signPayloadWithLegacyAccount(request: HostSignPayloadWithLegacyAccountRequest): Promise>", - docUrl: - "api/signing/trait.Signing.html#method.sign_payload_with_legacy_account", - description: "Sign an extrinsic payload with a non-product account.", - requestDescription: "HostSignPayloadWithLegacyAccountRequest", - requestType: "host-sign-payload-with-legacy-account-request", - responseType: "host-sign-payload-response", - errorType: "host-sign-payload-error", - }, - { - name: "sign_raw", - type: "unary", - signature: - "signRaw(request: HostSignRawRequest): Promise>", - docUrl: "api/signing/trait.Signing.html#method.sign_raw", - description: "Sign raw bytes or a message.", - requestDescription: "HostSignRawRequest", - requestType: "host-sign-raw-request", - responseType: "host-sign-payload-response", - errorType: "host-sign-payload-error", - }, - { - name: "sign_payload", - type: "unary", - signature: - "signPayload(request: HostSignPayloadRequest): Promise>", - docUrl: "api/signing/trait.Signing.html#method.sign_payload", - description: "Sign an extrinsic payload.", - requestDescription: "HostSignPayloadRequest", - requestType: "host-sign-payload-request", - responseType: "host-sign-payload-response", - errorType: "host-sign-payload-error", - }, - ], - }, - { - name: "Statement Store", - methods: [ - { - name: "subscribe", - type: "subscription", - signature: - "subscribe(request: RemoteStatementStoreSubscribeRequest): ObservableLike", - docUrl: - "api/statement_store/trait.StatementStore.html#method.subscribe", - description: "Subscribe to statements matching a topic filter.", - requestDescription: "RemoteStatementStoreSubscribeRequest", - requestType: "remote-statement-store-subscribe-request", - responseType: "remote-statement-store-subscribe-item", - }, - { - name: "create_proof", - type: "unary", - signature: - "createProof(request: RemoteStatementStoreCreateProofRequest): Promise>", - docUrl: - "api/statement_store/trait.StatementStore.html#method.create_proof", - description: - "Create a proof for a statement.\n\n**Deprecated:** use [`create_proof_authorized`](Self::create_proof_authorized)\ninstead, which uses a pre-allocated allowance account and does not\nrequire a per-call signing prompt.", - requestDescription: "RemoteStatementStoreCreateProofRequest", - requestType: "remote-statement-store-create-proof-request", - responseType: "remote-statement-store-create-proof-response", - errorType: "remote-statement-store-create-proof-error", - }, - { - name: "submit", - type: "unary", - signature: - "submit(request: SignedStatement): Promise>", - docUrl: "api/statement_store/trait.StatementStore.html#method.submit", - description: - "Submit a signed statement to the network. The request body is the\n[`SignedStatement`](crate::v01::SignedStatement) directly (no wrapping\nstruct), matching upstream `triangle-js-sdks`.", - requestDescription: "SignedStatement", - requestType: "signed-statement", - errorType: "generic-error", - }, - { - name: "create_proof_authorized", - type: "unary", - signature: - "createProofAuthorized(request: Statement): Promise>", - docUrl: - "api/statement_store/trait.StatementStore.html#method.create_proof_authorized", - description: - "Create a proof for a statement using a pre-allocated allowance account,\nbypassing the per-call signing prompt.", - requestDescription: "Statement", - requestType: "statement", - responseType: "remote-statement-store-create-proof-response", - errorType: "remote-statement-store-create-proof-error", - }, - ], - }, - { - name: "System", - methods: [ - { - name: "handshake", - type: "unary", - signature: - "handshake(request: HostHandshakeRequest): Promise>", - docUrl: "api/system/trait.System.html#method.handshake", - description: "Negotiate the wire codec version with the product.", - requestDescription: "HostHandshakeRequest", - requestType: "host-handshake-request", - errorType: "host-handshake-error", - }, - { - name: "feature_supported", - type: "unary", - signature: - "featureSupported(request: HostFeatureSupportedRequest): Promise>", - docUrl: "api/system/trait.System.html#method.feature_supported", - description: "Query whether the host supports a specific feature.", - requestDescription: "HostFeatureSupportedRequest", - requestType: "host-feature-supported-request", - responseType: "host-feature-supported-response", - errorType: "generic-error", - }, - { - name: "navigate_to", - type: "unary", - signature: - "navigateTo(request: HostNavigateToRequest): Promise>", - docUrl: "api/system/trait.System.html#method.navigate_to", - description: "Request the host to open a URL.", - requestDescription: "HostNavigateToRequest", - requestType: "host-navigate-to-request", - errorType: "host-navigate-to-error", - }, - ], - }, - { - name: "Theme", - methods: [ - { - name: "subscribe", - type: "subscription", - signature: "subscribe(): ObservableLike", - docUrl: "api/theme/trait.Theme.html#method.subscribe", - description: "Subscribe to host theme changes.", - responseType: "host-theme-subscribe-item", - }, - ], - }, -]; diff --git a/js/packages/truapi/src/explorer/codegen/versions/0.3.2/types.ts b/js/packages/truapi/src/explorer/codegen/versions/0.3.2/types.ts deleted file mode 100644 index 37f778be..00000000 --- a/js/packages/truapi/src/explorer/codegen/versions/0.3.2/types.ts +++ /dev/null @@ -1,3836 +0,0 @@ -// Auto-generated by truapi-codegen. Do not edit. -import type { DataType } from "../../../data-types.js"; - -export const types: DataType[] = [ - { - id: "account-id", - name: "AccountId", - category: "transaction", - definition: "export type AccountId = HexString;", - description: - "A 32-byte raw account identifier used for legacy (non-product) accounts.", - }, - { - id: "action-trigger", - name: "ActionTrigger", - category: "chat", - definition: - "export interface ActionTrigger {\n messageId: string;\n actionId: string;\n payload?: HexString;\n}", - description: "Payload when a user clicks an action button.", - fields: [ - { - name: "message_id", - type: "string", - description: "Message containing the action.", - }, - { - name: "action_id", - type: "string", - description: "Which action was triggered.", - }, - { - name: "payload", - type: "HexString | undefined", - description: "Optional additional data.", - }, - ], - }, - { - id: "allocatable-resource", - name: "AllocatableResource", - category: "resource_allocation", - definition: - 'export type AllocatableResource =\n | { tag: "StatementStoreAllowance"; value?: undefined }\n | { tag: "BulletinAllowance"; value?: undefined }\n | { tag: "SmartContractAllowance"; value: number }\n | { tag: "AutoSigning"; value?: undefined }\n;', - description: - "A resource the host can pre-allocate on behalf of the product (RFC 0010).\n\nFor the slot-table allowances (`StatementStoreAllowance`,\n`BulletinAllowance`, `SmartContractAllowance`), pre-allocation is\nopportunistic and the host may also fulfil the allowance implicitly on the\nfirst submission. `AutoSigning` must be requested explicitly through this\ncall.", - variants: [ - { - name: "StatementStoreAllowance", - type: '{ tag: "StatementStoreAllowance"; value?: undefined }', - description: - "Statement Store slot allowance for the product's own allowance account.", - }, - { - name: "BulletinAllowance", - type: '{ tag: "BulletinAllowance"; value?: undefined }', - description: - "Bulletin chain slot allowance for the product's own allowance account.", - }, - { - name: "SmartContractAllowance", - type: '{ tag: "SmartContractAllowance"; value: number }', - description: - "Pre-warmed PGAS balance for the smart-contract account at the given\nderivation index.", - }, - { - name: "AutoSigning", - type: '{ tag: "AutoSigning"; value?: undefined }', - description: - "Permission to sign on the product's behalf without per-call user prompts.", - }, - ], - }, - { - id: "allocation-outcome", - name: "AllocationOutcome", - category: "resource_allocation", - definition: - 'export type AllocationOutcome = "Allocated" | "Rejected" | "NotAvailable";', - description: "Outcome of allocating a single resource (RFC 0010).", - variants: [ - { - name: "Allocated", - type: '{ tag: "Allocated"; value?: undefined }', - description: "Resource is now available for use.", - }, - { - name: "Rejected", - type: '{ tag: "Rejected"; value?: undefined }', - description: "User or host refused the allocation.", - }, - { - name: "NotAvailable", - type: '{ tag: "NotAvailable"; value?: undefined }', - description: - "Host cannot provide this resource on the current chain or environment.", - }, - ], - }, - { - id: "arrangement", - name: "Arrangement", - category: "chat", - definition: - 'export type Arrangement = "Start" | "End" | "Center" | "SpaceBetween" | "SpaceAround" | "SpaceEvenly";', - description: "Layout arrangement (like CSS flexbox `justify-content`).", - variants: [ - { - name: "Start", - type: '{ tag: "Start"; value?: undefined }', - }, - { - name: "End", - type: '{ tag: "End"; value?: undefined }', - }, - { - name: "Center", - type: '{ tag: "Center"; value?: undefined }', - }, - { - name: "SpaceBetween", - type: '{ tag: "SpaceBetween"; value?: undefined }', - }, - { - name: "SpaceAround", - type: '{ tag: "SpaceAround"; value?: undefined }', - }, - { - name: "SpaceEvenly", - type: '{ tag: "SpaceEvenly"; value?: undefined }', - }, - ], - }, - { - id: "background", - name: "Background", - category: "chat", - definition: - "export interface Background {\n color: ColorToken;\n shape?: Shape;\n}", - description: "Background styling.", - fields: [ - { - name: "color", - type: "ColorToken", - description: "Background color.", - }, - { - name: "shape", - type: "Shape | undefined", - description: "Background shape.", - }, - ], - }, - { - id: "balance", - name: "Balance", - category: "payment", - definition: "export type Balance = bigint;", - description: - "Balance amount for payment operations. Interpreted according to the host's\nsingle fixed payment asset (e.g. pUSD).", - }, - { - id: "border-style", - name: "BorderStyle", - category: "chat", - definition: - "export interface BorderStyle {\n width: Size;\n color: ColorToken;\n shape?: Shape;\n}", - description: "Border styling.", - fields: [ - { - name: "width", - type: "Size", - description: "Border width.", - }, - { - name: "color", - type: "ColorToken", - description: "Border color.", - }, - { - name: "shape", - type: "Shape | undefined", - description: "Border shape.", - }, - ], - }, - { - id: "box-props", - name: "BoxProps", - category: "chat", - definition: - "export interface BoxProps {\n contentAlignment?: ContentAlignment;\n}", - description: "Properties for a [`CustomRendererNode::Box`] container.", - fields: [ - { - name: "content_alignment", - type: "ContentAlignment | undefined", - description: "Content alignment within the box.", - }, - ], - }, - { - id: "button-props", - name: "ButtonProps", - category: "chat", - definition: - "export interface ButtonProps {\n text: string;\n variant?: ButtonVariant;\n enabled: boolean | undefined;\n loading: boolean | undefined;\n clickAction?: string;\n}", - description: "Properties for a [`CustomRendererNode::Button`].", - fields: [ - { - name: "text", - type: "string", - description: "Button label text.", - }, - { - name: "variant", - type: "ButtonVariant | undefined", - description: "Button style variant.", - }, - { - name: "enabled", - type: "boolean | undefined", - description: - "Whether the button is enabled. Absent leaves the default to the host.", - }, - { - name: "loading", - type: "boolean | undefined", - description: - "Whether the button shows a loading state. Absent leaves the default to the host.", - }, - { - name: "click_action", - type: "string | undefined", - description: "Action identifier triggered on click.", - }, - ], - }, - { - id: "button-variant", - name: "ButtonVariant", - category: "chat", - definition: 'export type ButtonVariant = "Primary" | "Secondary" | "Text";', - description: "Button style variants.", - variants: [ - { - name: "Primary", - type: '{ tag: "Primary"; value?: undefined }', - }, - { - name: "Secondary", - type: '{ tag: "Secondary"; value?: undefined }', - }, - { - name: "Text", - type: '{ tag: "Text"; value?: undefined }', - }, - ], - }, - { - id: "chat-action", - name: "ChatAction", - category: "chat", - definition: - "export interface ChatAction {\n actionId: string;\n title: string;\n}", - description: "A clickable action button in a chat message.", - fields: [ - { - name: "action_id", - type: "string", - description: "Action identifier.", - }, - { - name: "title", - type: "string", - description: "Button label.", - }, - ], - }, - { - id: "chat-action-layout", - name: "ChatActionLayout", - category: "chat", - definition: 'export type ChatActionLayout = "Column" | "Grid";', - description: "Layout for action buttons.", - variants: [ - { - name: "Column", - type: '{ tag: "Column"; value?: undefined }', - }, - { - name: "Grid", - type: '{ tag: "Grid"; value?: undefined }', - }, - ], - }, - { - id: "chat-action-payload", - name: "ChatActionPayload", - category: "chat", - definition: - 'export type ChatActionPayload =\n | { tag: "MessagePosted"; value: ChatMessageContent }\n | { tag: "ActionTriggered"; value: ActionTrigger }\n | { tag: "Command"; value: ChatCommand }\n;', - description: "Payload of a received chat action.", - variants: [ - { - name: "MessagePosted", - type: '{ tag: "MessagePosted"; value: ChatMessageContent }', - description: "A peer posted a message.", - }, - { - name: "ActionTriggered", - type: '{ tag: "ActionTriggered"; value: ActionTrigger }', - description: "A user triggered an action button.", - }, - { - name: "Command", - type: '{ tag: "Command"; value: ChatCommand }', - description: "A user issued a command.", - }, - ], - }, - { - id: "chat-actions", - name: "ChatActions", - category: "chat", - definition: - "export interface ChatActions {\n text?: string;\n actions: Array;\n layout: ChatActionLayout;\n}", - description: "A set of action buttons with optional text.", - fields: [ - { - name: "text", - type: "string | undefined", - description: "Optional message text.", - }, - { - name: "actions", - type: "Array", - description: "List of action buttons.", - }, - { - name: "layout", - type: "ChatActionLayout", - description: "`Column` or `Grid` layout.", - }, - ], - }, - { - id: "chat-bot-registration-status", - name: "ChatBotRegistrationStatus", - category: "chat", - definition: 'export type ChatBotRegistrationStatus = "New" | "Exists";', - description: "Whether the bot was newly registered or already existed.", - variants: [ - { - name: "New", - type: '{ tag: "New"; value?: undefined }', - }, - { - name: "Exists", - type: '{ tag: "Exists"; value?: undefined }', - }, - ], - }, - { - id: "chat-command", - name: "ChatCommand", - category: "chat", - definition: - "export interface ChatCommand {\n command: string;\n payload: string;\n}", - description: "A slash command from a chat user.", - fields: [ - { - name: "command", - type: "string", - description: "Command name.", - }, - { - name: "payload", - type: "string", - description: "Command arguments.", - }, - ], - }, - { - id: "chat-custom-message", - name: "ChatCustomMessage", - category: "chat", - definition: - "export interface ChatCustomMessage {\n messageType: string;\n payload: HexString;\n}", - description: - "A custom message with application-defined type and binary payload.", - fields: [ - { - name: "message_type", - type: "string", - description: "Application-defined type key.", - }, - { - name: "payload", - type: "HexString", - description: "Binary payload.", - }, - ], - }, - { - id: "chat-file", - name: "ChatFile", - category: "chat", - definition: - "export interface ChatFile {\n url: string;\n fileName: string;\n mimeType: string;\n sizeBytes: bigint;\n text?: string;\n}", - description: "A file attachment in a chat message.", - fields: [ - { - name: "url", - type: "string", - description: "File download URL.", - }, - { - name: "file_name", - type: "string", - description: "File name.", - }, - { - name: "mime_type", - type: "string", - description: "MIME type.", - }, - { - name: "size_bytes", - type: "bigint", - description: "File size in bytes.", - }, - { - name: "text", - type: "string | undefined", - description: "Optional caption text.", - }, - ], - }, - { - id: "chat-media", - name: "ChatMedia", - category: "chat", - definition: "export interface ChatMedia {\n url: string;\n}", - description: "A media attachment.", - fields: [ - { - name: "url", - type: "string", - description: "Media URL.", - }, - ], - }, - { - id: "chat-message-content", - name: "ChatMessageContent", - category: "chat", - definition: - 'export type ChatMessageContent =\n | { tag: "Text"; value: { text: string } }\n | { tag: "RichText"; value: ChatRichText }\n | { tag: "Actions"; value: ChatActions }\n | { tag: "File"; value: ChatFile }\n | { tag: "Reaction"; value: ChatReaction }\n | { tag: "ReactionRemoved"; value: ChatReaction }\n | { tag: "Custom"; value: ChatCustomMessage }\n;', - description: "Content of a chat message -- one of several types.", - variants: [ - { - name: "Text", - type: '{ tag: "Text"; value: { text: string } }', - description: "Plain text message.", - }, - { - name: "RichText", - type: '{ tag: "RichText"; value: ChatRichText }', - description: "Rich text with media.", - }, - { - name: "Actions", - type: '{ tag: "Actions"; value: ChatActions }', - description: "Action button set.", - }, - { - name: "File", - type: '{ tag: "File"; value: ChatFile }', - description: "File attachment.", - }, - { - name: "Reaction", - type: '{ tag: "Reaction"; value: ChatReaction }', - description: "Emoji reaction.", - }, - { - name: "ReactionRemoved", - type: '{ tag: "ReactionRemoved"; value: ChatReaction }', - description: "Reaction removal.", - }, - { - name: "Custom", - type: '{ tag: "Custom"; value: ChatCustomMessage }', - description: "Custom message.", - }, - ], - }, - { - id: "chat-reaction", - name: "ChatReaction", - category: "chat", - definition: - "export interface ChatReaction {\n messageId: string;\n emoji: string;\n}", - description: "A reaction to a chat message.", - fields: [ - { - name: "message_id", - type: "string", - description: "Message being reacted to.", - }, - { - name: "emoji", - type: "string", - description: "Emoji reaction.", - }, - ], - }, - { - id: "chat-rich-text", - name: "ChatRichText", - category: "chat", - definition: - "export interface ChatRichText {\n text?: string;\n media: Array;\n}", - description: "Rich text message with optional media.", - fields: [ - { - name: "text", - type: "string | undefined", - description: "Optional text content.", - }, - { - name: "media", - type: "Array", - description: "Attached media items.", - }, - ], - }, - { - id: "chat-room", - name: "ChatRoom", - category: "chat", - definition: - "export interface ChatRoom {\n roomId: string;\n participatingAs: ChatRoomParticipation;\n}", - description: "A chat room the product participates in.", - fields: [ - { - name: "room_id", - type: "string", - description: "Room identifier.", - }, - { - name: "participating_as", - type: "ChatRoomParticipation", - description: "`RoomHost` or `Bot`.", - }, - ], - }, - { - id: "chat-room-participation", - name: "ChatRoomParticipation", - category: "chat", - definition: 'export type ChatRoomParticipation = "RoomHost" | "Bot";', - description: "How the product participates in a chat room.", - variants: [ - { - name: "RoomHost", - type: '{ tag: "RoomHost"; value?: undefined }', - }, - { - name: "Bot", - type: '{ tag: "Bot"; value?: undefined }', - }, - ], - }, - { - id: "chat-room-registration-status", - name: "ChatRoomRegistrationStatus", - category: "chat", - definition: 'export type ChatRoomRegistrationStatus = "New" | "Exists";', - description: "Whether the room was newly created or already existed.", - variants: [ - { - name: "New", - type: '{ tag: "New"; value?: undefined }', - }, - { - name: "Exists", - type: '{ tag: "Exists"; value?: undefined }', - }, - ], - }, - { - id: "color-token", - name: "ColorToken", - category: "chat", - definition: - 'export type ColorToken = "FgPrimary" | "FgSecondary" | "FgTertiary" | "BgSurfaceMain" | "BgSurfaceContainer" | "BgSurfaceNested" | "FgSuccess" | "FgError" | "FgWarning";', - description: "Semantic color tokens for theming.", - variants: [ - { - name: "FgPrimary", - type: '{ tag: "FgPrimary"; value?: undefined }', - }, - { - name: "FgSecondary", - type: '{ tag: "FgSecondary"; value?: undefined }', - }, - { - name: "FgTertiary", - type: '{ tag: "FgTertiary"; value?: undefined }', - }, - { - name: "BgSurfaceMain", - type: '{ tag: "BgSurfaceMain"; value?: undefined }', - }, - { - name: "BgSurfaceContainer", - type: '{ tag: "BgSurfaceContainer"; value?: undefined }', - }, - { - name: "BgSurfaceNested", - type: '{ tag: "BgSurfaceNested"; value?: undefined }', - }, - { - name: "FgSuccess", - type: '{ tag: "FgSuccess"; value?: undefined }', - }, - { - name: "FgError", - type: '{ tag: "FgError"; value?: undefined }', - }, - { - name: "FgWarning", - type: '{ tag: "FgWarning"; value?: undefined }', - }, - ], - }, - { - id: "column-props", - name: "ColumnProps", - category: "chat", - definition: - "export interface ColumnProps {\n horizontalAlignment?: HorizontalAlignment;\n verticalArrangement?: Arrangement;\n}", - description: "Properties for a [`CustomRendererNode::Column`] layout.", - fields: [ - { - name: "horizontal_alignment", - type: "HorizontalAlignment | undefined", - description: "Horizontal alignment of children.", - }, - { - name: "vertical_arrangement", - type: "Arrangement | undefined", - description: "Vertical arrangement of children.", - }, - ], - }, - { - id: "component", - name: "Component", - category: "chat", - definition: - "export interface Component

{\n modifiers: Array;\n props: P;\n children: Array;\n}", - description: - "A component in the custom renderer UI tree, combining modifiers, typed props,\nand recursive children.", - fields: [ - { - name: "modifiers", - type: "Array", - description: "Layout and styling modifiers.", - }, - { - name: "props", - type: "P", - description: "Component-specific properties.", - }, - { - name: "children", - type: "Array", - description: "Child nodes.", - }, - ], - }, - { - id: "content-alignment", - name: "ContentAlignment", - category: "chat", - definition: - 'export type ContentAlignment = "TopStart" | "TopCenter" | "TopEnd" | "CenterStart" | "Center" | "CenterEnd" | "BottomStart" | "BottomCenter" | "BottomEnd";', - description: "2D content alignment.", - variants: [ - { - name: "TopStart", - type: '{ tag: "TopStart"; value?: undefined }', - }, - { - name: "TopCenter", - type: '{ tag: "TopCenter"; value?: undefined }', - }, - { - name: "TopEnd", - type: '{ tag: "TopEnd"; value?: undefined }', - }, - { - name: "CenterStart", - type: '{ tag: "CenterStart"; value?: undefined }', - }, - { - name: "Center", - type: '{ tag: "Center"; value?: undefined }', - }, - { - name: "CenterEnd", - type: '{ tag: "CenterEnd"; value?: undefined }', - }, - { - name: "BottomStart", - type: '{ tag: "BottomStart"; value?: undefined }', - }, - { - name: "BottomCenter", - type: '{ tag: "BottomCenter"; value?: undefined }', - }, - { - name: "BottomEnd", - type: '{ tag: "BottomEnd"; value?: undefined }', - }, - ], - }, - { - id: "custom-renderer-node", - name: "CustomRendererNode", - category: "chat", - definition: - 'export type CustomRendererNode =\n | { tag: "Nil"; value?: undefined }\n | { tag: "String"; value: { text: string } }\n | { tag: "Box"; value: Component }\n | { tag: "Column"; value: Component }\n | { tag: "Row"; value: Component }\n | { tag: "Spacer"; value: Component }\n | { tag: "Text"; value: Component }\n | { tag: "Button"; value: Component }\n | { tag: "TextField"; value: Component }\n;', - description: - "A node in the custom renderer UI tree. Can be nested recursively via the\n`children` field of each [`Component`].", - variants: [ - { - name: "Nil", - type: '{ tag: "Nil"; value?: undefined }', - description: "Empty node.", - }, - { - name: "String", - type: '{ tag: "String"; value: { text: string } }', - description: "Raw text string.", - }, - { - name: "Box", - type: '{ tag: "Box"; value: Component }', - description: "Generic container.", - }, - { - name: "Column", - type: '{ tag: "Column"; value: Component }', - description: "Vertical layout.", - }, - { - name: "Row", - type: '{ tag: "Row"; value: Component }', - description: "Horizontal layout.", - }, - { - name: "Spacer", - type: '{ tag: "Spacer"; value: Component }', - description: "Flexible space.", - }, - { - name: "Text", - type: '{ tag: "Text"; value: Component }', - description: "Text display.", - }, - { - name: "Button", - type: '{ tag: "Button"; value: Component }', - description: "Interactive button.", - }, - { - name: "TextField", - type: '{ tag: "TextField"; value: Component }', - description: "Text input.", - }, - ], - }, - { - id: "dimensions", - name: "Dimensions", - category: "chat", - definition: - "export interface Dimensions {\n top: Size;\n end: Size;\n bottom?: Size;\n start?: Size;\n}", - description: - "CSS-like dimensions: (top, end, bottom, start).\nBottom defaults to top, start defaults to end when `None`.", - fields: [ - { - name: "top", - type: "Size", - description: "Top dimension.", - }, - { - name: "end", - type: "Size", - description: "End dimension.", - }, - { - name: "bottom", - type: "Size | undefined", - description: "Bottom dimension. Defaults to top when absent.", - }, - { - name: "start", - type: "Size | undefined", - description: "Start dimension. Defaults to end when absent.", - }, - ], - }, - { - id: "generic-error", - name: "GenericError", - category: "common", - definition: "export interface GenericError {\n reason: string;\n}", - description: - "Generic error payload carrying a human-readable reason string. Used by many\nmethods as a catch-all error type.", - fields: [ - { - name: "reason", - type: "string", - }, - ], - }, - { - id: "genesis-hash", - name: "GenesisHash", - category: "transaction", - definition: "export type GenesisHash = HexString;", - description: - "A 32-byte chain genesis hash used to identify the target chain.", - }, - { - id: "horizontal-alignment", - name: "HorizontalAlignment", - category: "chat", - definition: 'export type HorizontalAlignment = "Start" | "Center" | "End";', - description: "Horizontal alignment options.", - variants: [ - { - name: "Start", - type: '{ tag: "Start"; value?: undefined }', - }, - { - name: "Center", - type: '{ tag: "Center"; value?: undefined }', - }, - { - name: "End", - type: '{ tag: "End"; value?: undefined }', - }, - ], - }, - { - id: "host-account-connection-status-subscribe-item", - name: "HostAccountConnectionStatusSubscribeItem", - category: "account", - definition: - 'export type HostAccountConnectionStatusSubscribeItem = "Disconnected" | "Connected";', - description: "User's authentication state.", - variants: [ - { - name: "Disconnected", - type: '{ tag: "Disconnected"; value?: undefined }', - }, - { - name: "Connected", - type: '{ tag: "Connected"; value?: undefined }', - }, - ], - }, - { - id: "host-account-create-proof-error", - name: "HostAccountCreateProofError", - category: "account", - definition: - 'export type HostAccountCreateProofError =\n | { tag: "RingNotFound"; value?: undefined }\n | { tag: "Rejected"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: "Error returned when ring VRF proof creation fails.", - variants: [ - { - name: "RingNotFound", - type: '{ tag: "RingNotFound"; value?: undefined }', - description: "Ring not available at the specified location.", - }, - { - name: "Rejected", - type: '{ tag: "Rejected"; value?: undefined }', - description: "User or host rejected.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-account-create-proof-request", - name: "HostAccountCreateProofRequest", - category: "account", - definition: - "export interface HostAccountCreateProofRequest {\n productAccountId: ProductAccountId;\n ringLocation: RingLocation;\n context: HexString;\n}", - description: "Request to create a ring VRF proof for a product account.", - fields: [ - { - name: "product_account_id", - type: "ProductAccountId", - description: "Product account that should create the proof.", - }, - { - name: "ring_location", - type: "RingLocation", - description: "Ring location to use for proof generation.", - }, - { - name: "context", - type: "HexString", - description: "Context bytes bound to the proof.", - }, - ], - }, - { - id: "host-account-create-proof-response", - name: "HostAccountCreateProofResponse", - category: "account", - definition: - "export interface HostAccountCreateProofResponse {\n proof: HexString;\n}", - description: "Response containing a ring VRF proof.", - fields: [ - { - name: "proof", - type: "HexString", - description: "Variable-length ring VRF proof bytes.", - }, - ], - }, - { - id: "host-account-get-alias-request", - name: "HostAccountGetAliasRequest", - category: "account", - definition: - "export interface HostAccountGetAliasRequest {\n productAccountId: ProductAccountId;\n}", - description: - "Request to retrieve a contextual alias for a product account.", - fields: [ - { - name: "product_account_id", - type: "ProductAccountId", - description: "Product account to derive the alias for.", - }, - ], - }, - { - id: "host-account-get-alias-response", - name: "HostAccountGetAliasResponse", - category: "account", - definition: - "export interface HostAccountGetAliasResponse {\n context: HexString;\n alias: HexString;\n}", - description: - "A privacy-preserving alias derived via ring VRF, bound to a specific context.", - fields: [ - { - name: "context", - type: "HexString", - description: "32-byte context identifier.", - }, - { - name: "alias", - type: "HexString", - description: "Ring VRF alias (variable length).", - }, - ], - }, - { - id: "host-account-get-error", - name: "HostAccountGetError", - category: "account", - definition: - 'export type HostAccountGetError =\n | { tag: "NotConnected"; value?: undefined }\n | { tag: "Rejected"; value?: undefined }\n | { tag: "DomainNotValid"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: "Error returned when credential/account requests fail.", - variants: [ - { - name: "NotConnected", - type: '{ tag: "NotConnected"; value?: undefined }', - description: "User is not logged in.", - }, - { - name: "Rejected", - type: '{ tag: "Rejected"; value?: undefined }', - description: "User or host rejected the request.", - }, - { - name: "DomainNotValid", - type: '{ tag: "DomainNotValid"; value?: undefined }', - description: "Domain identifier is invalid.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all error with reason.", - }, - ], - }, - { - id: "host-account-get-request", - name: "HostAccountGetRequest", - category: "account", - definition: - "export interface HostAccountGetRequest {\n productAccountId: ProductAccountId;\n}", - description: "Request to retrieve a product-scoped account.", - fields: [ - { - name: "product_account_id", - type: "ProductAccountId", - description: "Product account to retrieve.", - }, - ], - }, - { - id: "host-account-get-response", - name: "HostAccountGetResponse", - category: "account", - definition: - "export interface HostAccountGetResponse {\n account: ProductAccount;\n}", - description: "Response containing a product-scoped account.", - fields: [ - { - name: "account", - type: "ProductAccount", - description: "Retrieved product account.", - }, - ], - }, - { - id: "host-chat-action-subscribe-item", - name: "HostChatActionSubscribeItem", - category: "chat", - definition: - "export interface HostChatActionSubscribeItem {\n roomId: string;\n peer: string;\n payload: ChatActionPayload;\n}", - description: "A chat action received from the host.", - fields: [ - { - name: "room_id", - type: "string", - description: "Room where the action occurred.", - }, - { - name: "peer", - type: "string", - description: "Peer who initiated the action.", - }, - { - name: "payload", - type: "ChatActionPayload", - description: "The action payload.", - }, - ], - }, - { - id: "host-chat-create-room-error", - name: "HostChatCreateRoomError", - category: "chat", - definition: - 'export type HostChatCreateRoomError =\n | { tag: "PermissionDenied"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: "Chat room registration error.", - variants: [ - { - name: "PermissionDenied", - type: '{ tag: "PermissionDenied"; value?: undefined }', - description: "Not allowed.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-chat-create-room-request", - name: "HostChatCreateRoomRequest", - category: "chat", - definition: - "export interface HostChatCreateRoomRequest {\n roomId: string;\n name: string;\n icon: string;\n}", - description: "Request to create a chat room.", - fields: [ - { - name: "room_id", - type: "string", - description: "Unique room identifier.", - }, - { - name: "name", - type: "string", - description: "Room display name.", - }, - { - name: "icon", - type: "string", - description: "URL or base64 image.", - }, - ], - }, - { - id: "host-chat-create-room-response", - name: "HostChatCreateRoomResponse", - category: "chat", - definition: - "export interface HostChatCreateRoomResponse {\n status: ChatRoomRegistrationStatus;\n}", - description: "Result of a room registration.", - fields: [ - { - name: "status", - type: "ChatRoomRegistrationStatus", - description: "`New` or `Exists`.", - }, - ], - }, - { - id: "host-chat-list-subscribe-item", - name: "HostChatListSubscribeItem", - category: "chat", - definition: - "export interface HostChatListSubscribeItem {\n rooms: Array;\n}", - description: "Item containing the current chat rooms.", - fields: [ - { - name: "rooms", - type: "Array", - description: "Chat rooms the product participates in.", - }, - ], - }, - { - id: "host-chat-post-message-error", - name: "HostChatPostMessageError", - category: "chat", - definition: - 'export type HostChatPostMessageError =\n | { tag: "MessageTooLarge"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: "Chat message posting error.", - variants: [ - { - name: "MessageTooLarge", - type: '{ tag: "MessageTooLarge"; value?: undefined }', - description: "Message exceeded size limit.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-chat-post-message-request", - name: "HostChatPostMessageRequest", - category: "chat", - definition: - "export interface HostChatPostMessageRequest {\n roomId: string;\n payload: ChatMessageContent;\n}", - description: "Request to post a message to a chat room.", - fields: [ - { - name: "room_id", - type: "string", - description: "Room to post to.", - }, - { - name: "payload", - type: "ChatMessageContent", - description: "Message content.", - }, - ], - }, - { - id: "host-chat-post-message-response", - name: "HostChatPostMessageResponse", - category: "chat", - definition: - "export interface HostChatPostMessageResponse {\n messageId: string;\n}", - description: "Result of posting a message.", - fields: [ - { - name: "message_id", - type: "string", - description: "Assigned message ID.", - }, - ], - }, - { - id: "host-chat-register-bot-error", - name: "HostChatRegisterBotError", - category: "chat", - definition: - 'export type HostChatRegisterBotError =\n | { tag: "PermissionDenied"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: "Chat bot registration error.", - variants: [ - { - name: "PermissionDenied", - type: '{ tag: "PermissionDenied"; value?: undefined }', - description: "Not allowed.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-chat-register-bot-request", - name: "HostChatRegisterBotRequest", - category: "chat", - definition: - "export interface HostChatRegisterBotRequest {\n botId: string;\n name: string;\n icon: string;\n}", - description: "Request to register a chat bot.", - fields: [ - { - name: "bot_id", - type: "string", - description: "Unique bot identifier.", - }, - { - name: "name", - type: "string", - description: "Bot display name.", - }, - { - name: "icon", - type: "string", - description: "URL or base64 image.", - }, - ], - }, - { - id: "host-chat-register-bot-response", - name: "HostChatRegisterBotResponse", - category: "chat", - definition: - "export interface HostChatRegisterBotResponse {\n status: ChatBotRegistrationStatus;\n}", - description: "Result of a bot registration.", - fields: [ - { - name: "status", - type: "ChatBotRegistrationStatus", - description: "`New` or `Exists`.", - }, - ], - }, - { - id: "host-create-transaction-error", - name: "HostCreateTransactionError", - category: "transaction", - definition: - 'export type HostCreateTransactionError =\n | { tag: "FailedToDecode"; value?: undefined }\n | { tag: "Rejected"; value?: undefined }\n | { tag: "NotSupported"; value: { reason: string } }\n | { tag: "PermissionDenied"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: "Transaction creation error.", - variants: [ - { - name: "FailedToDecode", - type: '{ tag: "FailedToDecode"; value?: undefined }', - description: "Payload could not be deserialized.", - }, - { - name: "Rejected", - type: '{ tag: "Rejected"; value?: undefined }', - description: "User rejected.", - }, - { - name: "NotSupported", - type: '{ tag: "NotSupported"; value: { reason: string } }', - description: "Unsupported payload version or extension.", - }, - { - name: "PermissionDenied", - type: '{ tag: "PermissionDenied"; value?: undefined }', - description: "Not authenticated.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-create-transaction-response", - name: "HostCreateTransactionResponse", - category: "signing", - definition: - "export interface HostCreateTransactionResponse {\n transaction: HexString;\n}", - description: "Response containing a created transaction.", - fields: [ - { - name: "transaction", - type: "HexString", - description: "SCALE-encoded signed transaction.", - }, - ], - }, - { - id: "host-create-transaction-with-legacy-account-response", - name: "HostCreateTransactionWithLegacyAccountResponse", - category: "signing", - definition: - "export interface HostCreateTransactionWithLegacyAccountResponse {\n transaction: HexString;\n}", - description: - "Response containing a transaction created with a non-product account.", - fields: [ - { - name: "transaction", - type: "HexString", - description: "SCALE-encoded signed transaction.", - }, - ], - }, - { - id: "host-derive-entropy-error", - name: "HostDeriveEntropyError", - category: "entropy", - definition: - 'export type HostDeriveEntropyError =\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: "Error from [`crate::api::Entropy::derive`] (RFC 0007).", - variants: [ - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-derive-entropy-request", - name: "HostDeriveEntropyRequest", - category: "entropy", - definition: - "export interface HostDeriveEntropyRequest {\n context: HexString;\n}", - description: - "Request to derive deterministic per-product entropy (RFC 0007).\n\nThe host derives 32 bytes from product-scoped seed material and `context`.\nRepeated calls with the same `context` for the same product yield the same\nentropy.", - fields: [ - { - name: "context", - type: "HexString", - description: "Domain-separated derivation context.", - }, - ], - }, - { - id: "host-derive-entropy-response", - name: "HostDeriveEntropyResponse", - category: "entropy", - definition: - "export interface HostDeriveEntropyResponse {\n entropy: HexString;\n}", - description: - "Response carrying 32 bytes of deterministically derived entropy.", - fields: [ - { - name: "entropy", - type: "HexString", - description: "32 bytes of derived entropy.", - }, - ], - }, - { - id: "host-device-permission-request", - name: "HostDevicePermissionRequest", - category: "permissions", - definition: - 'export type HostDevicePermissionRequest = "Notifications" | "Camera" | "Microphone" | "Bluetooth" | "NFC" | "Location" | "Clipboard" | "OpenUrl" | "Biometrics";', - description: - "Device-capability permission requested from the host (RFC 0002).\n\nThe user's decision is persisted indefinitely after the first prompt and\nsurvives app restarts, whether the decision was grant or deny; the host\ndoes not re-prompt on subsequent requests for the same capability.", - variants: [ - { - name: "Notifications", - type: '{ tag: "Notifications"; value?: undefined }', - }, - { - name: "Camera", - type: '{ tag: "Camera"; value?: undefined }', - }, - { - name: "Microphone", - type: '{ tag: "Microphone"; value?: undefined }', - }, - { - name: "Bluetooth", - type: '{ tag: "Bluetooth"; value?: undefined }', - }, - { - name: "NFC", - type: '{ tag: "NFC"; value?: undefined }', - }, - { - name: "Location", - type: '{ tag: "Location"; value?: undefined }', - }, - { - name: "Clipboard", - type: '{ tag: "Clipboard"; value?: undefined }', - }, - { - name: "OpenUrl", - type: '{ tag: "OpenUrl"; value?: undefined }', - }, - { - name: "Biometrics", - type: '{ tag: "Biometrics"; value?: undefined }', - }, - ], - }, - { - id: "host-device-permission-response", - name: "HostDevicePermissionResponse", - category: "permissions", - definition: - "export interface HostDevicePermissionResponse {\n granted: boolean;\n}", - description: "Outcome of a device-permission request.", - fields: [ - { - name: "granted", - type: "boolean", - description: "Whether the permission was granted.", - }, - ], - }, - { - id: "host-feature-supported-request", - name: "HostFeatureSupportedRequest", - category: "system", - definition: - 'export type HostFeatureSupportedRequest =\n | { tag: "Chain"; value: { genesisHash: HexString } }\n;', - description: "Request to query whether a feature is supported by the host.", - variants: [ - { - name: "Chain", - type: '{ tag: "Chain"; value: { genesisHash: HexString } }', - description: - "Ask whether the host can interact with the chain identified by genesis hash.", - }, - ], - }, - { - id: "host-feature-supported-response", - name: "HostFeatureSupportedResponse", - category: "system", - definition: - "export interface HostFeatureSupportedResponse {\n supported: boolean;\n}", - description: "Response to a feature-support query.", - fields: [ - { - name: "supported", - type: "boolean", - description: "Whether the feature is supported.", - }, - ], - }, - { - id: "host-get-legacy-accounts-response", - name: "HostGetLegacyAccountsResponse", - category: "account", - definition: - "export interface HostGetLegacyAccountsResponse {\n accounts: Array;\n}", - description: - "Response containing all legacy (user-imported) accounts owned by the user.", - fields: [ - { - name: "accounts", - type: "Array", - description: "Legacy accounts.", - }, - ], - }, - { - id: "host-get-user-id-error", - name: "HostGetUserIdError", - category: "account", - definition: - 'export type HostGetUserIdError =\n | { tag: "PermissionDenied"; value?: undefined }\n | { tag: "NotConnected"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: "Error from [`crate::api::Account::get_user_id`].", - variants: [ - { - name: "PermissionDenied", - type: '{ tag: "PermissionDenied"; value?: undefined }', - description: "User denied the identity disclosure request.", - }, - { - name: "NotConnected", - type: '{ tag: "NotConnected"; value?: undefined }', - description: "User is not logged in.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-get-user-id-response", - name: "HostGetUserIdResponse", - category: "account", - definition: - "export interface HostGetUserIdResponse {\n primaryUsername: string;\n}", - description: "The user's primary DotNS account identity.", - fields: [ - { - name: "primary_username", - type: "string", - description: "The user's primary DotNS username.", - }, - ], - }, - { - id: "host-handshake-error", - name: "HostHandshakeError", - category: "system", - definition: - 'export type HostHandshakeError =\n | { tag: "Timeout"; value?: undefined }\n | { tag: "UnsupportedProtocolVersion"; value?: undefined }\n | { tag: "Unknown"; value: GenericError }\n;', - description: - "Error from [`crate::api::System::handshake`] (RFC 0009).\n\nThe handshake is the first call on a fresh connection; it does not require\nuser authentication and is used to negotiate the wire codec version.", - variants: [ - { - name: "Timeout", - type: '{ tag: "Timeout"; value?: undefined }', - description: "Host did not complete the handshake in time.", - }, - { - name: "UnsupportedProtocolVersion", - type: '{ tag: "UnsupportedProtocolVersion"; value?: undefined }', - description: - "Host does not speak the codec version requested by the product.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: GenericError }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-handshake-request", - name: "HostHandshakeRequest", - category: "system", - definition: - "export interface HostHandshakeRequest {\n codecVersion: number;\n}", - description: - "Wire-codec negotiation payload sent by the product (RFC 0009).", - fields: [ - { - name: "codec_version", - type: "number", - description: "Wire codec version requested by the product.", - }, - ], - }, - { - id: "host-local-storage-clear-request", - name: "HostLocalStorageClearRequest", - category: "local_storage", - definition: - "export interface HostLocalStorageClearRequest {\n key: string;\n}", - description: "Request to clear a local storage key.", - fields: [ - { - name: "key", - type: "string", - description: "Storage key to clear.", - }, - ], - }, - { - id: "host-local-storage-read-error", - name: "HostLocalStorageReadError", - category: "local_storage", - definition: - 'export type HostLocalStorageReadError =\n | { tag: "Full"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: "Local storage operation error.", - variants: [ - { - name: "Full", - type: '{ tag: "Full"; value?: undefined }', - description: "Storage quota exceeded.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-local-storage-read-request", - name: "HostLocalStorageReadRequest", - category: "local_storage", - definition: - "export interface HostLocalStorageReadRequest {\n key: string;\n}", - description: "Request to read a local storage value.", - fields: [ - { - name: "key", - type: "string", - description: "Storage key to read.", - }, - ], - }, - { - id: "host-local-storage-read-response", - name: "HostLocalStorageReadResponse", - category: "local_storage", - definition: - "export interface HostLocalStorageReadResponse {\n value?: HexString;\n}", - description: "Response containing an optional local storage value.", - fields: [ - { - name: "value", - type: "HexString | undefined", - description: "Stored value, if present.", - }, - ], - }, - { - id: "host-local-storage-write-request", - name: "HostLocalStorageWriteRequest", - category: "local_storage", - definition: - "export interface HostLocalStorageWriteRequest {\n key: string;\n value: HexString;\n}", - description: "Request to write a value into local storage.", - fields: [ - { - name: "key", - type: "string", - description: "Storage key to write.", - }, - { - name: "value", - type: "HexString", - description: "Value to store at the key.", - }, - ], - }, - { - id: "host-navigate-to-error", - name: "HostNavigateToError", - category: "system", - definition: - 'export type HostNavigateToError =\n | { tag: "PermissionDenied"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: "Error from [`crate::api::System::navigate_to`].", - variants: [ - { - name: "PermissionDenied", - type: '{ tag: "PermissionDenied"; value?: undefined }', - description: "User denied the navigation prompt.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-navigate-to-request", - name: "HostNavigateToRequest", - category: "system", - definition: "export interface HostNavigateToRequest {\n url: string;\n}", - description: "Request to navigate the host to an external URL.", - fields: [ - { - name: "url", - type: "string", - description: "URL to open.", - }, - ], - }, - { - id: "host-payment-balance-subscribe-error", - name: "HostPaymentBalanceSubscribeError", - category: "payment", - definition: - 'export type HostPaymentBalanceSubscribeError =\n | { tag: "PermissionDenied"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: - "Error from [`crate::api::Payment::balance_subscribe`].\n\nSee [RFC 0006].\n\n[RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94", - variants: [ - { - name: "PermissionDenied", - type: '{ tag: "PermissionDenied"; value?: undefined }', - description: "User denied the balance disclosure request.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-payment-balance-subscribe-item", - name: "HostPaymentBalanceSubscribeItem", - category: "payment", - definition: - "export interface HostPaymentBalanceSubscribeItem {\n available: Balance;\n}", - description: - "Current payment balance state pushed to subscribers.\n\nSee [RFC 0006].\n\n[RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94", - fields: [ - { - name: "available", - type: "Balance", - description: "Balance that can be spent right now.", - }, - ], - }, - { - id: "host-payment-balance-subscribe-request", - name: "HostPaymentBalanceSubscribeRequest", - category: "payment", - definition: - "export interface HostPaymentBalanceSubscribeRequest {\n purse?: PaymentPurseId;\n}", - description: "Request to subscribe to payment balance updates.", - fields: [ - { - name: "purse", - type: "PaymentPurseId | undefined", - description: "Optional purse selector. `None` means MAIN_PURSE.", - }, - ], - }, - { - id: "host-payment-error", - name: "HostPaymentError", - category: "payment", - definition: - 'export type HostPaymentError =\n | { tag: "Rejected"; value?: undefined }\n | { tag: "InsufficientBalance"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: - "Error from [`crate::api::Payment::request`].\n\nSee [RFC 0006].\n\n[RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94", - variants: [ - { - name: "Rejected", - type: '{ tag: "Rejected"; value?: undefined }', - description: "User rejected the payment request.", - }, - { - name: "InsufficientBalance", - type: '{ tag: "InsufficientBalance"; value?: undefined }', - description: - "User's available balance is not sufficient for the requested amount.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-payment-request", - name: "HostPaymentRequest", - category: "payment", - definition: - "export interface HostPaymentRequest {\n from?: PaymentPurseId;\n amount: Balance;\n destination: HexString;\n}", - description: "Request to initiate a payment to another account.", - fields: [ - { - name: "from", - type: "PaymentPurseId | undefined", - description: "Optional purse selector. `None` means MAIN_PURSE.", - }, - { - name: "amount", - type: "Balance", - description: "Amount to pay.", - }, - { - name: "destination", - type: "HexString", - description: "Destination account.", - }, - ], - }, - { - id: "host-payment-response", - name: "HostPaymentResponse", - category: "payment", - definition: "export interface HostPaymentResponse {\n id: string;\n}", - description: - "Receipt returned after a successful payment request.\n\nSee [RFC 0006].\n\n[RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94", - fields: [ - { - name: "id", - type: "string", - description: "The assigned payment identifier.", - }, - ], - }, - { - id: "host-payment-status-subscribe-error", - name: "HostPaymentStatusSubscribeError", - category: "payment", - definition: - 'export type HostPaymentStatusSubscribeError =\n | { tag: "PaymentNotFound"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: - "Error from [`crate::api::Payment::status_subscribe`].\n\nSee [RFC 0006].\n\n[RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94", - variants: [ - { - name: "PaymentNotFound", - type: '{ tag: "PaymentNotFound"; value?: undefined }', - description: - "Payment ID was not found or does not belong to the current product.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-payment-status-subscribe-item", - name: "HostPaymentStatusSubscribeItem", - category: "payment", - definition: - 'export type HostPaymentStatusSubscribeItem =\n | { tag: "Processing"; value?: undefined }\n | { tag: "Completed"; value?: undefined }\n | { tag: "Failed"; value: { reason: string } }\n;', - description: - "Payment lifecycle status pushed to subscribers.\n\nOnce a terminal state (`Completed` or `Failed`) is reached, the host\ndelivers it and may close the subscription.\n\nSee [RFC 0006].\n\n[RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94", - variants: [ - { - name: "Processing", - type: '{ tag: "Processing"; value?: undefined }', - description: "Payment is being processed.", - }, - { - name: "Completed", - type: '{ tag: "Completed"; value?: undefined }', - description: "Payment has been settled successfully.", - }, - { - name: "Failed", - type: '{ tag: "Failed"; value: { reason: string } }', - description: "Payment has failed.", - }, - ], - }, - { - id: "host-payment-status-subscribe-request", - name: "HostPaymentStatusSubscribeRequest", - category: "payment", - definition: - "export interface HostPaymentStatusSubscribeRequest {\n paymentId: string;\n}", - description: "Request to subscribe to a payment status.", - fields: [ - { - name: "payment_id", - type: "string", - description: "Payment identifier to watch.", - }, - ], - }, - { - id: "host-payment-top-up-error", - name: "HostPaymentTopUpError", - category: "payment", - definition: - 'export type HostPaymentTopUpError =\n | { tag: "InsufficientFunds"; value?: undefined }\n | { tag: "InvalidSource"; value?: undefined }\n | { tag: "PartialPayment"; value: { credited: Balance } }\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: - "Error from [`crate::api::Payment::top_up`].\n\nSee [RFC 0006].\n\n[RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94", - variants: [ - { - name: "InsufficientFunds", - type: '{ tag: "InsufficientFunds"; value?: undefined }', - description: "The source account does not hold sufficient funds.", - }, - { - name: "InvalidSource", - type: '{ tag: "InvalidSource"; value?: undefined }', - description: "The source account was not found or is invalid.", - }, - { - name: "PartialPayment", - type: '{ tag: "PartialPayment"; value: { credited: Balance } }', - description: - "Some coins were claimed but the total fell short of the requested amount.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-payment-top-up-request", - name: "HostPaymentTopUpRequest", - category: "payment", - definition: - "export interface HostPaymentTopUpRequest {\n into?: PaymentPurseId;\n amount: Balance;\n source: PaymentTopUpSource;\n}", - description: "Request to top up the product payment balance.", - fields: [ - { - name: "into", - type: "PaymentPurseId | undefined", - description: "Optional purse selector. `None` means MAIN_PURSE.", - }, - { - name: "amount", - type: "Balance", - description: "Amount to top up.", - }, - { - name: "source", - type: "PaymentTopUpSource", - description: "Funding source for the top-up.", - }, - ], - }, - { - id: "host-push-notification-cancel-request", - name: "HostPushNotificationCancelRequest", - category: "notifications", - definition: - "export interface HostPushNotificationCancelRequest {\n id: NotificationId;\n}", - description: "Request to cancel a previously scheduled notification.", - fields: [ - { - name: "id", - type: "NotificationId", - description: - "The notification identifier returned by [`HostPushNotificationResponse`].", - }, - ], - }, - { - id: "host-push-notification-error", - name: "HostPushNotificationError", - category: "notifications", - definition: - 'export type HostPushNotificationError =\n | { tag: "ScheduleLimitReached"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: "Push notification error.", - variants: [ - { - name: "ScheduleLimitReached", - type: '{ tag: "ScheduleLimitReached"; value?: undefined }', - description: - "The host-wide queue of pending scheduled notifications is full.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-push-notification-request", - name: "HostPushNotificationRequest", - category: "notifications", - definition: - "export interface HostPushNotificationRequest {\n text: string;\n deeplink?: string;\n scheduledAt?: bigint;\n}", - description: - "Push notification payload.\n\nWhen `scheduled_at` is `Some`, the notification is deferred to the given\nwall-clock instant (Unix milliseconds UTC). `None` fires immediately,\npreserving prior behaviour. See [RFC 0019].\n\n[RFC 0019]: https://github.com/paritytech/truapi/blob/main/docs/rfcs/0019-scheduled-notifications.md", - fields: [ - { - name: "text", - type: "string", - description: "Notification text.", - }, - { - name: "deeplink", - type: "string | undefined", - description: "Optional URL to open on tap.", - }, - { - name: "scheduled_at", - type: "bigint | undefined", - description: - "Optional Unix timestamp in milliseconds (UTC) at which the notification\nshould fire. `None` fires immediately.", - }, - ], - }, - { - id: "host-push-notification-response", - name: "HostPushNotificationResponse", - category: "notifications", - definition: - "export interface HostPushNotificationResponse {\n id: NotificationId;\n}", - description: - "Successful push notification response carrying the assigned id.", - fields: [ - { - name: "id", - type: "NotificationId", - description: "Host-assigned notification identifier.", - }, - ], - }, - { - id: "host-request-login-error", - name: "HostRequestLoginError", - category: "account", - definition: - 'export type HostRequestLoginError =\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: "Login request error.", - variants: [ - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-request-login-request", - name: "HostRequestLoginRequest", - category: "account", - definition: - "export interface HostRequestLoginRequest {\n reason?: string;\n}", - description: "Request to present the host login flow.", - fields: [ - { - name: "reason", - type: "string | undefined", - description: "Optional human-readable reason shown in the login UI.", - }, - ], - }, - { - id: "host-request-login-response", - name: "HostRequestLoginResponse", - category: "account", - definition: - 'export type HostRequestLoginResponse = "Success" | "AlreadyConnected" | "Rejected";', - description: "Result of a login request.", - variants: [ - { - name: "Success", - type: '{ tag: "Success"; value?: undefined }', - description: "User successfully authenticated.", - }, - { - name: "AlreadyConnected", - type: '{ tag: "AlreadyConnected"; value?: undefined }', - description: "User is already authenticated — no action was taken.", - }, - { - name: "Rejected", - type: '{ tag: "Rejected"; value?: undefined }', - description: "User dismissed/rejected the login UI.", - }, - ], - }, - { - id: "host-request-resource-allocation-request", - name: "HostRequestResourceAllocationRequest", - category: "resource_allocation", - definition: - "export interface HostRequestResourceAllocationRequest {\n resources: Array;\n}", - description: "Batched resource pre-allocation request (RFC 0010).", - fields: [ - { - name: "resources", - type: "Array", - description: "Resources to allocate.", - }, - ], - }, - { - id: "host-request-resource-allocation-response", - name: "HostRequestResourceAllocationResponse", - category: "resource_allocation", - definition: - "export interface HostRequestResourceAllocationResponse {\n outcomes: Array;\n}", - description: - "Per-resource outcomes for a batched allocation request (RFC 0010).", - fields: [ - { - name: "outcomes", - type: "Array", - description: - "Per-resource allocation outcomes, in the same order as the request.", - }, - ], - }, - { - id: "host-sign-payload-data", - name: "HostSignPayloadData", - category: "signing", - definition: - "export interface HostSignPayloadData {\n blockHash: HexString;\n blockNumber: HexString;\n era: HexString;\n genesisHash: HexString;\n method: HexString;\n nonce: HexString;\n specVersion: HexString;\n tip: HexString;\n transactionVersion: HexString;\n signedExtensions: Array;\n version: number;\n assetId?: HexString;\n metadataHash?: HexString;\n mode?: number;\n withSignedTransaction?: boolean;\n}", - description: - "Full Substrate extrinsic signing payload with all fields needed for signature\ngeneration.", - fields: [ - { - name: "block_hash", - type: "HexString", - description: "Reference block hash.", - }, - { - name: "block_number", - type: "HexString", - description: "Reference block number.", - }, - { - name: "era", - type: "HexString", - description: "Mortality era encoding.", - }, - { - name: "genesis_hash", - type: "HexString", - description: "Chain genesis hash.", - }, - { - name: "method", - type: "HexString", - description: "SCALE-encoded call data.", - }, - { - name: "nonce", - type: "HexString", - description: "Account nonce.", - }, - { - name: "spec_version", - type: "HexString", - description: "Runtime spec version.", - }, - { - name: "tip", - type: "HexString", - description: "Transaction tip.", - }, - { - name: "transaction_version", - type: "HexString", - description: "Transaction format version.", - }, - { - name: "signed_extensions", - type: "Array", - description: "Extension identifiers.", - }, - { - name: "version", - type: "number", - description: "Extrinsic version.", - }, - { - name: "asset_id", - type: "HexString | undefined", - description: "For multi-asset tips.", - }, - { - name: "metadata_hash", - type: "HexString | undefined", - description: "CheckMetadataHash extension.", - }, - { - name: "mode", - type: "number | undefined", - description: "Metadata mode.", - }, - { - name: "with_signed_transaction", - type: "boolean | undefined", - description: "Request signed transaction back.", - }, - ], - }, - { - id: "host-sign-payload-error", - name: "HostSignPayloadError", - category: "signing", - definition: - 'export type HostSignPayloadError =\n | { tag: "FailedToDecode"; value?: undefined }\n | { tag: "Rejected"; value?: undefined }\n | { tag: "PermissionDenied"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: "Signing operation error.", - variants: [ - { - name: "FailedToDecode", - type: '{ tag: "FailedToDecode"; value?: undefined }', - description: "Payload could not be deserialized.", - }, - { - name: "Rejected", - type: '{ tag: "Rejected"; value?: undefined }', - description: "User rejected signing.", - }, - { - name: "PermissionDenied", - type: '{ tag: "PermissionDenied"; value?: undefined }', - description: "Not authenticated.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "host-sign-payload-request", - name: "HostSignPayloadRequest", - category: "signing", - definition: - "export interface HostSignPayloadRequest {\n account: ProductAccountId;\n payload: HostSignPayloadData;\n}", - description: "Request to sign an extrinsic payload with a product account.", - fields: [ - { - name: "account", - type: "ProductAccountId", - description: "Product account that will sign this payload.", - }, - { - name: "payload", - type: "HostSignPayloadData", - description: "The extrinsic payload to sign.", - }, - ], - }, - { - id: "host-sign-payload-response", - name: "HostSignPayloadResponse", - category: "signing", - definition: - "export interface HostSignPayloadResponse {\n signature: HexString;\n signedTransaction?: HexString;\n}", - description: "Result of a signing operation.", - fields: [ - { - name: "signature", - type: "HexString", - description: "The cryptographic signature.", - }, - { - name: "signed_transaction", - type: "HexString | undefined", - description: "Full signed transaction, if requested.", - }, - ], - }, - { - id: "host-sign-payload-with-legacy-account-request", - name: "HostSignPayloadWithLegacyAccountRequest", - category: "signing", - definition: - "export interface HostSignPayloadWithLegacyAccountRequest {\n signer: string;\n payload: HostSignPayloadData;\n}", - description: - "Sign a Substrate extrinsic payload with a non-product (legacy) account.\nContains the same fields as [`HostSignPayloadRequest`] minus `address`\n(replaced by `signer`).", - fields: [ - { - name: "signer", - type: "string", - description: "Signer address (SS58 or hex) of the legacy account.", - }, - { - name: "payload", - type: "HostSignPayloadData", - description: "The extrinsic payload to sign.", - }, - ], - }, - { - id: "host-sign-raw-request", - name: "HostSignRawRequest", - category: "signing", - definition: - "export interface HostSignRawRequest {\n account: ProductAccountId;\n payload: RawPayload;\n}", - description: - "A raw signing request pairing an account with the payload to sign.", - fields: [ - { - name: "account", - type: "ProductAccountId", - description: "Product account that will sign this payload.", - }, - { - name: "payload", - type: "RawPayload", - description: "The payload to sign.", - }, - ], - }, - { - id: "host-sign-raw-with-legacy-account-request", - name: "HostSignRawWithLegacyAccountRequest", - category: "signing", - definition: - "export interface HostSignRawWithLegacyAccountRequest {\n signer: string;\n payload: RawPayload;\n}", - description: - "Sign raw bytes with a non-product (legacy) account. The signer field\nidentifies which legacy account to use.", - fields: [ - { - name: "signer", - type: "string", - description: "Signer address (SS58 or hex) of the legacy account.", - }, - { - name: "payload", - type: "RawPayload", - description: "The data to sign.", - }, - ], - }, - { - id: "host-theme-subscribe-item", - name: "HostThemeSubscribeItem", - category: "theme", - definition: - "export interface HostThemeSubscribeItem {\n name: ThemeName;\n variant: ThemeVariant;\n}", - description: "Current theme state pushed to subscribers.", - fields: [ - { - name: "name", - type: "ThemeName", - description: "Theme name.", - }, - { - name: "variant", - type: "ThemeVariant", - description: "Light or dark variant.", - }, - ], - }, - { - id: "legacy-account", - name: "LegacyAccount", - category: "account", - definition: - "export interface LegacyAccount {\n publicKey: HexString;\n name?: string;\n}", - description: - "A user-imported (legacy) account: public key plus an optional user-chosen\ndisplay name.\n\nReturned by [`HostGetLegacyAccountsResponse`]. Distinct from\n[`ProductAccount`], which is protocol-derived and never carries a label.", - fields: [ - { - name: "public_key", - type: "HexString", - description: "The account public key (variable-length bytes).", - }, - { - name: "name", - type: "string | undefined", - description: "Optional user-chosen display name.", - }, - ], - }, - { - id: "legacy-account-tx-payload", - name: "LegacyAccountTxPayload", - category: "transaction", - definition: - "export interface LegacyAccountTxPayload {\n signer: AccountId;\n genesisHash: GenesisHash;\n callData: HexString;\n extensions: Array;\n txExtVersion: number;\n}", - description: - "Transaction payload for a legacy (non-product) account.\n\nIdentical to [`ProductAccountTxPayload`] except the signer is a raw\n32-byte [`AccountId`].", - fields: [ - { - name: "signer", - type: "AccountId", - description: "Raw 32-byte public key of the legacy account.", - }, - { - name: "genesis_hash", - type: "GenesisHash", - description: "Chain where the transaction will execute.", - }, - { - name: "call_data", - type: "HexString", - description: "SCALE-encoded Call data.", - }, - { - name: "extensions", - type: "Array", - description: "Transaction extensions supplied by the caller.", - }, - { - name: "tx_ext_version", - type: "number", - description: "0 for Extrinsic V4, runtime-supported value for V5.", - }, - ], - }, - { - id: "modifier", - name: "Modifier", - category: "chat", - definition: - 'export type Modifier =\n | { tag: "Margin"; value: Dimensions }\n | { tag: "Padding"; value: Dimensions }\n | { tag: "Background"; value: Background }\n | { tag: "Border"; value: BorderStyle }\n | { tag: "Height"; value: { height: Size } }\n | { tag: "Width"; value: { width: Size } }\n | { tag: "MinWidth"; value: { width: Size } }\n | { tag: "MinHeight"; value: { height: Size } }\n | { tag: "FillWidth"; value: { enabled: boolean } }\n | { tag: "FillHeight"; value: { enabled: boolean } }\n;', - description: - "Layout and styling modifiers applied to custom renderer components.", - variants: [ - { - name: "Margin", - type: '{ tag: "Margin"; value: Dimensions }', - description: "Outer spacing.", - }, - { - name: "Padding", - type: '{ tag: "Padding"; value: Dimensions }', - description: "Inner spacing.", - }, - { - name: "Background", - type: '{ tag: "Background"; value: Background }', - description: "Background fill.", - }, - { - name: "Border", - type: '{ tag: "Border"; value: BorderStyle }', - description: "Border style.", - }, - { - name: "Height", - type: '{ tag: "Height"; value: { height: Size } }', - description: "Fixed height.", - }, - { - name: "Width", - type: '{ tag: "Width"; value: { width: Size } }', - description: "Fixed width.", - }, - { - name: "MinWidth", - type: '{ tag: "MinWidth"; value: { width: Size } }', - description: "Minimum width.", - }, - { - name: "MinHeight", - type: '{ tag: "MinHeight"; value: { height: Size } }', - description: "Minimum height.", - }, - { - name: "FillWidth", - type: '{ tag: "FillWidth"; value: { enabled: boolean } }', - description: "Fill available width.", - }, - { - name: "FillHeight", - type: '{ tag: "FillHeight"; value: { enabled: boolean } }', - description: "Fill available height.", - }, - ], - }, - { - id: "notification-id", - name: "NotificationId", - category: "notifications", - definition: "export type NotificationId = number;", - description: - "Opaque identifier for a push notification, unique per product.", - }, - { - id: "operation-started-result", - name: "OperationStartedResult", - category: "chain", - definition: - 'export type OperationStartedResult =\n | { tag: "Started"; value: { operationId: string } }\n | { tag: "LimitReached"; value?: undefined }\n;', - variants: [ - { - name: "Started", - type: '{ tag: "Started"; value: { operationId: string } }', - }, - { - name: "LimitReached", - type: '{ tag: "LimitReached"; value?: undefined }', - }, - ], - }, - { - id: "payment-purse-id", - name: "PaymentPurseId", - category: "payment", - definition: "export type PaymentPurseId = number;", - description: "Identifier selecting a product payment purse.", - }, - { - id: "payment-top-up-source", - name: "PaymentTopUpSource", - category: "payment", - definition: - 'export type PaymentTopUpSource =\n | { tag: "ProductAccount"; value: { derivationIndex: number } }\n | { tag: "PrivateKey"; value: { sr25519SecretKey: HexString } }\n | { tag: "Coins"; value: { sr25519SecretKeys: Array } }\n;', - description: - "Source for a payment top-up operation.\n\nSee [RFC 0006].\n\n[RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94", - variants: [ - { - name: "ProductAccount", - type: '{ tag: "ProductAccount"; value: { derivationIndex: number } }', - description: "Fund from one of the calling product's scoped accounts.", - }, - { - name: "PrivateKey", - type: '{ tag: "PrivateKey"; value: { sr25519SecretKey: HexString } }', - description: - "Fund from a one-time account represented by its private key. This is a\nstandard account holding public funds, not a coin key.", - }, - { - name: "Coins", - type: '{ tag: "Coins"; value: { sr25519SecretKeys: Array } }', - description: - "Fund directly from coin secret keys. Each key is an sr25519 secret\ncontrolling a single coin.", - }, - ], - }, - { - id: "preimage-submit-error", - name: "PreimageSubmitError", - category: "preimage", - definition: - 'export type PreimageSubmitError =\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: "Preimage submission error.", - variants: [ - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "product-account", - name: "ProductAccount", - category: "account", - definition: "export interface ProductAccount {\n publicKey: HexString;\n}", - description: "A product account: public key only, no display name.", - fields: [ - { - name: "public_key", - type: "HexString", - description: "The account public key (variable-length bytes).", - }, - ], - }, - { - id: "product-account-id", - name: "ProductAccountId", - category: "account", - definition: - "export interface ProductAccountId {\n dotNsIdentifier: string;\n derivationIndex: number;\n}", - description: - "Identifies a product-specific account by combining a dotNS domain name with a\nderivation index.", - fields: [ - { - name: "dot_ns_identifier", - type: "string", - description: - 'A dotNS domain name identifier (e.g., `"my-product.dot"`).', - }, - { - name: "derivation_index", - type: "number", - description: - "Key derivation index for generating product-specific accounts.", - }, - ], - }, - { - id: "product-account-tx-payload", - name: "ProductAccountTxPayload", - category: "transaction", - definition: - "export interface ProductAccountTxPayload {\n signer: ProductAccountId;\n genesisHash: GenesisHash;\n callData: HexString;\n extensions: Array;\n txExtVersion: number;\n}", - description: - "Transaction payload for a product account.\n\nContains everything the host needs to construct a signed extrinsic.\nThe signer is a [`ProductAccountId`]; the host resolves the\ncorresponding key pair through its account management layer.", - fields: [ - { - name: "signer", - type: "ProductAccountId", - description: "Product account that will sign the transaction.", - }, - { - name: "genesis_hash", - type: "GenesisHash", - description: "Chain where the transaction will execute.", - }, - { - name: "call_data", - type: "HexString", - description: "SCALE-encoded Call data.", - }, - { - name: "extensions", - type: "Array", - description: "Transaction extensions supplied by the caller.", - }, - { - name: "tx_ext_version", - type: "number", - description: "0 for Extrinsic V4, runtime-supported value for V5.", - }, - ], - }, - { - id: "product-chat-custom-message-render-subscribe-request", - name: "ProductChatCustomMessageRenderSubscribeRequest", - category: "chat", - definition: - "export interface ProductChatCustomMessageRenderSubscribeRequest {\n messageId: string;\n messageType: string;\n payload: HexString;\n}", - description: - "Subscribe payload identifying the chat message to render. The host responds\nwith a stream of [`CustomRendererNode`] trees describing the rendered UI.", - fields: [ - { - name: "message_id", - type: "string", - description: "Message identifier.", - }, - { - name: "message_type", - type: "string", - description: "Application-defined message type.", - }, - { - name: "payload", - type: "HexString", - description: "Binary payload.", - }, - ], - }, - { - id: "raw-payload", - name: "RawPayload", - category: "signing", - definition: - 'export type RawPayload =\n | { tag: "Bytes"; value: { bytes: HexString } }\n | { tag: "Payload"; value: { payload: string } }\n;', - description: "Raw data to sign -- either binary bytes or a string message.", - variants: [ - { - name: "Bytes", - type: '{ tag: "Bytes"; value: { bytes: HexString } }', - description: "Raw binary data to sign.", - }, - { - name: "Payload", - type: '{ tag: "Payload"; value: { payload: string } }', - description: "String message to sign.", - }, - ], - }, - { - id: "remote-chain-head-body-request", - name: "RemoteChainHeadBodyRequest", - category: "chain", - definition: - "export interface RemoteChainHeadBodyRequest {\n genesisHash: HexString;\n followSubscriptionId: string;\n hash: HexString;\n}", - fields: [ - { - name: "genesis_hash", - type: "HexString", - description: "Chain genesis hash.", - }, - { - name: "follow_subscription_id", - type: "string", - description: "Follow subscription identifier.", - }, - { - name: "hash", - type: "HexString", - description: "Block hash.", - }, - ], - }, - { - id: "remote-chain-head-body-response", - name: "RemoteChainHeadBodyResponse", - category: "chain", - definition: - "export interface RemoteChainHeadBodyResponse {\n operation: OperationStartedResult;\n}", - fields: [ - { - name: "operation", - type: "OperationStartedResult", - description: "Started operation result.", - }, - ], - }, - { - id: "remote-chain-head-call-request", - name: "RemoteChainHeadCallRequest", - category: "chain", - definition: - "export interface RemoteChainHeadCallRequest {\n genesisHash: HexString;\n followSubscriptionId: string;\n hash: HexString;\n function: string;\n callParameters: HexString;\n}", - fields: [ - { - name: "genesis_hash", - type: "HexString", - description: "Chain genesis hash.", - }, - { - name: "follow_subscription_id", - type: "string", - description: "Follow subscription identifier.", - }, - { - name: "hash", - type: "HexString", - description: "Block hash.", - }, - { - name: "function", - type: "string", - description: "Runtime API function name.", - }, - { - name: "call_parameters", - type: "HexString", - description: "SCALE-encoded call parameters.", - }, - ], - }, - { - id: "remote-chain-head-call-response", - name: "RemoteChainHeadCallResponse", - category: "chain", - definition: - "export interface RemoteChainHeadCallResponse {\n operation: OperationStartedResult;\n}", - fields: [ - { - name: "operation", - type: "OperationStartedResult", - description: "Started operation result.", - }, - ], - }, - { - id: "remote-chain-head-continue-request", - name: "RemoteChainHeadContinueRequest", - category: "chain", - definition: - "export interface RemoteChainHeadContinueRequest {\n genesisHash: HexString;\n followSubscriptionId: string;\n operationId: string;\n}", - fields: [ - { - name: "genesis_hash", - type: "HexString", - description: "Chain genesis hash.", - }, - { - name: "follow_subscription_id", - type: "string", - description: "Follow subscription identifier.", - }, - { - name: "operation_id", - type: "string", - description: "Operation identifier.", - }, - ], - }, - { - id: "remote-chain-head-follow-item", - name: "RemoteChainHeadFollowItem", - category: "chain", - definition: - 'export type RemoteChainHeadFollowItem =\n | { tag: "Initialized"; value: { finalizedBlockHashes: Array; finalizedBlockRuntime?: RuntimeType } }\n | { tag: "NewBlock"; value: { blockHash: HexString; parentBlockHash: HexString; newRuntime?: RuntimeType } }\n | { tag: "BestBlockChanged"; value: { bestBlockHash: HexString } }\n | { tag: "Finalized"; value: { finalizedBlockHashes: Array; prunedBlockHashes: Array } }\n | { tag: "OperationBodyDone"; value: { operationId: string; value: Array } }\n | { tag: "OperationCallDone"; value: { operationId: string; output: HexString } }\n | { tag: "OperationStorageItems"; value: { operationId: string; items: Array } }\n | { tag: "OperationStorageDone"; value: { operationId: string } }\n | { tag: "OperationWaitingForContinue"; value: { operationId: string } }\n | { tag: "OperationInaccessible"; value: { operationId: string } }\n | { tag: "OperationError"; value: { operationId: string; error: string } }\n | { tag: "Stop"; value?: undefined }\n;', - variants: [ - { - name: "Initialized", - type: '{ tag: "Initialized"; value: { finalizedBlockHashes: Array; finalizedBlockRuntime?: RuntimeType } }', - }, - { - name: "NewBlock", - type: '{ tag: "NewBlock"; value: { blockHash: HexString; parentBlockHash: HexString; newRuntime?: RuntimeType } }', - }, - { - name: "BestBlockChanged", - type: '{ tag: "BestBlockChanged"; value: { bestBlockHash: HexString } }', - }, - { - name: "Finalized", - type: '{ tag: "Finalized"; value: { finalizedBlockHashes: Array; prunedBlockHashes: Array } }', - }, - { - name: "OperationBodyDone", - type: '{ tag: "OperationBodyDone"; value: { operationId: string; value: Array } }', - }, - { - name: "OperationCallDone", - type: '{ tag: "OperationCallDone"; value: { operationId: string; output: HexString } }', - }, - { - name: "OperationStorageItems", - type: '{ tag: "OperationStorageItems"; value: { operationId: string; items: Array } }', - }, - { - name: "OperationStorageDone", - type: '{ tag: "OperationStorageDone"; value: { operationId: string } }', - }, - { - name: "OperationWaitingForContinue", - type: '{ tag: "OperationWaitingForContinue"; value: { operationId: string } }', - }, - { - name: "OperationInaccessible", - type: '{ tag: "OperationInaccessible"; value: { operationId: string } }', - }, - { - name: "OperationError", - type: '{ tag: "OperationError"; value: { operationId: string; error: string } }', - }, - { - name: "Stop", - type: '{ tag: "Stop"; value?: undefined }', - }, - ], - }, - { - id: "remote-chain-head-follow-request", - name: "RemoteChainHeadFollowRequest", - category: "chain", - definition: - "export interface RemoteChainHeadFollowRequest {\n genesisHash: HexString;\n withRuntime: boolean;\n}", - fields: [ - { - name: "genesis_hash", - type: "HexString", - description: "Chain genesis hash.", - }, - { - name: "with_runtime", - type: "boolean", - description: "Whether to include runtime information in events.", - }, - ], - }, - { - id: "remote-chain-head-header-request", - name: "RemoteChainHeadHeaderRequest", - category: "chain", - definition: - "export interface RemoteChainHeadHeaderRequest {\n genesisHash: HexString;\n followSubscriptionId: string;\n hash: HexString;\n}", - fields: [ - { - name: "genesis_hash", - type: "HexString", - description: "Chain genesis hash.", - }, - { - name: "follow_subscription_id", - type: "string", - description: "Follow subscription identifier.", - }, - { - name: "hash", - type: "HexString", - description: "Block hash.", - }, - ], - }, - { - id: "remote-chain-head-header-response", - name: "RemoteChainHeadHeaderResponse", - category: "chain", - definition: - "export interface RemoteChainHeadHeaderResponse {\n header?: HexString;\n}", - fields: [ - { - name: "header", - type: "HexString | undefined", - description: "SCALE-encoded block header.", - }, - ], - }, - { - id: "remote-chain-head-stop-operation-request", - name: "RemoteChainHeadStopOperationRequest", - category: "chain", - definition: - "export interface RemoteChainHeadStopOperationRequest {\n genesisHash: HexString;\n followSubscriptionId: string;\n operationId: string;\n}", - fields: [ - { - name: "genesis_hash", - type: "HexString", - description: "Chain genesis hash.", - }, - { - name: "follow_subscription_id", - type: "string", - description: "Follow subscription identifier.", - }, - { - name: "operation_id", - type: "string", - description: "Operation identifier.", - }, - ], - }, - { - id: "remote-chain-head-storage-request", - name: "RemoteChainHeadStorageRequest", - category: "chain", - definition: - "export interface RemoteChainHeadStorageRequest {\n genesisHash: HexString;\n followSubscriptionId: string;\n hash: HexString;\n items: Array;\n childTrie?: HexString;\n}", - fields: [ - { - name: "genesis_hash", - type: "HexString", - description: "Chain genesis hash.", - }, - { - name: "follow_subscription_id", - type: "string", - description: "Follow subscription identifier.", - }, - { - name: "hash", - type: "HexString", - description: "Block hash.", - }, - { - name: "items", - type: "Array", - description: "Storage items to query.", - }, - { - name: "child_trie", - type: "HexString | undefined", - description: "Optional child trie.", - }, - ], - }, - { - id: "remote-chain-head-storage-response", - name: "RemoteChainHeadStorageResponse", - category: "chain", - definition: - "export interface RemoteChainHeadStorageResponse {\n operation: OperationStartedResult;\n}", - fields: [ - { - name: "operation", - type: "OperationStartedResult", - description: "Started operation result.", - }, - ], - }, - { - id: "remote-chain-head-unpin-request", - name: "RemoteChainHeadUnpinRequest", - category: "chain", - definition: - "export interface RemoteChainHeadUnpinRequest {\n genesisHash: HexString;\n followSubscriptionId: string;\n hashes: Array;\n}", - fields: [ - { - name: "genesis_hash", - type: "HexString", - description: "Chain genesis hash.", - }, - { - name: "follow_subscription_id", - type: "string", - description: "Follow subscription identifier.", - }, - { - name: "hashes", - type: "Array", - description: "Block hashes to unpin.", - }, - ], - }, - { - id: "remote-chain-spec-chain-name-request", - name: "RemoteChainSpecChainNameRequest", - category: "chain", - definition: - "export interface RemoteChainSpecChainNameRequest {\n genesisHash: HexString;\n}", - fields: [ - { - name: "genesis_hash", - type: "HexString", - description: "Chain genesis hash.", - }, - ], - }, - { - id: "remote-chain-spec-chain-name-response", - name: "RemoteChainSpecChainNameResponse", - category: "chain", - definition: - "export interface RemoteChainSpecChainNameResponse {\n chainName: string;\n}", - fields: [ - { - name: "chain_name", - type: "string", - description: "Chain display name.", - }, - ], - }, - { - id: "remote-chain-spec-genesis-hash-request", - name: "RemoteChainSpecGenesisHashRequest", - category: "chain", - definition: - "export interface RemoteChainSpecGenesisHashRequest {\n genesisHash: HexString;\n}", - fields: [ - { - name: "genesis_hash", - type: "HexString", - description: "Chain genesis hash requested by the product.", - }, - ], - }, - { - id: "remote-chain-spec-genesis-hash-response", - name: "RemoteChainSpecGenesisHashResponse", - category: "chain", - definition: - "export interface RemoteChainSpecGenesisHashResponse {\n genesisHash: HexString;\n}", - fields: [ - { - name: "genesis_hash", - type: "HexString", - description: "Chain genesis hash.", - }, - ], - }, - { - id: "remote-chain-spec-properties-request", - name: "RemoteChainSpecPropertiesRequest", - category: "chain", - definition: - "export interface RemoteChainSpecPropertiesRequest {\n genesisHash: HexString;\n}", - fields: [ - { - name: "genesis_hash", - type: "HexString", - description: "Chain genesis hash.", - }, - ], - }, - { - id: "remote-chain-spec-properties-response", - name: "RemoteChainSpecPropertiesResponse", - category: "chain", - definition: - "export interface RemoteChainSpecPropertiesResponse {\n properties: string;\n}", - fields: [ - { - name: "properties", - type: "string", - description: "JSON-encoded properties.", - }, - ], - }, - { - id: "remote-chain-transaction-broadcast-request", - name: "RemoteChainTransactionBroadcastRequest", - category: "chain", - definition: - "export interface RemoteChainTransactionBroadcastRequest {\n genesisHash: HexString;\n transaction: HexString;\n}", - fields: [ - { - name: "genesis_hash", - type: "HexString", - description: "Chain genesis hash.", - }, - { - name: "transaction", - type: "HexString", - description: "Signed transaction bytes.", - }, - ], - }, - { - id: "remote-chain-transaction-broadcast-response", - name: "RemoteChainTransactionBroadcastResponse", - category: "chain", - definition: - "export interface RemoteChainTransactionBroadcastResponse {\n operationId?: string;\n}", - fields: [ - { - name: "operation_id", - type: "string | undefined", - description: "Broadcast operation identifier, if available.", - }, - ], - }, - { - id: "remote-chain-transaction-stop-request", - name: "RemoteChainTransactionStopRequest", - category: "chain", - definition: - "export interface RemoteChainTransactionStopRequest {\n genesisHash: HexString;\n operationId: string;\n}", - fields: [ - { - name: "genesis_hash", - type: "HexString", - description: "Chain genesis hash.", - }, - { - name: "operation_id", - type: "string", - description: "Operation identifier of the broadcast to stop.", - }, - ], - }, - { - id: "remote-permission", - name: "RemotePermission", - category: "permissions", - definition: - 'export type RemotePermission =\n | { tag: "Remote"; value: { domains: Array } }\n | { tag: "WebRtc"; value?: undefined }\n | { tag: "ChainSubmit"; value?: undefined }\n | { tag: "PreimageSubmit"; value?: undefined }\n | { tag: "StatementSubmit"; value?: undefined }\n;', - description: - "One remote-operation permission requested by the product (RFC 0002).\n\n`ChainSubmit`, `PreimageSubmit`, and `StatementSubmit` are also triggered\nimplicitly by the corresponding business calls when not yet granted.", - variants: [ - { - name: "Remote", - type: '{ tag: "Remote"; value: { domains: Array } }', - description: "Outbound HTTP/WebSocket access to a set of domains.", - }, - { - name: "WebRtc", - type: '{ tag: "WebRtc"; value?: undefined }', - description: "WebRTC media access.", - }, - { - name: "ChainSubmit", - type: '{ tag: "ChainSubmit"; value?: undefined }', - description: - "Submitting transactions on behalf of the user via `remote_chain_transaction_broadcast`.", - }, - { - name: "PreimageSubmit", - type: '{ tag: "PreimageSubmit"; value?: undefined }', - description: - "Submitting preimages on behalf of the user via `remote_preimage_submit`.", - }, - { - name: "StatementSubmit", - type: '{ tag: "StatementSubmit"; value?: undefined }', - description: - "Submitting statements on behalf of the user via `remote_statement_store_submit`.", - }, - ], - }, - { - id: "remote-permission-request", - name: "RemotePermissionRequest", - category: "permissions", - definition: - "export interface RemotePermissionRequest {\n permission: RemotePermission;\n}", - description: "remote-permission request (RFC 0002).", - fields: [ - { - name: "permission", - type: "RemotePermission", - description: "Permission requested by the product.", - }, - ], - }, - { - id: "remote-permission-response", - name: "RemotePermissionResponse", - category: "permissions", - definition: - "export interface RemotePermissionResponse {\n granted: boolean;\n}", - description: "Outcome of a remote-permission request.", - fields: [ - { - name: "granted", - type: "boolean", - description: "Whether the permission was granted.", - }, - ], - }, - { - id: "remote-preimage-lookup-subscribe-item", - name: "RemotePreimageLookupSubscribeItem", - category: "preimage", - definition: - "export interface RemotePreimageLookupSubscribeItem {\n value?: HexString;\n}", - description: "Item containing an optional preimage lookup result.", - fields: [ - { - name: "value", - type: "HexString | undefined", - description: "Preimage data, if found.", - }, - ], - }, - { - id: "remote-preimage-lookup-subscribe-request", - name: "RemotePreimageLookupSubscribeRequest", - category: "preimage", - definition: - "export interface RemotePreimageLookupSubscribeRequest {\n key: HexString;\n}", - description: "Request to subscribe to preimage lookup results.", - fields: [ - { - name: "key", - type: "HexString", - description: "Hash of the preimage.", - }, - ], - }, - { - id: "remote-statement-store-create-proof-error", - name: "RemoteStatementStoreCreateProofError", - category: "statement_store", - definition: - 'export type RemoteStatementStoreCreateProofError =\n | { tag: "UnableToSign"; value?: undefined }\n | { tag: "UnknownAccount"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: "Statement proof creation error.", - variants: [ - { - name: "UnableToSign", - type: '{ tag: "UnableToSign"; value?: undefined }', - description: "Signing operation failed.", - }, - { - name: "UnknownAccount", - type: '{ tag: "UnknownAccount"; value?: undefined }', - description: "Account not recognized.", - }, - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "remote-statement-store-create-proof-request", - name: "RemoteStatementStoreCreateProofRequest", - category: "statement_store", - definition: - "export interface RemoteStatementStoreCreateProofRequest {\n productAccountId: ProductAccountId;\n statement: Statement;\n}", - description: "Request to create a cryptographic proof for a statement.", - fields: [ - { - name: "product_account_id", - type: "ProductAccountId", - description: "Product account that should create the proof.", - }, - { - name: "statement", - type: "Statement", - description: "Statement to prove.", - }, - ], - }, - { - id: "remote-statement-store-create-proof-response", - name: "RemoteStatementStoreCreateProofResponse", - category: "statement_store", - definition: - "export interface RemoteStatementStoreCreateProofResponse {\n proof: StatementProof;\n}", - description: "Response containing a statement proof.", - fields: [ - { - name: "proof", - type: "StatementProof", - description: "Created statement proof.", - }, - ], - }, - { - id: "remote-statement-store-subscribe-item", - name: "RemoteStatementStoreSubscribeItem", - category: "statement_store", - definition: - "export interface RemoteStatementStoreSubscribeItem {\n statements: Array;\n isComplete: boolean;\n}", - description: - "Page of signed statements delivered by the statement store subscription\n(RFC 0008). The `is_complete` flag distinguishes the historical-dump phase\n(`false`) from the live-update phase (`true`).", - fields: [ - { - name: "statements", - type: "Array", - description: "Signed statements matching the subscription.", - }, - { - name: "is_complete", - type: "boolean", - description: - "`false` while the host is still streaming the historical dump (more\npages to follow). `true` once the dump is complete; all subsequent\npages are also `true` and carry only newly-arrived statements.", - }, - ], - }, - { - id: "remote-statement-store-subscribe-request", - name: "RemoteStatementStoreSubscribeRequest", - category: "statement_store", - definition: - 'export type RemoteStatementStoreSubscribeRequest =\n | { tag: "MatchAll"; value: Array }\n | { tag: "MatchAny"; value: Array }\n;', - description: - "Request to subscribe to statements via a topic filter (RFC 0008).", - variants: [ - { - name: "MatchAll", - type: '{ tag: "MatchAll"; value: Array }', - description: "AND: statement must contain every listed topic.", - }, - { - name: "MatchAny", - type: '{ tag: "MatchAny"; value: Array }', - description: "OR: statement must contain at least one listed topic.", - }, - ], - }, - { - id: "resource-allocation-error", - name: "ResourceAllocationError", - category: "resource_allocation", - definition: - 'export type ResourceAllocationError =\n | { tag: "Unknown"; value: { reason: string } }\n;', - description: "Error from [`crate::api::ResourceAllocation::request`].", - variants: [ - { - name: "Unknown", - type: '{ tag: "Unknown"; value: { reason: string } }', - description: "Catch-all.", - }, - ], - }, - { - id: "ring-location", - name: "RingLocation", - category: "account", - definition: - "export interface RingLocation {\n genesisHash: HexString;\n ringRootHash: HexString;\n hints?: RingLocationHint;\n}", - description: - "Locates a specific ring on a specific chain for ring VRF operations.", - fields: [ - { - name: "genesis_hash", - type: "HexString", - description: "Chain genesis hash.", - }, - { - name: "ring_root_hash", - type: "HexString", - description: "Root hash of the ring.", - }, - { - name: "hints", - type: "RingLocationHint | undefined", - description: "Optional location hints.", - }, - ], - }, - { - id: "ring-location-hint", - name: "RingLocationHint", - category: "account", - definition: - "export interface RingLocationHint {\n palletInstance?: number;\n}", - description: "Hints for locating a ring on-chain.", - fields: [ - { - name: "pallet_instance", - type: "number | undefined", - description: "Optional pallet instance index.", - }, - ], - }, - { - id: "row-props", - name: "RowProps", - category: "chat", - definition: - "export interface RowProps {\n verticalAlignment?: VerticalAlignment;\n horizontalArrangement?: Arrangement;\n}", - description: "Properties for a [`CustomRendererNode::Row`] layout.", - fields: [ - { - name: "vertical_alignment", - type: "VerticalAlignment | undefined", - description: "Vertical alignment of children.", - }, - { - name: "horizontal_arrangement", - type: "Arrangement | undefined", - description: "Horizontal arrangement of children.", - }, - ], - }, - { - id: "runtime-api", - name: "RuntimeApi", - category: "chain", - definition: - "export interface RuntimeApi {\n name: string;\n version: number;\n}", - fields: [ - { - name: "name", - type: "string", - description: "Runtime API name.", - }, - { - name: "version", - type: "number", - description: "Runtime API version.", - }, - ], - }, - { - id: "runtime-spec", - name: "RuntimeSpec", - category: "chain", - definition: - "export interface RuntimeSpec {\n specName: string;\n implName: string;\n specVersion: number;\n implVersion: number;\n transactionVersion?: number;\n apis: Array;\n}", - fields: [ - { - name: "spec_name", - type: "string", - description: "Specification name.", - }, - { - name: "impl_name", - type: "string", - description: "Implementation name.", - }, - { - name: "spec_version", - type: "number", - description: "Spec version number.", - }, - { - name: "impl_version", - type: "number", - description: "Implementation version.", - }, - { - name: "transaction_version", - type: "number | undefined", - description: "Transaction format version.", - }, - { - name: "apis", - type: "Array", - description: "Supported runtime APIs.", - }, - ], - }, - { - id: "runtime-type", - name: "RuntimeType", - category: "chain", - definition: - 'export type RuntimeType =\n | { tag: "Valid"; value: RuntimeSpec }\n | { tag: "Invalid"; value: { error: string } }\n;', - variants: [ - { - name: "Valid", - type: '{ tag: "Valid"; value: RuntimeSpec }', - }, - { - name: "Invalid", - type: '{ tag: "Invalid"; value: { error: string } }', - }, - ], - }, - { - id: "shape", - name: "Shape", - category: "chat", - definition: - 'export type Shape =\n | { tag: "Rounded"; value: { radius: Size } }\n | { tag: "Circle"; value?: undefined }\n;', - description: "Shape for borders and backgrounds.", - variants: [ - { - name: "Rounded", - type: '{ tag: "Rounded"; value: { radius: Size } }', - description: "Border radius value.", - }, - { - name: "Circle", - type: '{ tag: "Circle"; value?: undefined }', - description: "Circular shape.", - }, - ], - }, - { - id: "signed-statement", - name: "SignedStatement", - category: "statement_store", - definition: - "export interface SignedStatement {\n proof: StatementProof;\n decryptionKey?: HexString;\n expiry?: bigint;\n channel?: HexString;\n topics: Array;\n data?: HexString;\n}", - description: "A statement with a required (not optional) proof.", - fields: [ - { - name: "proof", - type: "StatementProof", - description: "Required cryptographic proof.", - }, - { - name: "decryption_key", - type: "HexString | undefined", - description: "Optional decryption key.", - }, - { - name: "expiry", - type: "bigint | undefined", - description: "Optional Unix timestamp expiry.", - }, - { - name: "channel", - type: "HexString | undefined", - description: "Optional channel.", - }, - { - name: "topics", - type: "Array", - description: "[u8; 32] tags.", - }, - { - name: "data", - type: "HexString | undefined", - description: "Optional data payload.", - }, - ], - }, - { - id: "size", - name: "Size", - category: "chat", - definition: "export type Size = number | bigint;", - description: - "A size/dimension value (logical pixels) used across the custom renderer.\n\nEncoded as a SCALE `Compact`: the common small values cost a single\nbyte on the wire instead of eight.", - }, - { - id: "statement", - name: "Statement", - category: "statement_store", - definition: - "export interface Statement {\n proof?: StatementProof;\n decryptionKey?: HexString;\n expiry?: bigint;\n channel?: HexString;\n topics: Array;\n data?: HexString;\n}", - description: "A statement with optional proof and metadata.", - fields: [ - { - name: "proof", - type: "StatementProof | undefined", - description: "Optional cryptographic proof.", - }, - { - name: "decryption_key", - type: "HexString | undefined", - description: "Optional decryption key.", - }, - { - name: "expiry", - type: "bigint | undefined", - description: "Optional Unix timestamp expiry.", - }, - { - name: "channel", - type: "HexString | undefined", - description: "Optional channel.", - }, - { - name: "topics", - type: "Array", - description: "[u8; 32] tags.", - }, - { - name: "data", - type: "HexString | undefined", - description: "Optional data payload.", - }, - ], - }, - { - id: "statement-proof", - name: "StatementProof", - category: "statement_store", - definition: - 'export type StatementProof =\n | { tag: "Sr25519"; value: { signature: HexString; signer: HexString } }\n | { tag: "Ed25519"; value: { signature: HexString; signer: HexString } }\n | { tag: "Ecdsa"; value: { signature: HexString; signer: HexString } }\n | { tag: "OnChain"; value: { who: HexString; blockHash: HexString; event: bigint } }\n;', - description: "Cryptographic proof for a statement.", - variants: [ - { - name: "Sr25519", - type: '{ tag: "Sr25519"; value: { signature: HexString; signer: HexString } }', - description: "Sr25519 signature proof.", - }, - { - name: "Ed25519", - type: '{ tag: "Ed25519"; value: { signature: HexString; signer: HexString } }', - description: "Ed25519 signature proof.", - }, - { - name: "Ecdsa", - type: '{ tag: "Ecdsa"; value: { signature: HexString; signer: HexString } }', - description: "ECDSA signature proof.", - }, - { - name: "OnChain", - type: '{ tag: "OnChain"; value: { who: HexString; blockHash: HexString; event: bigint } }', - description: "On-chain event proof.", - }, - ], - }, - { - id: "storage-query-item", - name: "StorageQueryItem", - category: "chain", - definition: - "export interface StorageQueryItem {\n key: HexString;\n queryType: StorageQueryType;\n}", - fields: [ - { - name: "key", - type: "HexString", - description: "Storage key to query.", - }, - { - name: "query_type", - type: "StorageQueryType", - description: "What to return.", - }, - ], - }, - { - id: "storage-query-type", - name: "StorageQueryType", - category: "chain", - definition: - 'export type StorageQueryType = "Value" | "Hash" | "ClosestDescendantMerkleValue" | "DescendantsValues" | "DescendantsHashes";', - variants: [ - { - name: "Value", - type: '{ tag: "Value"; value?: undefined }', - }, - { - name: "Hash", - type: '{ tag: "Hash"; value?: undefined }', - }, - { - name: "ClosestDescendantMerkleValue", - type: '{ tag: "ClosestDescendantMerkleValue"; value?: undefined }', - }, - { - name: "DescendantsValues", - type: '{ tag: "DescendantsValues"; value?: undefined }', - }, - { - name: "DescendantsHashes", - type: '{ tag: "DescendantsHashes"; value?: undefined }', - }, - ], - }, - { - id: "storage-result-item", - name: "StorageResultItem", - category: "chain", - definition: - "export interface StorageResultItem {\n key: HexString;\n value?: HexString;\n hash?: HexString;\n closestDescendantMerkleValue?: HexString;\n}", - fields: [ - { - name: "key", - type: "HexString", - description: "The queried key.", - }, - { - name: "value", - type: "HexString | undefined", - description: "Value, if requested.", - }, - { - name: "hash", - type: "HexString | undefined", - description: "Hash, if requested.", - }, - { - name: "closest_descendant_merkle_value", - type: "HexString | undefined", - description: "Merkle value, if requested.", - }, - ], - }, - { - id: "text-field-props", - name: "TextFieldProps", - category: "chat", - definition: - "export interface TextFieldProps {\n text: string;\n placeholder?: string;\n label?: string;\n enabled: boolean | undefined;\n valueChangeAction?: string;\n}", - description: "Properties for a [`CustomRendererNode::TextField`].", - fields: [ - { - name: "text", - type: "string", - description: "Current text value.", - }, - { - name: "placeholder", - type: "string | undefined", - description: "Placeholder text.", - }, - { - name: "label", - type: "string | undefined", - description: "Field label.", - }, - { - name: "enabled", - type: "boolean | undefined", - description: - "Whether the field is enabled. Absent leaves the default to the host.", - }, - { - name: "value_change_action", - type: "string | undefined", - description: "Action identifier triggered when the value changes.", - }, - ], - }, - { - id: "text-props", - name: "TextProps", - category: "chat", - definition: - "export interface TextProps {\n style?: TypographyStyle;\n color?: ColorToken;\n}", - description: "Properties for a [`CustomRendererNode::Text`] display.", - fields: [ - { - name: "style", - type: "TypographyStyle | undefined", - description: "Typography preset.", - }, - { - name: "color", - type: "ColorToken | undefined", - description: "Text color.", - }, - ], - }, - { - id: "theme-name", - name: "ThemeName", - category: "theme", - definition: - 'export type ThemeName =\n | { tag: "Custom"; value: string }\n | { tag: "Default"; value?: undefined }\n;', - description: "Identifies a named theme.", - variants: [ - { - name: "Custom", - type: '{ tag: "Custom"; value: string }', - description: "A custom named theme.", - }, - { - name: "Default", - type: '{ tag: "Default"; value?: undefined }', - description: "The host's default theme.", - }, - ], - }, - { - id: "theme-variant", - name: "ThemeVariant", - category: "theme", - definition: 'export type ThemeVariant = "Light" | "Dark";', - description: "Light or dark variant.", - variants: [ - { - name: "Light", - type: '{ tag: "Light"; value?: undefined }', - }, - { - name: "Dark", - type: '{ tag: "Dark"; value?: undefined }', - }, - ], - }, - { - id: "topic", - name: "Topic", - category: "statement_store", - definition: "export type Topic = HexString;", - description: "32-byte statement topic.", - }, - { - id: "tx-payload-extension", - name: "TxPayloadExtension", - category: "transaction", - definition: - "export interface TxPayloadExtension {\n id: string;\n extra: HexString;\n additionalSigned: HexString;\n}", - description: "A signed extension for a transaction payload.", - fields: [ - { - name: "id", - type: "string", - description: 'Extension name (e.g., `"CheckSpecVersion"`).', - }, - { - name: "extra", - type: "HexString", - description: "SCALE-encoded extra data (in extrinsic body).", - }, - { - name: "additional_signed", - type: "HexString", - description: "SCALE-encoded implicit data (signed, not in body).", - }, - ], - }, - { - id: "typography-style", - name: "TypographyStyle", - category: "chat", - definition: - 'export type TypographyStyle = "HeadlineLarge" | "TitleMediumRegular" | "BodyLargeRegular" | "BodyMediumRegular" | "BodySmallRegular";', - description: "Text typography presets.", - variants: [ - { - name: "HeadlineLarge", - type: '{ tag: "HeadlineLarge"; value?: undefined }', - }, - { - name: "TitleMediumRegular", - type: '{ tag: "TitleMediumRegular"; value?: undefined }', - }, - { - name: "BodyLargeRegular", - type: '{ tag: "BodyLargeRegular"; value?: undefined }', - }, - { - name: "BodyMediumRegular", - type: '{ tag: "BodyMediumRegular"; value?: undefined }', - }, - { - name: "BodySmallRegular", - type: '{ tag: "BodySmallRegular"; value?: undefined }', - }, - ], - }, - { - id: "vertical-alignment", - name: "VerticalAlignment", - category: "chat", - definition: 'export type VerticalAlignment = "Top" | "Center" | "Bottom";', - description: "Vertical alignment options.", - variants: [ - { - name: "Top", - type: '{ tag: "Top"; value?: undefined }', - }, - { - name: "Center", - type: '{ tag: "Center"; value?: undefined }', - }, - { - name: "Bottom", - type: '{ tag: "Bottom"; value?: undefined }', - }, - ], - }, -]; diff --git a/js/packages/truapi/src/sandbox.ts b/js/packages/truapi/src/sandbox.ts index 51861e4e..702cda26 100644 --- a/js/packages/truapi/src/sandbox.ts +++ b/js/packages/truapi/src/sandbox.ts @@ -10,11 +10,7 @@ * @module */ -import { - createIframeProvider, - createMessagePortProvider, - type WireProvider, -} from "./transport.js"; +import { createMessagePortProvider, type WireProvider } from "./transport.js"; import { createTransport } from "./client.js"; import { createClient, type TrUApiClient } from "./generated/index.js"; @@ -62,10 +58,7 @@ export function isCorrectEnvironment(): boolean { } /** - * Origin used as the `targetOrigin` for outbound `postMessage` frames. Frames - * carry signed payloads and account ids, so this fails closed: when no concrete - * origin can be pinned it returns `null` (rather than falling back to `"*"`) and - * provider construction throws. + * Origin used as the `targetOrigin` for iframe bootstrap messages. */ function resolveHostOrigin(): string | null { if (typeof document !== "undefined" && document.referrer) { @@ -80,7 +73,8 @@ function resolveHostOrigin(): string | null { return null; } -const WEBVIEW_PORT_TIMEOUT_MS = 20_000; +const HOST_PORT_TIMEOUT_MS = 20_000; +let iframePortPromise: Promise | null = null; /** * Resolve the host-injected `MessagePort`, polling `window.__HOST_API_PORT__` @@ -93,7 +87,7 @@ const WEBVIEW_PORT_TIMEOUT_MS = 20_000; */ async function waitForWebviewPort( signal?: AbortSignal, - timeoutMs = WEBVIEW_PORT_TIMEOUT_MS, + timeoutMs = HOST_PORT_TIMEOUT_MS, ): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { @@ -107,21 +101,86 @@ async function waitForWebviewPort( ); } -/** Build the {@link WireProvider} matching the detected environment (iframe or webview). */ -function createSandboxProvider(): WireProvider { - if (isIframe()) { +/** + * Resolve the iframe `MessagePort` transferred by `createIframeHost`. + */ +function waitForIframePort( + signal?: AbortSignal, + timeoutMs = HOST_PORT_TIMEOUT_MS, +): Promise { + const existing = hostWindow()?.__HOST_API_PORT__; + if (existing) return Promise.resolve(existing); + if (iframePortPromise) return iframePortPromise; + + iframePortPromise = new Promise((resolve, reject) => { + const win = hostWindow(); + if (!win) { + reject(new Error("window is unavailable")); + return; + } + const hostOrigin = resolveHostOrigin(); - if (!hostOrigin) { - throw new Error( - "TrUAPI iframe provider could not resolve the host origin from document.referrer / ancestorOrigins.", + let done = false; + const cleanup = (): void => { + win.removeEventListener("message", onMessage); + signal?.removeEventListener("abort", onAbort); + clearTimeout(timer); + }; + const finish = (result: MessagePort | Error): void => { + if (done) return; + done = true; + cleanup(); + if (result instanceof Error) { + reject(result); + } else { + win.__HOST_API_PORT__ = result; + resolve(result); + } + }; + const onAbort = (): void => { + finish(new Error("waitForIframePort aborted")); + }; + const onMessage = (event: MessageEvent): void => { + if (event.source !== win.parent) return; + if ( + hostOrigin !== null && + event.origin !== hostOrigin && + event.origin !== "null" + ) { + return; + } + if (event.data?.type !== "truapi-init") return; + const [port] = event.ports; + if (!port) { + finish(new Error("truapi-init did not include a MessagePort")); + return; + } + finish(port); + }; + const timer = setTimeout(() => { + finish( + new Error(`Timed out waiting for iframe MessagePort (${timeoutMs}ms)`), ); - } - return createIframeProvider({ target: window.parent, hostOrigin }); - } + }, timeoutMs); + + win.addEventListener("message", onMessage); + signal?.addEventListener("abort", onAbort, { once: true }); + win.parent.postMessage({ type: "truapi-ready" }, hostOrigin ?? "*"); + }).catch((error: unknown) => { + iframePortPromise = null; + throw error; + }); + + return iframePortPromise; +} + +/** Build the {@link WireProvider} matching the detected environment (iframe or webview). */ +function createSandboxProvider(): WireProvider { const portController = new AbortController(); - const provider = createMessagePortProvider( - waitForWebviewPort(portController.signal), - ); + const portPromise = isIframe() + ? waitForIframePort(portController.signal) + : waitForWebviewPort(portController.signal); + const provider = createMessagePortProvider(portPromise); const baseDispose = provider.dispose; provider.dispose = () => { portController.abort(); @@ -166,14 +225,21 @@ export function getClientSync(): TrUApiClient | null { export function subscribeConnectionStatus( callback: (status: ConnectionStatus) => void, ): () => void { - statusListeners.add(callback); - callback(status); + let emitted = false; + const listener = (next: ConnectionStatus) => { + emitted = true; + callback(next); + }; + statusListeners.add(listener); if (status === "disconnected") { setStatus(getClientSync() ? "connected" : "disconnected"); } + if (!emitted) { + callback(status); + } return () => { - statusListeners.delete(callback); + statusListeners.delete(listener); }; } 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/js/packages/truapi/src/transport.ts b/js/packages/truapi/src/transport.ts index 0f9ad4de..926ec77b 100644 --- a/js/packages/truapi/src/transport.ts +++ b/js/packages/truapi/src/transport.ts @@ -201,12 +201,11 @@ export interface SubscribeRawParams { **/ export interface TrUApiTransport { /** - * Highest TrUAPI protocol version supported by this generated client. - **/ - readonly truapiVersion: number; - - /** - * SCALE codec version negotiated through the handshake. + * SCALE codec version used by generated handshake calls. + * + * @deprecated TODO(shared-core-wire): remove this public transport field once + * generated handshake requests read `TRUAPI_CODEC_VERSION` directly instead + * of going through transport state. **/ readonly codecVersion: number; diff --git a/js/packages/truapi/tsconfig.json b/js/packages/truapi/tsconfig.json index 79d35ea5..56a11d6f 100644 --- a/js/packages/truapi/tsconfig.json +++ b/js/packages/truapi/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", + "composite": true, "declaration": true, "outDir": "dist", "rootDir": "src", diff --git a/package-lock.json b/package-lock.json index 51905f9a..36199f2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,19 @@ "typescript": "^6.0" } }, + "js/packages/truapi-host-wasm": { + "name": "@parity/truapi-host-wasm", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@parity/truapi": "file:../truapi" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "neverthrow": "^8.2.0", + "typescript": "^5.7" + } + }, "js/packages/truapi/node_modules/typescript": { "version": "6.0.3", "dev": true, @@ -414,6 +427,10 @@ "resolved": "js/packages/truapi", "link": true }, + "node_modules/@parity/truapi-host-wasm": { + "resolved": "js/packages/truapi-host-wasm", + "link": true + }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", @@ -1173,6 +1190,20 @@ "node": ">=8.0" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", diff --git a/playground/tests/e2e/testing.spec.ts b/playground/tests/e2e/testing.spec.ts new file mode 100644 index 00000000..74b2ba81 --- /dev/null +++ b/playground/tests/e2e/testing.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test"; +import { openPlaygroundInDotli, selectMethod, waitForOnline } from "./helpers"; + +test.describe("testing service", () => { + test("version_probe runs through the latest generated request version", async ({ + page, + }) => { + const frame = await openPlaygroundInDotli(page); + await waitForOnline(frame); + + await selectMethod(frame, "Testing", "version_probe"); + await frame.locator('[data-testid="call-button"]').click(); + + const entries = frame.locator('[data-testid="stream-entry"]'); + await expect(entries.first()).toBeVisible({ timeout: 5_000 }); + await expect(frame.locator('[data-testid="error-display"]')).toHaveCount(0); + + const text = await entries.first().innerText(); + expect(text).toContain("testing version probe:"); + expect(text).toContain("receivedVersion"); + expect(text).toContain("2"); + }); + + test("echo_error surfaces a framework call error", async ({ page }) => { + const frame = await openPlaygroundInDotli(page); + await waitForOnline(frame); + + await selectMethod(frame, "Testing", "echo_error"); + await frame.locator('[data-testid="call-button"]').click(); + + const entries = frame.locator('[data-testid="stream-entry"]'); + await expect(entries.first()).toBeVisible({ timeout: 5_000 }); + await expect(frame.locator('[data-testid="error-display"]')).toHaveCount(0); + + const text = await entries.first().innerText(); + expect(text).toContain("echo error:"); + expect(text).toContain("HostFailure"); + expect(text).toContain("forced by test"); + }); +}); From 4c04a814f9566e84bc3e1ad644ac1c01b5944b12 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Tue, 30 Jun 2026 09:42:09 +0200 Subject: [PATCH 6/8] chore: docs, CI, tooling, and dotli integration for the Rust core Updates CLAUDE.md/README, CI workflows, Makefile, deny.toml, changesets, and linguist attributes for generated code, and bumps the dotli submodule to the host integration that consumes the WASM runtime. --- .changeset/rename-provider-to-wireprovider.md | 2 +- .changeset/truapi-sandbox-bootstrap.md | 2 +- .gitattributes | 2 + .github/workflows/check-rfc.yml | 2 +- .github/workflows/ci.yml | 17 +-- .github/workflows/deploy-docs.yml | 2 +- .github/workflows/deploy-playground.yml | 2 +- .github/workflows/diagnosis-report.yml | 2 +- .github/workflows/number-rfc.yml | 2 +- .github/workflows/release-version-check.yml | 2 +- .github/workflows/release.yml | 12 +- CLAUDE.md | 135 +++++++++++++++++- Makefile | 85 +++++++++-- README.md | 34 +++-- deny.toml | 18 +++ docs/local-e2e-testing.md | 36 ++++- hosts/dotli | 2 +- playground/playwright.config.ts | 11 +- playground/tests/e2e/helpers.ts | 14 ++ 19 files changed, 320 insertions(+), 62 deletions(-) create mode 100644 .gitattributes diff --git a/.changeset/rename-provider-to-wireprovider.md b/.changeset/rename-provider-to-wireprovider.md index 7df1bda9..a4b59d9b 100644 --- a/.changeset/rename-provider-to-wireprovider.md +++ b/.changeset/rename-provider-to-wireprovider.md @@ -1,5 +1,5 @@ --- -"@parity/truapi": patch +"@parity/truapi": minor --- Rename the exported `Provider` transport type to `WireProvider` to make its role explicit. It is the low-level SCALE-wire-frame pipe (a `MessagePort` or iframe `postMessage` channel) that `createTransport` runs on. The `createIframeProvider` / `createMessagePortProvider` factories are unchanged; only the type name moves. Consumers importing `Provider` should import `WireProvider` instead. diff --git a/.changeset/truapi-sandbox-bootstrap.md b/.changeset/truapi-sandbox-bootstrap.md index 14eb6333..1b94d436 100644 --- a/.changeset/truapi-sandbox-bootstrap.md +++ b/.changeset/truapi-sandbox-bootstrap.md @@ -1,5 +1,5 @@ --- -"@parity/truapi": patch +"@parity/truapi": minor --- Add the `@parity/truapi/sandbox` entry point: host-environment detection (`isCorrectEnvironment`), a lazily-built cached client (`getClientSync`, `null` outside a host container), and a `subscribeConnectionStatus` connected/disconnected listener. Browser-embedded hosts can bootstrap a client without assembling the transport by hand. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..4fb9ca56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +rust/crates/truapi-codegen/tests/golden/* linguist-generated=true +rust/crates/truapi-server/src/generated/* linguist-generated=true diff --git a/.github/workflows/check-rfc.yml b/.github/workflows/check-rfc.yml index 2bfb03d8..35d50852 100644 --- a/.github/workflows/check-rfc.yml +++ b/.github/workflows/check-rfc.yml @@ -14,7 +14,7 @@ jobs: if: github.event.pull_request.user.login != 'github-actions[bot]' runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5652e58..7e997639 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: pull_request: push: - branches: [main, release/v0.3.2] + branches: [main, release/v0.3.0] merge_group: workflow_dispatch: @@ -21,7 +21,7 @@ jobs: env: RUSTFLAGS: "-D warnings" steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -52,7 +52,7 @@ jobs: name: Dependency licenses runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -65,7 +65,7 @@ jobs: name: Codegen runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -100,6 +100,7 @@ jobs: js/packages/truapi/src/playground/codegen js/packages/truapi/src/explorer/codegen js/packages/truapi/src/explorer/versions.ts + js/packages/truapi-host-wasm/src/generated playground/test/generated ts-client: @@ -109,7 +110,7 @@ jobs: env: TRUAPI_REQUIRE_GENERATED: 1 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -142,7 +143,7 @@ jobs: env: TRUAPI_REQUIRE_GENERATED: 1 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -187,7 +188,7 @@ jobs: env: TRUAPI_REQUIRE_GENERATED: 1 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -227,7 +228,7 @@ jobs: env: TRUAPI_REQUIRE_GENERATED: 1 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: submodules: recursive persist-credentials: false diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 07aad965..8c1ad37c 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -20,7 +20,7 @@ jobs: pages: write id-token: write steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/deploy-playground.yml b/.github/workflows/deploy-playground.yml index 83c8a1ad..31f341a1 100644 --- a/.github/workflows/deploy-playground.yml +++ b/.github/workflows/deploy-playground.yml @@ -27,7 +27,7 @@ jobs: deploy-playground: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/diagnosis-report.yml b/.github/workflows/diagnosis-report.yml index 386b5027..60e22f2e 100644 --- a/.github/workflows/diagnosis-report.yml +++ b/.github/workflows/diagnosis-report.yml @@ -25,7 +25,7 @@ jobs: if: github.event.label.name == 'diagnosis-report' runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/number-rfc.yml b/.github/workflows/number-rfc.yml index 8ab1b2ee..08ba5752 100644 --- a/.github/workflows/number-rfc.yml +++ b/.github/workflows/number-rfc.yml @@ -14,7 +14,7 @@ jobs: number-rfc: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/release-version-check.yml b/.github/workflows/release-version-check.yml index 659d13aa..c76e3f60 100644 --- a/.github/workflows/release-version-check.yml +++ b/.github/workflows/release-version-check.yml @@ -12,7 +12,7 @@ jobs: if: startsWith(github.event.pull_request.title, 'release:') runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 835f03fc..245fad64 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ on: # zizmor: ignore[dangerous-triggers] workflow_run: workflows: ["CI"] types: [completed] - branches: [main, release/v0.3.2] + branches: [main, release/v0.3.0] permissions: contents: read @@ -30,7 +30,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 ref: ${{ github.event.workflow_run.head_sha }} @@ -80,13 +80,11 @@ jobs: - name: Tag release if: steps.version.outputs.proceed == 'true' + run: | + git tag "${STEPS_VERSION_OUTPUTS_TAG}" + git push origin "${STEPS_VERSION_OUTPUTS_TAG}" env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} STEPS_VERSION_OUTPUTS_TAG: ${{ steps.version.outputs.tag }} - run: | - gh api repos/${{ github.repository }}/git/refs \ - -f ref="refs/tags/${STEPS_VERSION_OUTPUTS_TAG}" \ - -f sha="${{ github.event.workflow_run.head_sha }}" - name: Pack package if: steps.version.outputs.proceed == 'true' diff --git a/CLAUDE.md b/CLAUDE.md index 0116c828..4ad2ef6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,17 +8,38 @@ This repo is the single source of truth for the TrUAPI protocol. It vendors `dot ``` rust/crates/ - truapi/ Rust trait + type definitions for protocol versions v0.1 and v0.2 + truapi/ Rust trait + type definitions for protocol versions v0.1 and v0.2 (canonical) truapi-codegen/ rustdoc JSON → TypeScript client + Rust dispatcher truapi-macros/ #[wire(id = N)] proc-macro + truapi-platform/ Host syscall traits (storage, navigation, consent, ...) + truapi-server/ Rust runtime hosts implement; ships as WASM (browser/node) js/packages/ - truapi/ @parity/truapi TS package; generated TS lives under ignored paths -playground/ Next.js interactive playground; deploys to truapi-playground.dot -hosts/dotli/ dotli submodule -docs/ design docs, RFCs, feature proposals -scripts/codegen.sh regenerate the TS client from the Rust crate + truapi/ @parity/truapi TS package; generated TS lives under ignored paths + truapi-host-wasm/ @parity/truapi-host-wasm: WASM-backed host runtime. Subpath entries: + `.` (shared host types), `/web` (iframe + Web + Worker), `/worker-runtime` (Worker entry). + WASM bundle (gitignored) under dist/wasm/web/, built via `make wasm` +playground/ Next.js interactive playground; deploys to truapi-playground.dot +hosts/dotli/ dotli submodule +docs/ design docs, RFCs, feature proposals +scripts/codegen.sh regenerate the TS client from the Rust crate ``` +### Crate + binding invariants + +- `truapi` is canonical; runtime crates re-export rather than redefine. New + syscall traits and host-side runtime types live in `truapi-platform` and + `truapi-server`, not in `truapi`. Any additions to `truapi` itself are limited + to additive `Display` impls. +- All types exposed by `truapi-platform` and `truapi-server` come from + `truapi::versioned::*` and `truapi::v01::*`. The runtime crates re-export + rather than redefine. +- `truapi-server` WASM artifacts live under + `js/packages/truapi-host-wasm/dist/wasm/web/` and are gitignored. + Build them locally with `make wasm` (rerun whenever + `rust/crates/truapi-server/` changes); CI builds the bundle fresh from the + Rust source on every run. + ## Code style - Every `pub` Rust item (functions, methods, types, traits, modules, constants) carries a doc comment (`///` or `//!`). @@ -26,12 +47,28 @@ scripts/codegen.sh regenerate the TS client from the Rust crate - Do not add code comments or doc comments that narrate migrations, compatibility shims, or historical changes. Comments should describe only the current code. - Remove legacy compatibility code by default. Keep or add it only when explicitly requested. - In Rust format strings, prefer inlined variables: `"log value: {value:?}"` over `"log value: {:?}", value`. +- For Rust modules, prefer `foo.rs` plus an optional `foo/` directory for + child modules. Do not introduce new `foo/mod.rs` files unless preserving + generated output or an existing external convention. +- In runtime Rust code, prefer `core::` over `std::` for types that are + available in `core` (`core::pin::Pin`, `core::task::Poll`, `core::fmt`, and + similar). Keep `std::` for std-only APIs, tests, and std-only programs such + as `truapi-codegen`. - **No `any` in TypeScript types**: If a type can't be expressed cleanly, stop and ask the user whether to (a) refactor or import the right type or (b) add a scoped `// eslint-disable-next-line @typescript-eslint/no-explicit-any` exception. Never silently leave `any`. - Don't introduce typealias chains that just rename a public type from another crate (e.g. `pub type StorageError = crate::v01::HostLocalStorageReadError`). Use the canonical name directly. A typealias is only worth its indirection when it captures a real abstraction. -- After any code change, update `README.md` (and CLAUDE.md if the layout changed) so the top-level docs reflect what the repo actually contains. Stale docs are a regression. +- After any code change, update `README.md` (and CLAUDE.md if the layout changed) so the top-level docs reflect what the repo actually contains. Stale docs are a regression. When moving or removing docs, `rg` for the old path and update or remove stale links in README files, agent notes, skills, comments, and design docs. - In codegen emitters, prefer `indoc::writedoc!` / `formatdoc!` over chains of `writeln!`. A single `writedoc!` with a multi-line raw string keeps the emitted shape visible in source instead of fragmenting it across one-line `writeln!` calls. Reserve `writeln!` for the genuinely-one-line case (a single import, a single statement inside a loop). - In PR descriptions, issue comments, and other artifacts that outlive the conversation: describe the resulting state, not the transition between commits. Avoid "previously X, now Y", "we removed", "the old shim is gone", "this PR replaces", those read as ephemeral history once the PR is squash-merged. Write what the system _does_ after the change, not what each commit _changed_ on the way there. (Commit messages are the place for transition narrative; they survive in `git log` even after the squash.) +## Explanation style + +- For architecture, event-flow, and debugging explanations, start with a short + direct summary of the model before diving into long details. Prefer simple + statements like "the host sends a dirty signal; the core re-reads and derives + auth state" before listing each hop. +- Use diagrams only when they clarify ownership or message flow. Keep them + layered and label what is per-tab, shared, host-owned, and core-owned. + ## First-time setup ```bash @@ -116,6 +153,90 @@ submodule init + `bun install` and the per-pane `cd` discipline). Alternatively, with a deployed Polkadot Desktop Host installed, navigate to `https://dot.li/localhost:3000` from within it. +#### Local dotli + playground E2E notes + +Use `make dev DEBUG=1` from the repo root for the local host stack. It prepares +the ignored WASM/build artifacts, verifies dotli can resolve +`@parity/truapi-host-wasm`, then starts dotli on `:5173` and the playground on +`:3000`. Open `http://localhost:5173/localhost:3000`. + +When automating with Playwright, block service workers for smoke tests unless +the test is explicitly about SW behavior. Stale host/product bundles can mask +runtime fixes. Use a fresh cache-busting query string on +`http://localhost:5173/localhost:3000?...`, collect `pageerror` and +`console` messages, and fail on unexpected page errors. + +For interactive SSO checks, prefer a persistent headed Chrome profile and reuse +the same browser context across checks. SSO pairing needs a real phone QR scan, +and signing/resource-allocation flows may need web or mobile confirmation; if +the human or companion app is unavailable, skip those methods and record the +skip instead of treating it as a protocol failure. Non-interactive checks should +still verify that the playground renders, the TrUAPI debug panel receives +host/product events, generated examples can call non-confirmation methods, and +logout/relogin does not restore a stale session. + +The dotli Playwright e2e suite under `hosts/dotli/apps/host/tests/e2e/` +pairs through the signer-bot service. It requires `SIGNER_BOT_SVC_TOKEN`; +`SIGNER_BOT_BASE_URL` and `SIGNER_BOT_NETWORK` default to dotli CI's +`https://signing-bot-dev.novasama-tech.org/` and `paseo-next-v2`. Without the +token, do not treat the full suite as locally runnable. Use +`E2E_DOTLI_SMOKE=1 make e2e-dotli` for the no-phone QR smoke path. +If those signer-bot variables are not available in a worktree, check for a +repo-root `.env` and load or copy the values from there before falling back to +smoke mode. Prefer the current worktree's `.env` when it exists. + +For a fully automated local playground diagnosis run, use: + +```bash +SIGNER_BOT_SVC_TOKEN=... \ +make e2e-dotli +``` + +`make e2e-dotli` starts dotli preview and the playground, signs out any +restored host session, signs in through signer-bot by extracting the QR payload, +runs the playground Diagnosis screen, auto-accepts host-side Allow/Sign modals, +and writes `hosts/dotli/test-results/e2e-dotli/diagnosis-report.md`. + +Root CI runs the same target when it can read the private dotli submodule. It +needs `DOTLI_CHECKOUT_TOKEN` for submodule checkout; without that token, the +job warns and skips dotli e2e rather than failing unrelated PR checks. With +dotli access but without `SIGNER_BOT_SVC_TOKEN`, CI runs the no-phone smoke +path only. + +A useful no-phone smoke assertion is: + +```bash +E2E_DOTLI_SMOKE=1 make e2e-dotli +``` + +For manual debugging of that smoke path: + +1. Start `make dev DEBUG=1`. +2. Open `http://localhost:5173/localhost:3000?debug=truapi&cachebust=` with + service workers blocked. +3. Wait for `globalThis.__truapi?.setLogLevel`, call + `__truapi.setLogLevel("debug")`, and confirm the console logs + `[truapi worker] logLevel=debug providers=0`. +4. Click `#auth-button`, wait for `#auth-modal-backdrop.open`, and confirm: + the modal shows `Login with Polkadot Mobile`, `__truapi.getProviderCount()` + is greater than zero, worker frame/callback logs appear, and there are no + page errors. + +If `make dev` reports `EADDRINUSE` on `:5173` or the playground moves from +`:3000` to `:3001`, kill stale `preview-server.ts` / `next dev` processes and +restart the tmux session. Port drift causes false-negative local e2e results. + +Useful debug signals: + +```js +__truapi.setLogLevel("debug"); +sessionStorage.setItem("dotli:truapi-debug", "1"); +``` + +Reload after setting the debug-panel flag. Watch for `Unknown wire discriminant`, missing +`@parity/truapi-host-wasm` imports, worker WASM instantiation failures, and +debug-panel traffic disappearing when the login popup opens. + ## Deployment Pushes to `main` trigger `.github/workflows/deploy-playground.yml`, which builds `playground/` and publishes the static export to `truapi-playground.dot` via `bulletin-deploy`. diff --git a/Makefile b/Makefile index c8bc371c..285aa467 100644 --- a/Makefile +++ b/Makefile @@ -3,39 +3,65 @@ # Run `make help` for the list of targets. .DEFAULT_GOAL := help -.PHONY: help setup build codegen test check playground dev matrix explorer +.PHONY: help setup build codegen test check playground wasm wasm-crypto-test dev dev-bootstrap dev-link-check e2e-dotli matrix explorer TRUAPI_PKG := js/packages/truapi PLAYGROUND := playground +JS_PACKAGES := js/packages EXPLORER := explorer DOTLI := hosts/dotli +HOST_WASM_PKG := $(JS_PACKAGES)/truapi-host-wasm +HOST_CALLBACKS_GENERATED := $(HOST_WASM_PKG)/src/generated/host-callbacks.ts +HOST_WASM_ADAPTER_GENERATED := $(HOST_WASM_PKG)/src/generated/host-callbacks-adapter.ts +HOST_WASM_WORKER_CALLBACKS_GENERATED := $(HOST_WASM_PKG)/src/generated/worker-callbacks.ts +HOST_WASM_WEB := $(HOST_WASM_PKG)/dist/wasm/web/truapi_server.js +DOTLI_UI := $(DOTLI)/packages/ui +DOTLI_HOST_WASM_LINK := $(DOTLI_UI)/node_modules/@parity/truapi-host-wasm +SIGNER_BOT_BASE_URL ?= https://signing-bot-dev.novasama-tech.org/ +SIGNER_BOT_NETWORK ?= paseo-next-v2 +VITE_NETWORKS ?= paseo-next-v2,previewnet +export SIGNER_BOT_BASE_URL +export SIGNER_BOT_NETWORK +export VITE_NETWORKS -# `make dev DEBUG=1` runs dotli with VITE_APP_DEBUG=true to log every wire frame. -DOTLI_PREVIEW := preview -ifdef DEBUG -DOTLI_PREVIEW := preview:debug -endif +# Local product URLs (`http://localhost:5173/localhost:3000`) are intentionally +# gated behind dotli's debug build flag, so the dev target must run the debug +# preview by default. Override with `DOTLI_PREVIEW=preview` to test production +# preview behavior. +DOTLI_PREVIEW ?= preview:debug help: ## Show this help. @awk 'BEGIN { FS = ":.*##"; printf "Usage: make \n\nTargets:\n" } \ - /^[a-zA-Z_-]+:.*?##/ { printf " %-12s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + /^[a-zA-Z0-9_-]+:.*?##/ { printf " %-12s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) -setup: ## First-time setup: submodules + JS dependencies. +setup: ## First-time setup: submodules, JS dependencies, generated artifacts. git submodule update --init --recursive - cd $(TRUAPI_PKG) && npm install + # --ignore-scripts: the workspace `prepare` builds need generated sources + # that only exist after codegen.sh, which also builds the packages. + npm ci --ignore-scripts + ./scripts/codegen.sh cd $(PLAYGROUND) && yarn install --frozen-lockfile + cd $(DOTLI) && bun install --frozen-lockfile build: ## Build the Rust workspace and the TypeScript client. cargo build --workspace cd $(TRUAPI_PKG) && npm run build + cd $(HOST_WASM_PKG) && npm run build -codegen: ## Regenerate the TypeScript client from the Rust crate. +codegen: ## Regenerate generated TS/Rust artifacts from the Rust crates. ./scripts/codegen.sh cd $(PLAYGROUND) && rm -rf node_modules/@parity && yarn install +wasm: ## Rebuild the truapi-server WASM artifacts under js/packages/truapi-host-wasm/dist/wasm/. + cd $(HOST_WASM_PKG) && npm run build:wasm + +wasm-crypto-test: ## Run crypto/vector tests on wasm32 via wasm-pack/node. + wasm-pack test --node rust/crates/truapi-server --test wasm_crypto_vectors --no-default-features + test: ## Run Rust + TypeScript client tests. cargo test --workspace cd $(TRUAPI_PKG) && npm test + cd $(JS_PACKAGES)/truapi-host-wasm && npm test check: ## Full verification suite (build, fmt, clippy, test, TS tests, playground build + lint). cargo build --workspace @@ -43,6 +69,7 @@ check: ## Full verification suite (build, fmt, clippy, test, TS tests, playgroun cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace cd $(TRUAPI_PKG) && npm run build && npm test + cd $(JS_PACKAGES)/truapi-host-wasm && npm install --no-fund --no-audit && npm test cd $(PLAYGROUND) && yarn build && yarn lint playground: ## Refresh the playground's @parity/truapi snapshot and rebuild. @@ -50,12 +77,48 @@ playground: ## Refresh the playground's @parity/truapi snapshot and rebuild. cd $(PLAYGROUND) && rm -rf node_modules/@parity && yarn install cd $(PLAYGROUND) && yarn build -dev: ## Start dotli host (:5173) + playground (:3000) together; open http://localhost:5173/localhost:3000. DEBUG=1 logs wire frames. +dev-bootstrap: ## Prepare ignored generated/build artifacts needed by dotli preview. + git submodule update --init --recursive + # --ignore-scripts: the workspace `prepare` builds need generated sources + # that only exist after codegen.sh, which also builds the packages. + if [ ! -d node_modules ]; then npm ci --ignore-scripts; fi + if [ ! -f "$(HOST_CALLBACKS_GENERATED)" ] || [ ! -f "$(HOST_WASM_ADAPTER_GENERATED)" ] || [ ! -f "$(HOST_WASM_WORKER_CALLBACKS_GENERATED)" ]; then ./scripts/codegen.sh; fi + cd $(HOST_WASM_PKG) && npm run build + TRUAPI_WASM_PROFILE=dev $(MAKE) wasm + cd $(PLAYGROUND) && yarn install --frozen-lockfile + cd $(DOTLI) && bun install --frozen-lockfile + $(MAKE) dev-link-check + +dev-link-check: ## Verify dotli can resolve the local @parity/truapi-host-wasm package. + @test -f "$(HOST_CALLBACKS_GENERATED)" || (echo "Missing generated host callbacks. Run: make codegen"; exit 1) + @test -f "$(HOST_WASM_ADAPTER_GENERATED)" || (echo "Missing generated host callbacks WASM adapter. Run: make codegen"; exit 1) + @test -f "$(HOST_WASM_WORKER_CALLBACKS_GENERATED)" || (echo "Missing generated host callbacks worker bridge. Run: make codegen"; exit 1) + @test -f "$(HOST_WASM_PKG)/dist/index.js" || (echo "Missing @parity/truapi-host-wasm dist. Run: npm run build --prefix $(HOST_WASM_PKG)"; exit 1) + @test -f "$(HOST_WASM_WEB)" || (echo "Missing @parity/truapi-host-wasm web WASM glue. Run: make wasm"; exit 1) + @test -e "$(DOTLI_HOST_WASM_LINK)/package.json" || (echo "dotli cannot resolve @parity/truapi-host-wasm. Run top-level: make dev"; exit 1) + cd $(DOTLI_UI) && bun -e 'await import("@parity/truapi-host-wasm"); await import("@parity/truapi-host-wasm/web");' + +dev: dev-bootstrap ## Start dotli host (:5173) + playground (:3000) together; open http://localhost:5173/localhost:3000. DEBUG=1 logs wire frames. @trap 'kill 0' EXIT; \ ( cd $(DOTLI) && bun run $(DOTLI_PREVIEW) ) & \ ( cd $(PLAYGROUND) && yarn dev ) & \ + ( until curl -fsS http://localhost:3000/ >/dev/null 2>&1; do sleep 1; done; curl -fsS http://localhost:3000/diagnostics >/dev/null 2>&1 || true ) & \ wait +e2e-dotli: ## Fully automated dotli + playground diagnosis e2e. Requires SIGNER_BOT_SVC_TOKEN unless E2E_DOTLI_SMOKE=1. + @SIGNER_BOT_SVC_TOKEN_ENV="$$SIGNER_BOT_SVC_TOKEN"; \ + SIGNER_BOT_BASE_URL_ENV="$$SIGNER_BOT_BASE_URL"; \ + SIGNER_BOT_NETWORK_ENV="$$SIGNER_BOT_NETWORK"; \ + set -a; \ + if [ -f .env ]; then . ./.env; fi; \ + set +a; \ + if [ -n "$$SIGNER_BOT_SVC_TOKEN_ENV" ]; then SIGNER_BOT_SVC_TOKEN="$$SIGNER_BOT_SVC_TOKEN_ENV"; export SIGNER_BOT_SVC_TOKEN; fi; \ + if [ -n "$$SIGNER_BOT_BASE_URL_ENV" ]; then SIGNER_BOT_BASE_URL="$$SIGNER_BOT_BASE_URL_ENV"; export SIGNER_BOT_BASE_URL; fi; \ + if [ -n "$$SIGNER_BOT_NETWORK_ENV" ]; then SIGNER_BOT_NETWORK="$$SIGNER_BOT_NETWORK_ENV"; export SIGNER_BOT_NETWORK; fi; \ + if [ "$$E2E_DOTLI_SMOKE" != "1" ]; then test -n "$$SIGNER_BOT_SVC_TOKEN" || (echo "Missing SIGNER_BOT_SVC_TOKEN. e2e-dotli requires signer-bot; without it a human phone scan is required."; exit 1); fi; \ + $(MAKE) dev-bootstrap; \ + cd $(DOTLI)/apps/host && bun tests/e2e/playground-diagnosis.ts + matrix: ## Regenerate the host compatibility matrix from explorer/diagnosis-reports. cd $(EXPLORER) && npm run generate-matrix diff --git a/README.md b/README.md index a08d9483..f8abf3c9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > The following is a prototype, reference implementation, and proof-of-concept. This open source code is provided for research, experimentation, and developer education only. This code has not been audited, is actively experimental, and may contain bugs, vulnerabilities, or incomplete features. Use at your own risk. -*The protocol that lets product webviews talk to their Polkadot host.* +_The protocol that lets product webviews talk to their Polkadot host._ [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](./LICENSE) [![CI](https://img.shields.io/github/actions/workflow/status/paritytech/truapi/ci.yml?branch=main&style=flat-square&label=ci)](https://github.com/paritytech/truapi/actions/workflows/ci.yml) @@ -57,14 +57,30 @@ rust/crates/ truapi/ Rust trait and type definitions (v01, v02) truapi-codegen/ rustdoc JSON to TypeScript client + Rust dispatcher truapi-macros/ #[wire(id = N)] proc-macro + truapi-platform/ Host syscall traits used by truapi-server (storage, navigation, consent, ...) + truapi-server/ Rust runtime that hosts implement: dispatcher, frames, SCALE, WASM surface js/packages/ - truapi/ @parity/truapi TypeScript client -playground/ Interactive Next.js playground (truapi-playground.dot) -hosts/dotli/ dotli host, vendored as a submodule -docs/ Design docs, RFCs, feature proposals -scripts/codegen.sh Regenerate the TS client from the Rust source + truapi/ @parity/truapi TypeScript client + truapi-host-wasm/ @parity/truapi-host-wasm: WASM-backed host runtime; entries `.` + (shared host types), `/web` (iframe + Web Worker), + `/worker-runtime` +playground/ Interactive Next.js playground (truapi-playground.dot) +hosts/dotli/ dotli host, vendored as a submodule +docs/ Design docs, RFCs, feature proposals +scripts/codegen.sh Regenerate the TS client from the Rust source ``` +### JS Host SDKs + +JS hosts integrate the Rust core through [`@parity/truapi-host-wasm`](js/packages/truapi-host-wasm), +a single package with tree-shakeable subpath entries: + +- `@parity/truapi-host-wasm` (the `.` entry) exposes shared host runtime types and generated callback contracts. +- `@parity/truapi-host-wasm/web` wires the WASM provider into a browser host: the iframe + MessageChannel handshake (`createIframeHost`) plus `createWebWorkerProvider`. +- `@parity/truapi-host-wasm/worker-runtime` is the Web Worker entrypoint so the WASM core can + run off the page main thread. + ## How it works 1. The protocol is defined as Rust traits in [`rust/crates/truapi/`](rust/crates/truapi/), with each method tagged `#[wire(id = N)]` for a stable byte-level dispatch table. Every method's doc comment must carry a ` ```ts ` example, which codegen extracts into the playground's EXAMPLE tab; the build fails if any method is missing one. @@ -80,9 +96,10 @@ Common tasks are wrapped in the top-level `Makefile`. Run `make help` for the fu ```bash make setup # submodules + JS dependencies -make build # Rust workspace + TypeScript client -make test # Rust + TypeScript client tests +make build # Rust workspace + TypeScript client + @parity/truapi-host-wasm +make test # Rust + TypeScript client + @parity/truapi-host-wasm tests make check # full suite: build, fmt, clippy, test, TS tests, playground build + lint +make wasm # rebuild truapi-server WASM artifacts under js/packages/truapi-host-wasm/dist/wasm/ ``` To run the playground locally: @@ -129,4 +146,3 @@ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for issue reports, feature proposals, a ## License [MIT](./LICENSE) - diff --git a/deny.toml b/deny.toml index 707c6f77..9305a03f 100644 --- a/deny.toml +++ b/deny.toml @@ -5,8 +5,26 @@ allow = [ "MIT", "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", "Unicode-3.0", "Unlicense", "Zlib", ] confidence-threshold = 0.8 + +# uniffi is MPL-2.0: file-level weak copyleft, consumed unmodified as a +# dependency, which MPL-2.0 permits without affecting the MIT outbound licence. +# Scoped per crate so MPL-2.0 stays disallowed everywhere else. +exceptions = [ + { name = "uniffi", allow = ["MPL-2.0"] }, + { name = "uniffi_bindgen", allow = ["MPL-2.0"] }, + { name = "uniffi_core", allow = ["MPL-2.0"] }, + { name = "uniffi_internal_macros", allow = ["MPL-2.0"] }, + { name = "uniffi_macros", allow = ["MPL-2.0"] }, + { name = "uniffi_meta", allow = ["MPL-2.0"] }, + { name = "uniffi_pipeline", allow = ["MPL-2.0"] }, + { name = "uniffi_udl", allow = ["MPL-2.0"] }, +] diff --git a/docs/local-e2e-testing.md b/docs/local-e2e-testing.md index f646e2db..78e0602a 100644 --- a/docs/local-e2e-testing.md +++ b/docs/local-e2e-testing.md @@ -25,6 +25,21 @@ The chain below is also automated: The doc below is still the canonical narrative and the source of truth for failure modes — both the skills and CI cite it. +`make e2e-dotli` is the end-to-end dotli + playground diagnosis harness. It +starts the local dotli preview and playground, opens Chromium, signs out any +restored host session, signs in through the signer-bot SSO service, runs the +playground Diagnosis screen, and writes +`hosts/dotli/test-results/e2e-dotli/diagnosis-report.md`. Full automation +requires `SIGNER_BOT_SVC_TOKEN`; `SIGNER_BOT_BASE_URL` and +`SIGNER_BOT_NETWORK` default to dotli CI's signer-bot service and +`paseo-next-v2`. Without the token, use +`E2E_DOTLI_SMOKE=1 make e2e-dotli` to verify the local stack, browser launch, +login click, TrUAPI debug logs, and QR/deeplink extraction without a phone. +In root CI, the job also needs `DOTLI_CHECKOUT_TOKEN` to read the private +dotli submodule. Without dotli access it reports a warning and skips the e2e +job; with dotli access but without `SIGNER_BOT_SVC_TOKEN`, it runs the smoke +path only. + The order matters: each layer assumes the layer below it builds clean. Skip a step only if you are certain the change cannot affect that layer. @@ -164,12 +179,19 @@ method from the UI. ```bash cd hosts/dotli bun run preview # → http://localhost:5173 -# or, for the TrUAPI debug panel: -bun run preview:debugger # = VITE_APP_DEBUG=true bun run preview ``` -`preview:debugger` is recommended whenever you're investigating a wire -issue — the debug panel logs every host↔product TrUAPI frame. +When investigating a wire issue, raise the Rust core's log level from the +host origin. The WASM worker bridge forwards core `tracing` output to the +browser console, mapping each level to the matching `console` method: + +```js +window.__truapi.setLogLevel("trace"); +``` + +`debug` and `trace` are emitted via `console.debug`, which Chrome hides +unless the console **Default levels ▾** dropdown includes **Verbose**; +`info`/`warn`/`error` always render. ### Start the playground dev server @@ -225,9 +247,9 @@ failing. Check: stale; redo step 4). If a method call hangs, the host either didn't receive the frame -(check dotli's debug panel or console) or didn't respond. The bridge -auto-responds to `host_handshake_request` only; everything else is on -the host implementation. +(check dotli's console with `truapi:logLevel` set to `debug`) or didn't respond. +The bridge auto-responds to `host_handshake_request` only; everything +else is on the host implementation. ## 7. Codegen tests diff --git a/hosts/dotli b/hosts/dotli index 80bedceb..9235eb93 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit 80bedceb98bd6f305f5bf134c0225c203752ecac +Subproject commit 9235eb9361462dbabd36d8c91c7fff84e4b59bbd diff --git a/playground/playwright.config.ts b/playground/playwright.config.ts index b5f80bfc..9009ec7c 100644 --- a/playground/playwright.config.ts +++ b/playground/playwright.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ reporter: isCI ? [["github"], ["html", { open: "never" }]] : "list", use: { baseURL: "http://localhost:5173", + serviceWorkers: "block", trace: "retain-on-failure", screenshot: "only-on-failure", video: "retain-on-failure", @@ -23,11 +24,13 @@ export default defineConfig({ ], webServer: [ { - // dotli host iframe shell at :5173. `bun run preview` runs - // `turbo run build && bun scripts/preview-server.ts`, so a cold - // CI runner needs the long timeout. - command: "bun run preview", + // dotli host iframe shell at :5173. Localhost product proxy routes are + // debug-build-only, so mirror `make dev` and run the debug preview. + command: "bun run preview:debug", cwd: "../hosts/dotli", + env: { + VITE_NETWORKS: process.env.VITE_NETWORKS ?? "paseo-next-v2,previewnet", + }, url: "http://localhost:5173", reuseExistingServer: !isCI, timeout: 10 * 60 * 1000, diff --git a/playground/tests/e2e/helpers.ts b/playground/tests/e2e/helpers.ts index a5cc3801..9a6dee31 100644 --- a/playground/tests/e2e/helpers.ts +++ b/playground/tests/e2e/helpers.ts @@ -8,6 +8,20 @@ import { expect, type FrameLocator, type Page } from "@playwright/test"; * iframe so individual specs only need to know about playground selectors. */ export async function openPlaygroundInDotli(page: Page): Promise { + await page.addInitScript(() => { + localStorage.setItem("dotli:mode", "gateway"); + localStorage.setItem("dotli:chain-backend", "rpc"); + localStorage.setItem("dotli:content-backend", "ipfs-gateway"); + localStorage.setItem( + "dotli:permissions:localhost:3000", + JSON.stringify({ Camera: "granted" }), + ); + localStorage.setItem("desktop-banner-dismissed", "1"); + localStorage.setItem("truapi:playground:e2e", "1"); + ( + window as typeof window & { __TRUAPI_PLAYGROUND_E2E__?: boolean } + ).__TRUAPI_PLAYGROUND_E2E__ = true; + }); await page.goto("/localhost:3000"); // dotli renders an additional hidden iframe (host.localhost:5173?mode=direct) // alongside the proxied playground; scope to the playground src so the From f898de6de789cb2025ab5ebf0c7f969b220a73d5 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 1 Jul 2026 15:20:56 +0200 Subject: [PATCH 7/8] fixup! feat: port Rust core runtime --- .../codegen/versions/0.3.2/services.ts | 724 ++++ .../explorer/codegen/versions/0.3.2/types.ts | 3836 +++++++++++++++++ 2 files changed, 4560 insertions(+) create mode 100644 js/packages/truapi/src/explorer/codegen/versions/0.3.2/services.ts create mode 100644 js/packages/truapi/src/explorer/codegen/versions/0.3.2/types.ts diff --git a/js/packages/truapi/src/explorer/codegen/versions/0.3.2/services.ts b/js/packages/truapi/src/explorer/codegen/versions/0.3.2/services.ts new file mode 100644 index 00000000..8916c096 --- /dev/null +++ b/js/packages/truapi/src/explorer/codegen/versions/0.3.2/services.ts @@ -0,0 +1,724 @@ +// Auto-generated by truapi-codegen. Do not edit. +import type { ServiceInfo } from "../../../../playground/services-types.js"; + +export const services: ServiceInfo[] = [ + { + name: "Account", + methods: [ + { + name: "connection_status_subscribe", + type: "subscription", + signature: + "connectionStatusSubscribe(): ObservableLike", + docUrl: + "api/account/trait.Account.html#method.connection_status_subscribe", + description: "Subscribe to account connection status changes.", + responseType: "host-account-connection-status-subscribe-item", + }, + { + name: "get_account", + type: "unary", + signature: + "getAccount(request: HostAccountGetRequest): Promise>", + docUrl: "api/account/trait.Account.html#method.get_account", + description: "Retrieve a product-scoped account.", + requestDescription: "HostAccountGetRequest", + requestType: "host-account-get-request", + responseType: "host-account-get-response", + errorType: "host-account-get-error", + }, + { + name: "get_account_alias", + type: "unary", + signature: + "getAccountAlias(request: HostAccountGetAliasRequest): Promise>", + docUrl: "api/account/trait.Account.html#method.get_account_alias", + description: "Retrieve a contextual alias for a product account.", + requestDescription: "HostAccountGetAliasRequest", + requestType: "host-account-get-alias-request", + responseType: "host-account-get-alias-response", + errorType: "host-account-get-error", + }, + { + name: "create_account_proof", + type: "unary", + signature: + "createAccountProof(request: HostAccountCreateProofRequest): Promise>", + docUrl: "api/account/trait.Account.html#method.create_account_proof", + description: "Generate a ring VRF proof for a product account.", + requestDescription: "HostAccountCreateProofRequest", + requestType: "host-account-create-proof-request", + responseType: "host-account-create-proof-response", + errorType: "host-account-create-proof-error", + }, + { + name: "get_legacy_accounts", + type: "unary", + signature: + "getLegacyAccounts(): Promise>", + docUrl: "api/account/trait.Account.html#method.get_legacy_accounts", + description: "List non-product accounts the user owns.", + responseType: "host-get-legacy-accounts-response", + errorType: "host-account-get-error", + }, + { + name: "get_user_id", + type: "unary", + signature: + "getUserId(): Promise>", + docUrl: "api/account/trait.Account.html#method.get_user_id", + description: "Fetch the user's primary identity.", + responseType: "host-get-user-id-response", + errorType: "host-get-user-id-error", + }, + { + name: "request_login", + type: "unary", + signature: + "requestLogin(request: HostRequestLoginRequest): Promise>", + docUrl: "api/account/trait.Account.html#method.request_login", + description: + 'Request the host to present the login flow to the user.\n\nProducts should call this in response to a user action (e.g. tapping a\n"Sign in" button), not automatically on load.', + requestDescription: "HostRequestLoginRequest", + requestType: "host-request-login-request", + responseType: "host-request-login-response", + errorType: "host-request-login-error", + }, + ], + }, + { + name: "Chain", + methods: [ + { + name: "follow_head_subscribe", + type: "subscription", + signature: + "followHeadSubscribe(request: RemoteChainHeadFollowRequest): ObservableLike", + docUrl: "api/chain/trait.Chain.html#method.follow_head_subscribe", + description: "Follow the chain head and receive block events.", + requestDescription: "RemoteChainHeadFollowRequest", + requestType: "remote-chain-head-follow-request", + responseType: "remote-chain-head-follow-item", + }, + { + name: "get_head_header", + type: "unary", + signature: + "getHeadHeader(request: RemoteChainHeadHeaderRequest): Promise>", + docUrl: "api/chain/trait.Chain.html#method.get_head_header", + description: "Fetch a block header.", + requestDescription: "RemoteChainHeadHeaderRequest", + requestType: "remote-chain-head-header-request", + responseType: "remote-chain-head-header-response", + errorType: "generic-error", + }, + { + name: "get_head_body", + type: "unary", + signature: + "getHeadBody(request: RemoteChainHeadBodyRequest): Promise>", + docUrl: "api/chain/trait.Chain.html#method.get_head_body", + description: "Fetch a block body.", + requestDescription: "RemoteChainHeadBodyRequest", + requestType: "remote-chain-head-body-request", + responseType: "remote-chain-head-body-response", + errorType: "generic-error", + }, + { + name: "get_head_storage", + type: "unary", + signature: + "getHeadStorage(request: RemoteChainHeadStorageRequest): Promise>", + docUrl: "api/chain/trait.Chain.html#method.get_head_storage", + description: "Query runtime storage at a specific block.", + requestDescription: "RemoteChainHeadStorageRequest", + requestType: "remote-chain-head-storage-request", + responseType: "remote-chain-head-storage-response", + errorType: "generic-error", + }, + { + name: "call_head", + type: "unary", + signature: + "callHead(request: RemoteChainHeadCallRequest): Promise>", + docUrl: "api/chain/trait.Chain.html#method.call_head", + description: "Invoke a runtime call at a specific block.", + requestDescription: "RemoteChainHeadCallRequest", + requestType: "remote-chain-head-call-request", + responseType: "remote-chain-head-call-response", + errorType: "generic-error", + }, + { + name: "unpin_head", + type: "unary", + signature: + "unpinHead(request: RemoteChainHeadUnpinRequest): Promise>", + docUrl: "api/chain/trait.Chain.html#method.unpin_head", + description: "Release pinned blocks.", + requestDescription: "RemoteChainHeadUnpinRequest", + requestType: "remote-chain-head-unpin-request", + errorType: "generic-error", + }, + { + name: "continue_head", + type: "unary", + signature: + "continueHead(request: RemoteChainHeadContinueRequest): Promise>", + docUrl: "api/chain/trait.Chain.html#method.continue_head", + description: "Continue a paused chain-head operation.", + requestDescription: "RemoteChainHeadContinueRequest", + requestType: "remote-chain-head-continue-request", + errorType: "generic-error", + }, + { + name: "stop_head_operation", + type: "unary", + signature: + "stopHeadOperation(request: RemoteChainHeadStopOperationRequest): Promise>", + docUrl: "api/chain/trait.Chain.html#method.stop_head_operation", + description: "Stop a chain-head operation.", + requestDescription: "RemoteChainHeadStopOperationRequest", + requestType: "remote-chain-head-stop-operation-request", + errorType: "generic-error", + }, + { + name: "get_spec_genesis_hash", + type: "unary", + signature: + "getSpecGenesisHash(request: RemoteChainSpecGenesisHashRequest): Promise>", + docUrl: "api/chain/trait.Chain.html#method.get_spec_genesis_hash", + description: "Fetch the canonical genesis hash for a chain.", + requestDescription: "RemoteChainSpecGenesisHashRequest", + requestType: "remote-chain-spec-genesis-hash-request", + responseType: "remote-chain-spec-genesis-hash-response", + errorType: "generic-error", + }, + { + name: "get_spec_chain_name", + type: "unary", + signature: + "getSpecChainName(request: RemoteChainSpecChainNameRequest): Promise>", + docUrl: "api/chain/trait.Chain.html#method.get_spec_chain_name", + description: "Fetch the display name of a chain.", + requestDescription: "RemoteChainSpecChainNameRequest", + requestType: "remote-chain-spec-chain-name-request", + responseType: "remote-chain-spec-chain-name-response", + errorType: "generic-error", + }, + { + name: "get_spec_properties", + type: "unary", + signature: + "getSpecProperties(request: RemoteChainSpecPropertiesRequest): Promise>", + docUrl: "api/chain/trait.Chain.html#method.get_spec_properties", + description: "Fetch the JSON-encoded properties of a chain.", + requestDescription: "RemoteChainSpecPropertiesRequest", + requestType: "remote-chain-spec-properties-request", + responseType: "remote-chain-spec-properties-response", + errorType: "generic-error", + }, + { + name: "broadcast_transaction", + type: "unary", + signature: + "broadcastTransaction(request: RemoteChainTransactionBroadcastRequest): Promise>", + docUrl: "api/chain/trait.Chain.html#method.broadcast_transaction", + description: "Broadcast a signed transaction.", + requestDescription: "RemoteChainTransactionBroadcastRequest", + requestType: "remote-chain-transaction-broadcast-request", + responseType: "remote-chain-transaction-broadcast-response", + errorType: "generic-error", + }, + { + name: "stop_transaction", + type: "unary", + signature: + "stopTransaction(request: RemoteChainTransactionStopRequest): Promise>", + docUrl: "api/chain/trait.Chain.html#method.stop_transaction", + description: "Stop a transaction broadcast.", + requestDescription: "RemoteChainTransactionStopRequest", + requestType: "remote-chain-transaction-stop-request", + errorType: "generic-error", + }, + ], + }, + { + name: "Chat", + methods: [ + { + name: "create_room", + type: "unary", + signature: + "createRoom(request: HostChatCreateRoomRequest): Promise>", + docUrl: "api/chat/trait.Chat.html#method.create_room", + description: "Create a chat room.", + requestDescription: "HostChatCreateRoomRequest", + requestType: "host-chat-create-room-request", + responseType: "host-chat-create-room-response", + errorType: "host-chat-create-room-error", + }, + { + name: "register_bot", + type: "unary", + signature: + "registerBot(request: HostChatRegisterBotRequest): Promise>", + docUrl: "api/chat/trait.Chat.html#method.register_bot", + description: "Register a chat bot.", + requestDescription: "HostChatRegisterBotRequest", + requestType: "host-chat-register-bot-request", + responseType: "host-chat-register-bot-response", + errorType: "host-chat-register-bot-error", + }, + { + name: "list_subscribe", + type: "subscription", + signature: "listSubscribe(): ObservableLike", + docUrl: "api/chat/trait.Chat.html#method.list_subscribe", + description: "Subscribe to the list of chat rooms.", + responseType: "host-chat-list-subscribe-item", + }, + { + name: "post_message", + type: "unary", + signature: + "postMessage(request: HostChatPostMessageRequest): Promise>", + docUrl: "api/chat/trait.Chat.html#method.post_message", + description: "Post a message to a chat room.", + requestDescription: "HostChatPostMessageRequest", + requestType: "host-chat-post-message-request", + responseType: "host-chat-post-message-response", + errorType: "host-chat-post-message-error", + }, + { + name: "action_subscribe", + type: "subscription", + signature: + "actionSubscribe(): ObservableLike", + docUrl: "api/chat/trait.Chat.html#method.action_subscribe", + description: "Subscribe to received chat actions.", + responseType: "host-chat-action-subscribe-item", + }, + { + name: "custom_message_render_subscribe", + type: "subscription", + signature: + "customMessageRenderSubscribe(request: ProductChatCustomMessageRenderSubscribeRequest): ObservableLike", + docUrl: + "api/chat/trait.Chat.html#method.custom_message_render_subscribe", + description: + "Subscribe to custom message render requests from the host. Each\nemitted item is a [`CustomRendererNode`](crate::v01::CustomRendererNode)\ntree describing the rendered UI.", + requestDescription: "ProductChatCustomMessageRenderSubscribeRequest", + requestType: "product-chat-custom-message-render-subscribe-request", + responseType: "custom-renderer-node", + }, + ], + }, + { + name: "Entropy", + methods: [ + { + name: "derive", + type: "unary", + signature: + "derive(request: HostDeriveEntropyRequest): Promise>", + docUrl: "api/entropy/trait.Entropy.html#method.derive", + description: "Derive deterministic entropy.", + requestDescription: "HostDeriveEntropyRequest", + requestType: "host-derive-entropy-request", + responseType: "host-derive-entropy-response", + errorType: "host-derive-entropy-error", + }, + ], + }, + { + name: "Local Storage", + methods: [ + { + name: "read", + type: "unary", + signature: + "read(request: HostLocalStorageReadRequest): Promise>", + docUrl: "api/local_storage/trait.LocalStorage.html#method.read", + description: "Read a value by key.", + requestDescription: "HostLocalStorageReadRequest", + requestType: "host-local-storage-read-request", + responseType: "host-local-storage-read-response", + errorType: "host-local-storage-read-error", + }, + { + name: "write", + type: "unary", + signature: + "write(request: HostLocalStorageWriteRequest): Promise>", + docUrl: "api/local_storage/trait.LocalStorage.html#method.write", + description: "Write a value to a key.", + requestDescription: "HostLocalStorageWriteRequest", + requestType: "host-local-storage-write-request", + errorType: "host-local-storage-read-error", + }, + { + name: "clear", + type: "unary", + signature: + "clear(request: HostLocalStorageClearRequest): Promise>", + docUrl: "api/local_storage/trait.LocalStorage.html#method.clear", + description: "Clear a value by key.", + requestDescription: "HostLocalStorageClearRequest", + requestType: "host-local-storage-clear-request", + errorType: "host-local-storage-read-error", + }, + ], + }, + { + name: "Notifications", + methods: [ + { + name: "send_push_notification", + type: "unary", + signature: + "sendPushNotification(request: HostPushNotificationRequest): Promise>", + docUrl: + "api/notifications/trait.Notifications.html#method.send_push_notification", + description: + "Send a push notification to the user.\n\nReturns a [`NotificationId`](crate::v01::NotificationId) that can be\npassed to [`cancel_push_notification`](Self::cancel_push_notification)\nto retract a scheduled notification. When `scheduled_at` is set the host\npersists the notification across restarts and fires it through the\nplatform-native scheduler. See [RFC 0019].\n\n[RFC 0019]: https://github.com/paritytech/truapi/blob/main/docs/rfcs/0019-scheduled-notifications.md", + requestDescription: "HostPushNotificationRequest", + requestType: "host-push-notification-request", + responseType: "host-push-notification-response", + errorType: "host-push-notification-error", + }, + { + name: "cancel_push_notification", + type: "unary", + signature: + "cancelPushNotification(request: HostPushNotificationCancelRequest): Promise>", + docUrl: + "api/notifications/trait.Notifications.html#method.cancel_push_notification", + description: + "Cancels a previously issued push notification.\n\nCancellation is idempotent: returns `Ok(())` whether the notification is\nstill pending, already fired, or was never issued. See [RFC 0019].\n\n[RFC 0019]: https://github.com/paritytech/truapi/blob/main/docs/rfcs/0019-scheduled-notifications.md", + requestDescription: "HostPushNotificationCancelRequest", + requestType: "host-push-notification-cancel-request", + errorType: "generic-error", + }, + ], + }, + { + name: "Payment", + methods: [ + { + name: "balance_subscribe", + type: "subscription", + signature: + "balanceSubscribe(request: HostPaymentBalanceSubscribeRequest): ObservableLike", + docUrl: "api/payment/trait.Payment.html#method.balance_subscribe", + description: "Subscribe to payment balance updates.", + requestDescription: "HostPaymentBalanceSubscribeRequest", + requestType: "host-payment-balance-subscribe-request", + responseType: "host-payment-balance-subscribe-item", + errorType: "host-payment-balance-subscribe-error", + }, + { + name: "top_up", + type: "unary", + signature: + "topUp(request: HostPaymentTopUpRequest): Promise>", + docUrl: "api/payment/trait.Payment.html#method.top_up", + description: "Top up the user's payment balance.", + requestDescription: "HostPaymentTopUpRequest", + requestType: "host-payment-top-up-request", + errorType: "host-payment-top-up-error", + }, + { + name: "request", + type: "unary", + signature: + "request(request: HostPaymentRequest): Promise>", + docUrl: "api/payment/trait.Payment.html#method.request", + description: "Request a payment from the user.", + requestDescription: "HostPaymentRequest", + requestType: "host-payment-request", + responseType: "host-payment-response", + errorType: "host-payment-error", + }, + { + name: "status_subscribe", + type: "subscription", + signature: + "statusSubscribe(request: HostPaymentStatusSubscribeRequest): ObservableLike", + docUrl: "api/payment/trait.Payment.html#method.status_subscribe", + description: + "Subscribe to payment lifecycle updates for a specific payment.", + requestDescription: "HostPaymentStatusSubscribeRequest", + requestType: "host-payment-status-subscribe-request", + responseType: "host-payment-status-subscribe-item", + errorType: "host-payment-status-subscribe-error", + }, + ], + }, + { + name: "Permissions", + methods: [ + { + name: "request_device_permission", + type: "unary", + signature: + "requestDevicePermission(request: HostDevicePermissionRequest): Promise>", + docUrl: + "api/permissions/trait.Permissions.html#method.request_device_permission", + description: "Request a device-capability permission from the user.", + requestDescription: + "Enum values: Notifications / Camera / Microphone / Bluetooth / NFC / Location / Clipboard / OpenUrl / Biometrics", + requestType: "host-device-permission-request", + responseType: "host-device-permission-response", + errorType: "generic-error", + }, + { + name: "request_remote_permission", + type: "unary", + signature: + "requestRemotePermission(request: RemotePermissionRequest): Promise>", + docUrl: + "api/permissions/trait.Permissions.html#method.request_remote_permission", + description: "Request a remote-operation permission.", + requestDescription: "RemotePermissionRequest", + requestType: "remote-permission-request", + responseType: "remote-permission-response", + errorType: "generic-error", + }, + ], + }, + { + name: "Preimage", + methods: [ + { + name: "lookup_subscribe", + type: "subscription", + signature: + "lookupSubscribe(request: RemotePreimageLookupSubscribeRequest): ObservableLike", + docUrl: "api/preimage/trait.Preimage.html#method.lookup_subscribe", + description: "Subscribe to preimage lookups for a given key.", + requestDescription: "RemotePreimageLookupSubscribeRequest", + requestType: "remote-preimage-lookup-subscribe-request", + responseType: "remote-preimage-lookup-subscribe-item", + }, + { + name: "submit", + type: "unary", + signature: + "submit(request: HexString): Promise>", + docUrl: "api/preimage/trait.Preimage.html#method.submit", + description: + "Submit a preimage. Returns the preimage key (hash) on success.", + requestDescription: "HexString", + errorType: "preimage-submit-error", + }, + ], + }, + { + name: "Resource Allocation", + methods: [ + { + name: "request", + type: "unary", + signature: + "request(request: HostRequestResourceAllocationRequest): Promise>", + docUrl: + "api/resource_allocation/trait.ResourceAllocation.html#method.request", + description: "Request the host to pre-allocate one or more resources.", + requestDescription: "HostRequestResourceAllocationRequest", + requestType: "host-request-resource-allocation-request", + responseType: "host-request-resource-allocation-response", + errorType: "resource-allocation-error", + }, + ], + }, + { + name: "Signing", + methods: [ + { + name: "create_transaction", + type: "unary", + signature: + "createTransaction(request: ProductAccountTxPayload): Promise>", + docUrl: "api/signing/trait.Signing.html#method.create_transaction", + description: "Construct a signed transaction for a product account.", + requestDescription: "ProductAccountTxPayload", + requestType: "product-account-tx-payload", + responseType: "host-create-transaction-response", + errorType: "host-create-transaction-error", + }, + { + name: "create_transaction_with_legacy_account", + type: "unary", + signature: + "createTransactionWithLegacyAccount(request: LegacyAccountTxPayload): Promise>", + docUrl: + "api/signing/trait.Signing.html#method.create_transaction_with_legacy_account", + description: + "Construct a signed transaction for a non-product (legacy) account.", + requestDescription: "LegacyAccountTxPayload", + requestType: "legacy-account-tx-payload", + responseType: "host-create-transaction-with-legacy-account-response", + errorType: "host-create-transaction-error", + }, + { + name: "sign_raw_with_legacy_account", + type: "unary", + signature: + "signRawWithLegacyAccount(request: HostSignRawWithLegacyAccountRequest): Promise>", + docUrl: + "api/signing/trait.Signing.html#method.sign_raw_with_legacy_account", + description: "Sign raw bytes with a non-product account.", + requestDescription: "HostSignRawWithLegacyAccountRequest", + requestType: "host-sign-raw-with-legacy-account-request", + responseType: "host-sign-payload-response", + errorType: "host-sign-payload-error", + }, + { + name: "sign_payload_with_legacy_account", + type: "unary", + signature: + "signPayloadWithLegacyAccount(request: HostSignPayloadWithLegacyAccountRequest): Promise>", + docUrl: + "api/signing/trait.Signing.html#method.sign_payload_with_legacy_account", + description: "Sign an extrinsic payload with a non-product account.", + requestDescription: "HostSignPayloadWithLegacyAccountRequest", + requestType: "host-sign-payload-with-legacy-account-request", + responseType: "host-sign-payload-response", + errorType: "host-sign-payload-error", + }, + { + name: "sign_raw", + type: "unary", + signature: + "signRaw(request: HostSignRawRequest): Promise>", + docUrl: "api/signing/trait.Signing.html#method.sign_raw", + description: "Sign raw bytes or a message.", + requestDescription: "HostSignRawRequest", + requestType: "host-sign-raw-request", + responseType: "host-sign-payload-response", + errorType: "host-sign-payload-error", + }, + { + name: "sign_payload", + type: "unary", + signature: + "signPayload(request: HostSignPayloadRequest): Promise>", + docUrl: "api/signing/trait.Signing.html#method.sign_payload", + description: "Sign an extrinsic payload.", + requestDescription: "HostSignPayloadRequest", + requestType: "host-sign-payload-request", + responseType: "host-sign-payload-response", + errorType: "host-sign-payload-error", + }, + ], + }, + { + name: "Statement Store", + methods: [ + { + name: "subscribe", + type: "subscription", + signature: + "subscribe(request: RemoteStatementStoreSubscribeRequest): ObservableLike", + docUrl: + "api/statement_store/trait.StatementStore.html#method.subscribe", + description: "Subscribe to statements matching a topic filter.", + requestDescription: "RemoteStatementStoreSubscribeRequest", + requestType: "remote-statement-store-subscribe-request", + responseType: "remote-statement-store-subscribe-item", + }, + { + name: "create_proof", + type: "unary", + signature: + "createProof(request: RemoteStatementStoreCreateProofRequest): Promise>", + docUrl: + "api/statement_store/trait.StatementStore.html#method.create_proof", + description: + "Create a proof for a statement.\n\n**Deprecated:** use [`create_proof_authorized`](Self::create_proof_authorized)\ninstead, which uses a pre-allocated allowance account and does not\nrequire a per-call signing prompt.", + requestDescription: "RemoteStatementStoreCreateProofRequest", + requestType: "remote-statement-store-create-proof-request", + responseType: "remote-statement-store-create-proof-response", + errorType: "remote-statement-store-create-proof-error", + }, + { + name: "submit", + type: "unary", + signature: + "submit(request: SignedStatement): Promise>", + docUrl: "api/statement_store/trait.StatementStore.html#method.submit", + description: + "Submit a signed statement to the network. The request body is the\n[`SignedStatement`](crate::v01::SignedStatement) directly (no wrapping\nstruct), matching upstream `triangle-js-sdks`.", + requestDescription: "SignedStatement", + requestType: "signed-statement", + errorType: "generic-error", + }, + { + name: "create_proof_authorized", + type: "unary", + signature: + "createProofAuthorized(request: Statement): Promise>", + docUrl: + "api/statement_store/trait.StatementStore.html#method.create_proof_authorized", + description: + "Create a proof for a statement using a pre-allocated allowance account,\nbypassing the per-call signing prompt.", + requestDescription: "Statement", + requestType: "statement", + responseType: "remote-statement-store-create-proof-response", + errorType: "remote-statement-store-create-proof-error", + }, + ], + }, + { + name: "System", + methods: [ + { + name: "handshake", + type: "unary", + signature: + "handshake(request: HostHandshakeRequest): Promise>", + docUrl: "api/system/trait.System.html#method.handshake", + description: "Negotiate the wire codec version with the product.", + requestDescription: "HostHandshakeRequest", + requestType: "host-handshake-request", + errorType: "host-handshake-error", + }, + { + name: "feature_supported", + type: "unary", + signature: + "featureSupported(request: HostFeatureSupportedRequest): Promise>", + docUrl: "api/system/trait.System.html#method.feature_supported", + description: "Query whether the host supports a specific feature.", + requestDescription: "HostFeatureSupportedRequest", + requestType: "host-feature-supported-request", + responseType: "host-feature-supported-response", + errorType: "generic-error", + }, + { + name: "navigate_to", + type: "unary", + signature: + "navigateTo(request: HostNavigateToRequest): Promise>", + docUrl: "api/system/trait.System.html#method.navigate_to", + description: "Request the host to open a URL.", + requestDescription: "HostNavigateToRequest", + requestType: "host-navigate-to-request", + errorType: "host-navigate-to-error", + }, + ], + }, + { + name: "Theme", + methods: [ + { + name: "subscribe", + type: "subscription", + signature: "subscribe(): ObservableLike", + docUrl: "api/theme/trait.Theme.html#method.subscribe", + description: "Subscribe to host theme changes.", + responseType: "host-theme-subscribe-item", + }, + ], + }, +]; diff --git a/js/packages/truapi/src/explorer/codegen/versions/0.3.2/types.ts b/js/packages/truapi/src/explorer/codegen/versions/0.3.2/types.ts new file mode 100644 index 00000000..37f778be --- /dev/null +++ b/js/packages/truapi/src/explorer/codegen/versions/0.3.2/types.ts @@ -0,0 +1,3836 @@ +// Auto-generated by truapi-codegen. Do not edit. +import type { DataType } from "../../../data-types.js"; + +export const types: DataType[] = [ + { + id: "account-id", + name: "AccountId", + category: "transaction", + definition: "export type AccountId = HexString;", + description: + "A 32-byte raw account identifier used for legacy (non-product) accounts.", + }, + { + id: "action-trigger", + name: "ActionTrigger", + category: "chat", + definition: + "export interface ActionTrigger {\n messageId: string;\n actionId: string;\n payload?: HexString;\n}", + description: "Payload when a user clicks an action button.", + fields: [ + { + name: "message_id", + type: "string", + description: "Message containing the action.", + }, + { + name: "action_id", + type: "string", + description: "Which action was triggered.", + }, + { + name: "payload", + type: "HexString | undefined", + description: "Optional additional data.", + }, + ], + }, + { + id: "allocatable-resource", + name: "AllocatableResource", + category: "resource_allocation", + definition: + 'export type AllocatableResource =\n | { tag: "StatementStoreAllowance"; value?: undefined }\n | { tag: "BulletinAllowance"; value?: undefined }\n | { tag: "SmartContractAllowance"; value: number }\n | { tag: "AutoSigning"; value?: undefined }\n;', + description: + "A resource the host can pre-allocate on behalf of the product (RFC 0010).\n\nFor the slot-table allowances (`StatementStoreAllowance`,\n`BulletinAllowance`, `SmartContractAllowance`), pre-allocation is\nopportunistic and the host may also fulfil the allowance implicitly on the\nfirst submission. `AutoSigning` must be requested explicitly through this\ncall.", + variants: [ + { + name: "StatementStoreAllowance", + type: '{ tag: "StatementStoreAllowance"; value?: undefined }', + description: + "Statement Store slot allowance for the product's own allowance account.", + }, + { + name: "BulletinAllowance", + type: '{ tag: "BulletinAllowance"; value?: undefined }', + description: + "Bulletin chain slot allowance for the product's own allowance account.", + }, + { + name: "SmartContractAllowance", + type: '{ tag: "SmartContractAllowance"; value: number }', + description: + "Pre-warmed PGAS balance for the smart-contract account at the given\nderivation index.", + }, + { + name: "AutoSigning", + type: '{ tag: "AutoSigning"; value?: undefined }', + description: + "Permission to sign on the product's behalf without per-call user prompts.", + }, + ], + }, + { + id: "allocation-outcome", + name: "AllocationOutcome", + category: "resource_allocation", + definition: + 'export type AllocationOutcome = "Allocated" | "Rejected" | "NotAvailable";', + description: "Outcome of allocating a single resource (RFC 0010).", + variants: [ + { + name: "Allocated", + type: '{ tag: "Allocated"; value?: undefined }', + description: "Resource is now available for use.", + }, + { + name: "Rejected", + type: '{ tag: "Rejected"; value?: undefined }', + description: "User or host refused the allocation.", + }, + { + name: "NotAvailable", + type: '{ tag: "NotAvailable"; value?: undefined }', + description: + "Host cannot provide this resource on the current chain or environment.", + }, + ], + }, + { + id: "arrangement", + name: "Arrangement", + category: "chat", + definition: + 'export type Arrangement = "Start" | "End" | "Center" | "SpaceBetween" | "SpaceAround" | "SpaceEvenly";', + description: "Layout arrangement (like CSS flexbox `justify-content`).", + variants: [ + { + name: "Start", + type: '{ tag: "Start"; value?: undefined }', + }, + { + name: "End", + type: '{ tag: "End"; value?: undefined }', + }, + { + name: "Center", + type: '{ tag: "Center"; value?: undefined }', + }, + { + name: "SpaceBetween", + type: '{ tag: "SpaceBetween"; value?: undefined }', + }, + { + name: "SpaceAround", + type: '{ tag: "SpaceAround"; value?: undefined }', + }, + { + name: "SpaceEvenly", + type: '{ tag: "SpaceEvenly"; value?: undefined }', + }, + ], + }, + { + id: "background", + name: "Background", + category: "chat", + definition: + "export interface Background {\n color: ColorToken;\n shape?: Shape;\n}", + description: "Background styling.", + fields: [ + { + name: "color", + type: "ColorToken", + description: "Background color.", + }, + { + name: "shape", + type: "Shape | undefined", + description: "Background shape.", + }, + ], + }, + { + id: "balance", + name: "Balance", + category: "payment", + definition: "export type Balance = bigint;", + description: + "Balance amount for payment operations. Interpreted according to the host's\nsingle fixed payment asset (e.g. pUSD).", + }, + { + id: "border-style", + name: "BorderStyle", + category: "chat", + definition: + "export interface BorderStyle {\n width: Size;\n color: ColorToken;\n shape?: Shape;\n}", + description: "Border styling.", + fields: [ + { + name: "width", + type: "Size", + description: "Border width.", + }, + { + name: "color", + type: "ColorToken", + description: "Border color.", + }, + { + name: "shape", + type: "Shape | undefined", + description: "Border shape.", + }, + ], + }, + { + id: "box-props", + name: "BoxProps", + category: "chat", + definition: + "export interface BoxProps {\n contentAlignment?: ContentAlignment;\n}", + description: "Properties for a [`CustomRendererNode::Box`] container.", + fields: [ + { + name: "content_alignment", + type: "ContentAlignment | undefined", + description: "Content alignment within the box.", + }, + ], + }, + { + id: "button-props", + name: "ButtonProps", + category: "chat", + definition: + "export interface ButtonProps {\n text: string;\n variant?: ButtonVariant;\n enabled: boolean | undefined;\n loading: boolean | undefined;\n clickAction?: string;\n}", + description: "Properties for a [`CustomRendererNode::Button`].", + fields: [ + { + name: "text", + type: "string", + description: "Button label text.", + }, + { + name: "variant", + type: "ButtonVariant | undefined", + description: "Button style variant.", + }, + { + name: "enabled", + type: "boolean | undefined", + description: + "Whether the button is enabled. Absent leaves the default to the host.", + }, + { + name: "loading", + type: "boolean | undefined", + description: + "Whether the button shows a loading state. Absent leaves the default to the host.", + }, + { + name: "click_action", + type: "string | undefined", + description: "Action identifier triggered on click.", + }, + ], + }, + { + id: "button-variant", + name: "ButtonVariant", + category: "chat", + definition: 'export type ButtonVariant = "Primary" | "Secondary" | "Text";', + description: "Button style variants.", + variants: [ + { + name: "Primary", + type: '{ tag: "Primary"; value?: undefined }', + }, + { + name: "Secondary", + type: '{ tag: "Secondary"; value?: undefined }', + }, + { + name: "Text", + type: '{ tag: "Text"; value?: undefined }', + }, + ], + }, + { + id: "chat-action", + name: "ChatAction", + category: "chat", + definition: + "export interface ChatAction {\n actionId: string;\n title: string;\n}", + description: "A clickable action button in a chat message.", + fields: [ + { + name: "action_id", + type: "string", + description: "Action identifier.", + }, + { + name: "title", + type: "string", + description: "Button label.", + }, + ], + }, + { + id: "chat-action-layout", + name: "ChatActionLayout", + category: "chat", + definition: 'export type ChatActionLayout = "Column" | "Grid";', + description: "Layout for action buttons.", + variants: [ + { + name: "Column", + type: '{ tag: "Column"; value?: undefined }', + }, + { + name: "Grid", + type: '{ tag: "Grid"; value?: undefined }', + }, + ], + }, + { + id: "chat-action-payload", + name: "ChatActionPayload", + category: "chat", + definition: + 'export type ChatActionPayload =\n | { tag: "MessagePosted"; value: ChatMessageContent }\n | { tag: "ActionTriggered"; value: ActionTrigger }\n | { tag: "Command"; value: ChatCommand }\n;', + description: "Payload of a received chat action.", + variants: [ + { + name: "MessagePosted", + type: '{ tag: "MessagePosted"; value: ChatMessageContent }', + description: "A peer posted a message.", + }, + { + name: "ActionTriggered", + type: '{ tag: "ActionTriggered"; value: ActionTrigger }', + description: "A user triggered an action button.", + }, + { + name: "Command", + type: '{ tag: "Command"; value: ChatCommand }', + description: "A user issued a command.", + }, + ], + }, + { + id: "chat-actions", + name: "ChatActions", + category: "chat", + definition: + "export interface ChatActions {\n text?: string;\n actions: Array;\n layout: ChatActionLayout;\n}", + description: "A set of action buttons with optional text.", + fields: [ + { + name: "text", + type: "string | undefined", + description: "Optional message text.", + }, + { + name: "actions", + type: "Array", + description: "List of action buttons.", + }, + { + name: "layout", + type: "ChatActionLayout", + description: "`Column` or `Grid` layout.", + }, + ], + }, + { + id: "chat-bot-registration-status", + name: "ChatBotRegistrationStatus", + category: "chat", + definition: 'export type ChatBotRegistrationStatus = "New" | "Exists";', + description: "Whether the bot was newly registered or already existed.", + variants: [ + { + name: "New", + type: '{ tag: "New"; value?: undefined }', + }, + { + name: "Exists", + type: '{ tag: "Exists"; value?: undefined }', + }, + ], + }, + { + id: "chat-command", + name: "ChatCommand", + category: "chat", + definition: + "export interface ChatCommand {\n command: string;\n payload: string;\n}", + description: "A slash command from a chat user.", + fields: [ + { + name: "command", + type: "string", + description: "Command name.", + }, + { + name: "payload", + type: "string", + description: "Command arguments.", + }, + ], + }, + { + id: "chat-custom-message", + name: "ChatCustomMessage", + category: "chat", + definition: + "export interface ChatCustomMessage {\n messageType: string;\n payload: HexString;\n}", + description: + "A custom message with application-defined type and binary payload.", + fields: [ + { + name: "message_type", + type: "string", + description: "Application-defined type key.", + }, + { + name: "payload", + type: "HexString", + description: "Binary payload.", + }, + ], + }, + { + id: "chat-file", + name: "ChatFile", + category: "chat", + definition: + "export interface ChatFile {\n url: string;\n fileName: string;\n mimeType: string;\n sizeBytes: bigint;\n text?: string;\n}", + description: "A file attachment in a chat message.", + fields: [ + { + name: "url", + type: "string", + description: "File download URL.", + }, + { + name: "file_name", + type: "string", + description: "File name.", + }, + { + name: "mime_type", + type: "string", + description: "MIME type.", + }, + { + name: "size_bytes", + type: "bigint", + description: "File size in bytes.", + }, + { + name: "text", + type: "string | undefined", + description: "Optional caption text.", + }, + ], + }, + { + id: "chat-media", + name: "ChatMedia", + category: "chat", + definition: "export interface ChatMedia {\n url: string;\n}", + description: "A media attachment.", + fields: [ + { + name: "url", + type: "string", + description: "Media URL.", + }, + ], + }, + { + id: "chat-message-content", + name: "ChatMessageContent", + category: "chat", + definition: + 'export type ChatMessageContent =\n | { tag: "Text"; value: { text: string } }\n | { tag: "RichText"; value: ChatRichText }\n | { tag: "Actions"; value: ChatActions }\n | { tag: "File"; value: ChatFile }\n | { tag: "Reaction"; value: ChatReaction }\n | { tag: "ReactionRemoved"; value: ChatReaction }\n | { tag: "Custom"; value: ChatCustomMessage }\n;', + description: "Content of a chat message -- one of several types.", + variants: [ + { + name: "Text", + type: '{ tag: "Text"; value: { text: string } }', + description: "Plain text message.", + }, + { + name: "RichText", + type: '{ tag: "RichText"; value: ChatRichText }', + description: "Rich text with media.", + }, + { + name: "Actions", + type: '{ tag: "Actions"; value: ChatActions }', + description: "Action button set.", + }, + { + name: "File", + type: '{ tag: "File"; value: ChatFile }', + description: "File attachment.", + }, + { + name: "Reaction", + type: '{ tag: "Reaction"; value: ChatReaction }', + description: "Emoji reaction.", + }, + { + name: "ReactionRemoved", + type: '{ tag: "ReactionRemoved"; value: ChatReaction }', + description: "Reaction removal.", + }, + { + name: "Custom", + type: '{ tag: "Custom"; value: ChatCustomMessage }', + description: "Custom message.", + }, + ], + }, + { + id: "chat-reaction", + name: "ChatReaction", + category: "chat", + definition: + "export interface ChatReaction {\n messageId: string;\n emoji: string;\n}", + description: "A reaction to a chat message.", + fields: [ + { + name: "message_id", + type: "string", + description: "Message being reacted to.", + }, + { + name: "emoji", + type: "string", + description: "Emoji reaction.", + }, + ], + }, + { + id: "chat-rich-text", + name: "ChatRichText", + category: "chat", + definition: + "export interface ChatRichText {\n text?: string;\n media: Array;\n}", + description: "Rich text message with optional media.", + fields: [ + { + name: "text", + type: "string | undefined", + description: "Optional text content.", + }, + { + name: "media", + type: "Array", + description: "Attached media items.", + }, + ], + }, + { + id: "chat-room", + name: "ChatRoom", + category: "chat", + definition: + "export interface ChatRoom {\n roomId: string;\n participatingAs: ChatRoomParticipation;\n}", + description: "A chat room the product participates in.", + fields: [ + { + name: "room_id", + type: "string", + description: "Room identifier.", + }, + { + name: "participating_as", + type: "ChatRoomParticipation", + description: "`RoomHost` or `Bot`.", + }, + ], + }, + { + id: "chat-room-participation", + name: "ChatRoomParticipation", + category: "chat", + definition: 'export type ChatRoomParticipation = "RoomHost" | "Bot";', + description: "How the product participates in a chat room.", + variants: [ + { + name: "RoomHost", + type: '{ tag: "RoomHost"; value?: undefined }', + }, + { + name: "Bot", + type: '{ tag: "Bot"; value?: undefined }', + }, + ], + }, + { + id: "chat-room-registration-status", + name: "ChatRoomRegistrationStatus", + category: "chat", + definition: 'export type ChatRoomRegistrationStatus = "New" | "Exists";', + description: "Whether the room was newly created or already existed.", + variants: [ + { + name: "New", + type: '{ tag: "New"; value?: undefined }', + }, + { + name: "Exists", + type: '{ tag: "Exists"; value?: undefined }', + }, + ], + }, + { + id: "color-token", + name: "ColorToken", + category: "chat", + definition: + 'export type ColorToken = "FgPrimary" | "FgSecondary" | "FgTertiary" | "BgSurfaceMain" | "BgSurfaceContainer" | "BgSurfaceNested" | "FgSuccess" | "FgError" | "FgWarning";', + description: "Semantic color tokens for theming.", + variants: [ + { + name: "FgPrimary", + type: '{ tag: "FgPrimary"; value?: undefined }', + }, + { + name: "FgSecondary", + type: '{ tag: "FgSecondary"; value?: undefined }', + }, + { + name: "FgTertiary", + type: '{ tag: "FgTertiary"; value?: undefined }', + }, + { + name: "BgSurfaceMain", + type: '{ tag: "BgSurfaceMain"; value?: undefined }', + }, + { + name: "BgSurfaceContainer", + type: '{ tag: "BgSurfaceContainer"; value?: undefined }', + }, + { + name: "BgSurfaceNested", + type: '{ tag: "BgSurfaceNested"; value?: undefined }', + }, + { + name: "FgSuccess", + type: '{ tag: "FgSuccess"; value?: undefined }', + }, + { + name: "FgError", + type: '{ tag: "FgError"; value?: undefined }', + }, + { + name: "FgWarning", + type: '{ tag: "FgWarning"; value?: undefined }', + }, + ], + }, + { + id: "column-props", + name: "ColumnProps", + category: "chat", + definition: + "export interface ColumnProps {\n horizontalAlignment?: HorizontalAlignment;\n verticalArrangement?: Arrangement;\n}", + description: "Properties for a [`CustomRendererNode::Column`] layout.", + fields: [ + { + name: "horizontal_alignment", + type: "HorizontalAlignment | undefined", + description: "Horizontal alignment of children.", + }, + { + name: "vertical_arrangement", + type: "Arrangement | undefined", + description: "Vertical arrangement of children.", + }, + ], + }, + { + id: "component", + name: "Component", + category: "chat", + definition: + "export interface Component

{\n modifiers: Array;\n props: P;\n children: Array;\n}", + description: + "A component in the custom renderer UI tree, combining modifiers, typed props,\nand recursive children.", + fields: [ + { + name: "modifiers", + type: "Array", + description: "Layout and styling modifiers.", + }, + { + name: "props", + type: "P", + description: "Component-specific properties.", + }, + { + name: "children", + type: "Array", + description: "Child nodes.", + }, + ], + }, + { + id: "content-alignment", + name: "ContentAlignment", + category: "chat", + definition: + 'export type ContentAlignment = "TopStart" | "TopCenter" | "TopEnd" | "CenterStart" | "Center" | "CenterEnd" | "BottomStart" | "BottomCenter" | "BottomEnd";', + description: "2D content alignment.", + variants: [ + { + name: "TopStart", + type: '{ tag: "TopStart"; value?: undefined }', + }, + { + name: "TopCenter", + type: '{ tag: "TopCenter"; value?: undefined }', + }, + { + name: "TopEnd", + type: '{ tag: "TopEnd"; value?: undefined }', + }, + { + name: "CenterStart", + type: '{ tag: "CenterStart"; value?: undefined }', + }, + { + name: "Center", + type: '{ tag: "Center"; value?: undefined }', + }, + { + name: "CenterEnd", + type: '{ tag: "CenterEnd"; value?: undefined }', + }, + { + name: "BottomStart", + type: '{ tag: "BottomStart"; value?: undefined }', + }, + { + name: "BottomCenter", + type: '{ tag: "BottomCenter"; value?: undefined }', + }, + { + name: "BottomEnd", + type: '{ tag: "BottomEnd"; value?: undefined }', + }, + ], + }, + { + id: "custom-renderer-node", + name: "CustomRendererNode", + category: "chat", + definition: + 'export type CustomRendererNode =\n | { tag: "Nil"; value?: undefined }\n | { tag: "String"; value: { text: string } }\n | { tag: "Box"; value: Component }\n | { tag: "Column"; value: Component }\n | { tag: "Row"; value: Component }\n | { tag: "Spacer"; value: Component }\n | { tag: "Text"; value: Component }\n | { tag: "Button"; value: Component }\n | { tag: "TextField"; value: Component }\n;', + description: + "A node in the custom renderer UI tree. Can be nested recursively via the\n`children` field of each [`Component`].", + variants: [ + { + name: "Nil", + type: '{ tag: "Nil"; value?: undefined }', + description: "Empty node.", + }, + { + name: "String", + type: '{ tag: "String"; value: { text: string } }', + description: "Raw text string.", + }, + { + name: "Box", + type: '{ tag: "Box"; value: Component }', + description: "Generic container.", + }, + { + name: "Column", + type: '{ tag: "Column"; value: Component }', + description: "Vertical layout.", + }, + { + name: "Row", + type: '{ tag: "Row"; value: Component }', + description: "Horizontal layout.", + }, + { + name: "Spacer", + type: '{ tag: "Spacer"; value: Component }', + description: "Flexible space.", + }, + { + name: "Text", + type: '{ tag: "Text"; value: Component }', + description: "Text display.", + }, + { + name: "Button", + type: '{ tag: "Button"; value: Component }', + description: "Interactive button.", + }, + { + name: "TextField", + type: '{ tag: "TextField"; value: Component }', + description: "Text input.", + }, + ], + }, + { + id: "dimensions", + name: "Dimensions", + category: "chat", + definition: + "export interface Dimensions {\n top: Size;\n end: Size;\n bottom?: Size;\n start?: Size;\n}", + description: + "CSS-like dimensions: (top, end, bottom, start).\nBottom defaults to top, start defaults to end when `None`.", + fields: [ + { + name: "top", + type: "Size", + description: "Top dimension.", + }, + { + name: "end", + type: "Size", + description: "End dimension.", + }, + { + name: "bottom", + type: "Size | undefined", + description: "Bottom dimension. Defaults to top when absent.", + }, + { + name: "start", + type: "Size | undefined", + description: "Start dimension. Defaults to end when absent.", + }, + ], + }, + { + id: "generic-error", + name: "GenericError", + category: "common", + definition: "export interface GenericError {\n reason: string;\n}", + description: + "Generic error payload carrying a human-readable reason string. Used by many\nmethods as a catch-all error type.", + fields: [ + { + name: "reason", + type: "string", + }, + ], + }, + { + id: "genesis-hash", + name: "GenesisHash", + category: "transaction", + definition: "export type GenesisHash = HexString;", + description: + "A 32-byte chain genesis hash used to identify the target chain.", + }, + { + id: "horizontal-alignment", + name: "HorizontalAlignment", + category: "chat", + definition: 'export type HorizontalAlignment = "Start" | "Center" | "End";', + description: "Horizontal alignment options.", + variants: [ + { + name: "Start", + type: '{ tag: "Start"; value?: undefined }', + }, + { + name: "Center", + type: '{ tag: "Center"; value?: undefined }', + }, + { + name: "End", + type: '{ tag: "End"; value?: undefined }', + }, + ], + }, + { + id: "host-account-connection-status-subscribe-item", + name: "HostAccountConnectionStatusSubscribeItem", + category: "account", + definition: + 'export type HostAccountConnectionStatusSubscribeItem = "Disconnected" | "Connected";', + description: "User's authentication state.", + variants: [ + { + name: "Disconnected", + type: '{ tag: "Disconnected"; value?: undefined }', + }, + { + name: "Connected", + type: '{ tag: "Connected"; value?: undefined }', + }, + ], + }, + { + id: "host-account-create-proof-error", + name: "HostAccountCreateProofError", + category: "account", + definition: + 'export type HostAccountCreateProofError =\n | { tag: "RingNotFound"; value?: undefined }\n | { tag: "Rejected"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: "Error returned when ring VRF proof creation fails.", + variants: [ + { + name: "RingNotFound", + type: '{ tag: "RingNotFound"; value?: undefined }', + description: "Ring not available at the specified location.", + }, + { + name: "Rejected", + type: '{ tag: "Rejected"; value?: undefined }', + description: "User or host rejected.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-account-create-proof-request", + name: "HostAccountCreateProofRequest", + category: "account", + definition: + "export interface HostAccountCreateProofRequest {\n productAccountId: ProductAccountId;\n ringLocation: RingLocation;\n context: HexString;\n}", + description: "Request to create a ring VRF proof for a product account.", + fields: [ + { + name: "product_account_id", + type: "ProductAccountId", + description: "Product account that should create the proof.", + }, + { + name: "ring_location", + type: "RingLocation", + description: "Ring location to use for proof generation.", + }, + { + name: "context", + type: "HexString", + description: "Context bytes bound to the proof.", + }, + ], + }, + { + id: "host-account-create-proof-response", + name: "HostAccountCreateProofResponse", + category: "account", + definition: + "export interface HostAccountCreateProofResponse {\n proof: HexString;\n}", + description: "Response containing a ring VRF proof.", + fields: [ + { + name: "proof", + type: "HexString", + description: "Variable-length ring VRF proof bytes.", + }, + ], + }, + { + id: "host-account-get-alias-request", + name: "HostAccountGetAliasRequest", + category: "account", + definition: + "export interface HostAccountGetAliasRequest {\n productAccountId: ProductAccountId;\n}", + description: + "Request to retrieve a contextual alias for a product account.", + fields: [ + { + name: "product_account_id", + type: "ProductAccountId", + description: "Product account to derive the alias for.", + }, + ], + }, + { + id: "host-account-get-alias-response", + name: "HostAccountGetAliasResponse", + category: "account", + definition: + "export interface HostAccountGetAliasResponse {\n context: HexString;\n alias: HexString;\n}", + description: + "A privacy-preserving alias derived via ring VRF, bound to a specific context.", + fields: [ + { + name: "context", + type: "HexString", + description: "32-byte context identifier.", + }, + { + name: "alias", + type: "HexString", + description: "Ring VRF alias (variable length).", + }, + ], + }, + { + id: "host-account-get-error", + name: "HostAccountGetError", + category: "account", + definition: + 'export type HostAccountGetError =\n | { tag: "NotConnected"; value?: undefined }\n | { tag: "Rejected"; value?: undefined }\n | { tag: "DomainNotValid"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: "Error returned when credential/account requests fail.", + variants: [ + { + name: "NotConnected", + type: '{ tag: "NotConnected"; value?: undefined }', + description: "User is not logged in.", + }, + { + name: "Rejected", + type: '{ tag: "Rejected"; value?: undefined }', + description: "User or host rejected the request.", + }, + { + name: "DomainNotValid", + type: '{ tag: "DomainNotValid"; value?: undefined }', + description: "Domain identifier is invalid.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all error with reason.", + }, + ], + }, + { + id: "host-account-get-request", + name: "HostAccountGetRequest", + category: "account", + definition: + "export interface HostAccountGetRequest {\n productAccountId: ProductAccountId;\n}", + description: "Request to retrieve a product-scoped account.", + fields: [ + { + name: "product_account_id", + type: "ProductAccountId", + description: "Product account to retrieve.", + }, + ], + }, + { + id: "host-account-get-response", + name: "HostAccountGetResponse", + category: "account", + definition: + "export interface HostAccountGetResponse {\n account: ProductAccount;\n}", + description: "Response containing a product-scoped account.", + fields: [ + { + name: "account", + type: "ProductAccount", + description: "Retrieved product account.", + }, + ], + }, + { + id: "host-chat-action-subscribe-item", + name: "HostChatActionSubscribeItem", + category: "chat", + definition: + "export interface HostChatActionSubscribeItem {\n roomId: string;\n peer: string;\n payload: ChatActionPayload;\n}", + description: "A chat action received from the host.", + fields: [ + { + name: "room_id", + type: "string", + description: "Room where the action occurred.", + }, + { + name: "peer", + type: "string", + description: "Peer who initiated the action.", + }, + { + name: "payload", + type: "ChatActionPayload", + description: "The action payload.", + }, + ], + }, + { + id: "host-chat-create-room-error", + name: "HostChatCreateRoomError", + category: "chat", + definition: + 'export type HostChatCreateRoomError =\n | { tag: "PermissionDenied"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: "Chat room registration error.", + variants: [ + { + name: "PermissionDenied", + type: '{ tag: "PermissionDenied"; value?: undefined }', + description: "Not allowed.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-chat-create-room-request", + name: "HostChatCreateRoomRequest", + category: "chat", + definition: + "export interface HostChatCreateRoomRequest {\n roomId: string;\n name: string;\n icon: string;\n}", + description: "Request to create a chat room.", + fields: [ + { + name: "room_id", + type: "string", + description: "Unique room identifier.", + }, + { + name: "name", + type: "string", + description: "Room display name.", + }, + { + name: "icon", + type: "string", + description: "URL or base64 image.", + }, + ], + }, + { + id: "host-chat-create-room-response", + name: "HostChatCreateRoomResponse", + category: "chat", + definition: + "export interface HostChatCreateRoomResponse {\n status: ChatRoomRegistrationStatus;\n}", + description: "Result of a room registration.", + fields: [ + { + name: "status", + type: "ChatRoomRegistrationStatus", + description: "`New` or `Exists`.", + }, + ], + }, + { + id: "host-chat-list-subscribe-item", + name: "HostChatListSubscribeItem", + category: "chat", + definition: + "export interface HostChatListSubscribeItem {\n rooms: Array;\n}", + description: "Item containing the current chat rooms.", + fields: [ + { + name: "rooms", + type: "Array", + description: "Chat rooms the product participates in.", + }, + ], + }, + { + id: "host-chat-post-message-error", + name: "HostChatPostMessageError", + category: "chat", + definition: + 'export type HostChatPostMessageError =\n | { tag: "MessageTooLarge"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: "Chat message posting error.", + variants: [ + { + name: "MessageTooLarge", + type: '{ tag: "MessageTooLarge"; value?: undefined }', + description: "Message exceeded size limit.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-chat-post-message-request", + name: "HostChatPostMessageRequest", + category: "chat", + definition: + "export interface HostChatPostMessageRequest {\n roomId: string;\n payload: ChatMessageContent;\n}", + description: "Request to post a message to a chat room.", + fields: [ + { + name: "room_id", + type: "string", + description: "Room to post to.", + }, + { + name: "payload", + type: "ChatMessageContent", + description: "Message content.", + }, + ], + }, + { + id: "host-chat-post-message-response", + name: "HostChatPostMessageResponse", + category: "chat", + definition: + "export interface HostChatPostMessageResponse {\n messageId: string;\n}", + description: "Result of posting a message.", + fields: [ + { + name: "message_id", + type: "string", + description: "Assigned message ID.", + }, + ], + }, + { + id: "host-chat-register-bot-error", + name: "HostChatRegisterBotError", + category: "chat", + definition: + 'export type HostChatRegisterBotError =\n | { tag: "PermissionDenied"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: "Chat bot registration error.", + variants: [ + { + name: "PermissionDenied", + type: '{ tag: "PermissionDenied"; value?: undefined }', + description: "Not allowed.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-chat-register-bot-request", + name: "HostChatRegisterBotRequest", + category: "chat", + definition: + "export interface HostChatRegisterBotRequest {\n botId: string;\n name: string;\n icon: string;\n}", + description: "Request to register a chat bot.", + fields: [ + { + name: "bot_id", + type: "string", + description: "Unique bot identifier.", + }, + { + name: "name", + type: "string", + description: "Bot display name.", + }, + { + name: "icon", + type: "string", + description: "URL or base64 image.", + }, + ], + }, + { + id: "host-chat-register-bot-response", + name: "HostChatRegisterBotResponse", + category: "chat", + definition: + "export interface HostChatRegisterBotResponse {\n status: ChatBotRegistrationStatus;\n}", + description: "Result of a bot registration.", + fields: [ + { + name: "status", + type: "ChatBotRegistrationStatus", + description: "`New` or `Exists`.", + }, + ], + }, + { + id: "host-create-transaction-error", + name: "HostCreateTransactionError", + category: "transaction", + definition: + 'export type HostCreateTransactionError =\n | { tag: "FailedToDecode"; value?: undefined }\n | { tag: "Rejected"; value?: undefined }\n | { tag: "NotSupported"; value: { reason: string } }\n | { tag: "PermissionDenied"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: "Transaction creation error.", + variants: [ + { + name: "FailedToDecode", + type: '{ tag: "FailedToDecode"; value?: undefined }', + description: "Payload could not be deserialized.", + }, + { + name: "Rejected", + type: '{ tag: "Rejected"; value?: undefined }', + description: "User rejected.", + }, + { + name: "NotSupported", + type: '{ tag: "NotSupported"; value: { reason: string } }', + description: "Unsupported payload version or extension.", + }, + { + name: "PermissionDenied", + type: '{ tag: "PermissionDenied"; value?: undefined }', + description: "Not authenticated.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-create-transaction-response", + name: "HostCreateTransactionResponse", + category: "signing", + definition: + "export interface HostCreateTransactionResponse {\n transaction: HexString;\n}", + description: "Response containing a created transaction.", + fields: [ + { + name: "transaction", + type: "HexString", + description: "SCALE-encoded signed transaction.", + }, + ], + }, + { + id: "host-create-transaction-with-legacy-account-response", + name: "HostCreateTransactionWithLegacyAccountResponse", + category: "signing", + definition: + "export interface HostCreateTransactionWithLegacyAccountResponse {\n transaction: HexString;\n}", + description: + "Response containing a transaction created with a non-product account.", + fields: [ + { + name: "transaction", + type: "HexString", + description: "SCALE-encoded signed transaction.", + }, + ], + }, + { + id: "host-derive-entropy-error", + name: "HostDeriveEntropyError", + category: "entropy", + definition: + 'export type HostDeriveEntropyError =\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: "Error from [`crate::api::Entropy::derive`] (RFC 0007).", + variants: [ + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-derive-entropy-request", + name: "HostDeriveEntropyRequest", + category: "entropy", + definition: + "export interface HostDeriveEntropyRequest {\n context: HexString;\n}", + description: + "Request to derive deterministic per-product entropy (RFC 0007).\n\nThe host derives 32 bytes from product-scoped seed material and `context`.\nRepeated calls with the same `context` for the same product yield the same\nentropy.", + fields: [ + { + name: "context", + type: "HexString", + description: "Domain-separated derivation context.", + }, + ], + }, + { + id: "host-derive-entropy-response", + name: "HostDeriveEntropyResponse", + category: "entropy", + definition: + "export interface HostDeriveEntropyResponse {\n entropy: HexString;\n}", + description: + "Response carrying 32 bytes of deterministically derived entropy.", + fields: [ + { + name: "entropy", + type: "HexString", + description: "32 bytes of derived entropy.", + }, + ], + }, + { + id: "host-device-permission-request", + name: "HostDevicePermissionRequest", + category: "permissions", + definition: + 'export type HostDevicePermissionRequest = "Notifications" | "Camera" | "Microphone" | "Bluetooth" | "NFC" | "Location" | "Clipboard" | "OpenUrl" | "Biometrics";', + description: + "Device-capability permission requested from the host (RFC 0002).\n\nThe user's decision is persisted indefinitely after the first prompt and\nsurvives app restarts, whether the decision was grant or deny; the host\ndoes not re-prompt on subsequent requests for the same capability.", + variants: [ + { + name: "Notifications", + type: '{ tag: "Notifications"; value?: undefined }', + }, + { + name: "Camera", + type: '{ tag: "Camera"; value?: undefined }', + }, + { + name: "Microphone", + type: '{ tag: "Microphone"; value?: undefined }', + }, + { + name: "Bluetooth", + type: '{ tag: "Bluetooth"; value?: undefined }', + }, + { + name: "NFC", + type: '{ tag: "NFC"; value?: undefined }', + }, + { + name: "Location", + type: '{ tag: "Location"; value?: undefined }', + }, + { + name: "Clipboard", + type: '{ tag: "Clipboard"; value?: undefined }', + }, + { + name: "OpenUrl", + type: '{ tag: "OpenUrl"; value?: undefined }', + }, + { + name: "Biometrics", + type: '{ tag: "Biometrics"; value?: undefined }', + }, + ], + }, + { + id: "host-device-permission-response", + name: "HostDevicePermissionResponse", + category: "permissions", + definition: + "export interface HostDevicePermissionResponse {\n granted: boolean;\n}", + description: "Outcome of a device-permission request.", + fields: [ + { + name: "granted", + type: "boolean", + description: "Whether the permission was granted.", + }, + ], + }, + { + id: "host-feature-supported-request", + name: "HostFeatureSupportedRequest", + category: "system", + definition: + 'export type HostFeatureSupportedRequest =\n | { tag: "Chain"; value: { genesisHash: HexString } }\n;', + description: "Request to query whether a feature is supported by the host.", + variants: [ + { + name: "Chain", + type: '{ tag: "Chain"; value: { genesisHash: HexString } }', + description: + "Ask whether the host can interact with the chain identified by genesis hash.", + }, + ], + }, + { + id: "host-feature-supported-response", + name: "HostFeatureSupportedResponse", + category: "system", + definition: + "export interface HostFeatureSupportedResponse {\n supported: boolean;\n}", + description: "Response to a feature-support query.", + fields: [ + { + name: "supported", + type: "boolean", + description: "Whether the feature is supported.", + }, + ], + }, + { + id: "host-get-legacy-accounts-response", + name: "HostGetLegacyAccountsResponse", + category: "account", + definition: + "export interface HostGetLegacyAccountsResponse {\n accounts: Array;\n}", + description: + "Response containing all legacy (user-imported) accounts owned by the user.", + fields: [ + { + name: "accounts", + type: "Array", + description: "Legacy accounts.", + }, + ], + }, + { + id: "host-get-user-id-error", + name: "HostGetUserIdError", + category: "account", + definition: + 'export type HostGetUserIdError =\n | { tag: "PermissionDenied"; value?: undefined }\n | { tag: "NotConnected"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: "Error from [`crate::api::Account::get_user_id`].", + variants: [ + { + name: "PermissionDenied", + type: '{ tag: "PermissionDenied"; value?: undefined }', + description: "User denied the identity disclosure request.", + }, + { + name: "NotConnected", + type: '{ tag: "NotConnected"; value?: undefined }', + description: "User is not logged in.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-get-user-id-response", + name: "HostGetUserIdResponse", + category: "account", + definition: + "export interface HostGetUserIdResponse {\n primaryUsername: string;\n}", + description: "The user's primary DotNS account identity.", + fields: [ + { + name: "primary_username", + type: "string", + description: "The user's primary DotNS username.", + }, + ], + }, + { + id: "host-handshake-error", + name: "HostHandshakeError", + category: "system", + definition: + 'export type HostHandshakeError =\n | { tag: "Timeout"; value?: undefined }\n | { tag: "UnsupportedProtocolVersion"; value?: undefined }\n | { tag: "Unknown"; value: GenericError }\n;', + description: + "Error from [`crate::api::System::handshake`] (RFC 0009).\n\nThe handshake is the first call on a fresh connection; it does not require\nuser authentication and is used to negotiate the wire codec version.", + variants: [ + { + name: "Timeout", + type: '{ tag: "Timeout"; value?: undefined }', + description: "Host did not complete the handshake in time.", + }, + { + name: "UnsupportedProtocolVersion", + type: '{ tag: "UnsupportedProtocolVersion"; value?: undefined }', + description: + "Host does not speak the codec version requested by the product.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: GenericError }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-handshake-request", + name: "HostHandshakeRequest", + category: "system", + definition: + "export interface HostHandshakeRequest {\n codecVersion: number;\n}", + description: + "Wire-codec negotiation payload sent by the product (RFC 0009).", + fields: [ + { + name: "codec_version", + type: "number", + description: "Wire codec version requested by the product.", + }, + ], + }, + { + id: "host-local-storage-clear-request", + name: "HostLocalStorageClearRequest", + category: "local_storage", + definition: + "export interface HostLocalStorageClearRequest {\n key: string;\n}", + description: "Request to clear a local storage key.", + fields: [ + { + name: "key", + type: "string", + description: "Storage key to clear.", + }, + ], + }, + { + id: "host-local-storage-read-error", + name: "HostLocalStorageReadError", + category: "local_storage", + definition: + 'export type HostLocalStorageReadError =\n | { tag: "Full"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: "Local storage operation error.", + variants: [ + { + name: "Full", + type: '{ tag: "Full"; value?: undefined }', + description: "Storage quota exceeded.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-local-storage-read-request", + name: "HostLocalStorageReadRequest", + category: "local_storage", + definition: + "export interface HostLocalStorageReadRequest {\n key: string;\n}", + description: "Request to read a local storage value.", + fields: [ + { + name: "key", + type: "string", + description: "Storage key to read.", + }, + ], + }, + { + id: "host-local-storage-read-response", + name: "HostLocalStorageReadResponse", + category: "local_storage", + definition: + "export interface HostLocalStorageReadResponse {\n value?: HexString;\n}", + description: "Response containing an optional local storage value.", + fields: [ + { + name: "value", + type: "HexString | undefined", + description: "Stored value, if present.", + }, + ], + }, + { + id: "host-local-storage-write-request", + name: "HostLocalStorageWriteRequest", + category: "local_storage", + definition: + "export interface HostLocalStorageWriteRequest {\n key: string;\n value: HexString;\n}", + description: "Request to write a value into local storage.", + fields: [ + { + name: "key", + type: "string", + description: "Storage key to write.", + }, + { + name: "value", + type: "HexString", + description: "Value to store at the key.", + }, + ], + }, + { + id: "host-navigate-to-error", + name: "HostNavigateToError", + category: "system", + definition: + 'export type HostNavigateToError =\n | { tag: "PermissionDenied"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: "Error from [`crate::api::System::navigate_to`].", + variants: [ + { + name: "PermissionDenied", + type: '{ tag: "PermissionDenied"; value?: undefined }', + description: "User denied the navigation prompt.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-navigate-to-request", + name: "HostNavigateToRequest", + category: "system", + definition: "export interface HostNavigateToRequest {\n url: string;\n}", + description: "Request to navigate the host to an external URL.", + fields: [ + { + name: "url", + type: "string", + description: "URL to open.", + }, + ], + }, + { + id: "host-payment-balance-subscribe-error", + name: "HostPaymentBalanceSubscribeError", + category: "payment", + definition: + 'export type HostPaymentBalanceSubscribeError =\n | { tag: "PermissionDenied"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: + "Error from [`crate::api::Payment::balance_subscribe`].\n\nSee [RFC 0006].\n\n[RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94", + variants: [ + { + name: "PermissionDenied", + type: '{ tag: "PermissionDenied"; value?: undefined }', + description: "User denied the balance disclosure request.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-payment-balance-subscribe-item", + name: "HostPaymentBalanceSubscribeItem", + category: "payment", + definition: + "export interface HostPaymentBalanceSubscribeItem {\n available: Balance;\n}", + description: + "Current payment balance state pushed to subscribers.\n\nSee [RFC 0006].\n\n[RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94", + fields: [ + { + name: "available", + type: "Balance", + description: "Balance that can be spent right now.", + }, + ], + }, + { + id: "host-payment-balance-subscribe-request", + name: "HostPaymentBalanceSubscribeRequest", + category: "payment", + definition: + "export interface HostPaymentBalanceSubscribeRequest {\n purse?: PaymentPurseId;\n}", + description: "Request to subscribe to payment balance updates.", + fields: [ + { + name: "purse", + type: "PaymentPurseId | undefined", + description: "Optional purse selector. `None` means MAIN_PURSE.", + }, + ], + }, + { + id: "host-payment-error", + name: "HostPaymentError", + category: "payment", + definition: + 'export type HostPaymentError =\n | { tag: "Rejected"; value?: undefined }\n | { tag: "InsufficientBalance"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: + "Error from [`crate::api::Payment::request`].\n\nSee [RFC 0006].\n\n[RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94", + variants: [ + { + name: "Rejected", + type: '{ tag: "Rejected"; value?: undefined }', + description: "User rejected the payment request.", + }, + { + name: "InsufficientBalance", + type: '{ tag: "InsufficientBalance"; value?: undefined }', + description: + "User's available balance is not sufficient for the requested amount.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-payment-request", + name: "HostPaymentRequest", + category: "payment", + definition: + "export interface HostPaymentRequest {\n from?: PaymentPurseId;\n amount: Balance;\n destination: HexString;\n}", + description: "Request to initiate a payment to another account.", + fields: [ + { + name: "from", + type: "PaymentPurseId | undefined", + description: "Optional purse selector. `None` means MAIN_PURSE.", + }, + { + name: "amount", + type: "Balance", + description: "Amount to pay.", + }, + { + name: "destination", + type: "HexString", + description: "Destination account.", + }, + ], + }, + { + id: "host-payment-response", + name: "HostPaymentResponse", + category: "payment", + definition: "export interface HostPaymentResponse {\n id: string;\n}", + description: + "Receipt returned after a successful payment request.\n\nSee [RFC 0006].\n\n[RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94", + fields: [ + { + name: "id", + type: "string", + description: "The assigned payment identifier.", + }, + ], + }, + { + id: "host-payment-status-subscribe-error", + name: "HostPaymentStatusSubscribeError", + category: "payment", + definition: + 'export type HostPaymentStatusSubscribeError =\n | { tag: "PaymentNotFound"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: + "Error from [`crate::api::Payment::status_subscribe`].\n\nSee [RFC 0006].\n\n[RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94", + variants: [ + { + name: "PaymentNotFound", + type: '{ tag: "PaymentNotFound"; value?: undefined }', + description: + "Payment ID was not found or does not belong to the current product.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-payment-status-subscribe-item", + name: "HostPaymentStatusSubscribeItem", + category: "payment", + definition: + 'export type HostPaymentStatusSubscribeItem =\n | { tag: "Processing"; value?: undefined }\n | { tag: "Completed"; value?: undefined }\n | { tag: "Failed"; value: { reason: string } }\n;', + description: + "Payment lifecycle status pushed to subscribers.\n\nOnce a terminal state (`Completed` or `Failed`) is reached, the host\ndelivers it and may close the subscription.\n\nSee [RFC 0006].\n\n[RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94", + variants: [ + { + name: "Processing", + type: '{ tag: "Processing"; value?: undefined }', + description: "Payment is being processed.", + }, + { + name: "Completed", + type: '{ tag: "Completed"; value?: undefined }', + description: "Payment has been settled successfully.", + }, + { + name: "Failed", + type: '{ tag: "Failed"; value: { reason: string } }', + description: "Payment has failed.", + }, + ], + }, + { + id: "host-payment-status-subscribe-request", + name: "HostPaymentStatusSubscribeRequest", + category: "payment", + definition: + "export interface HostPaymentStatusSubscribeRequest {\n paymentId: string;\n}", + description: "Request to subscribe to a payment status.", + fields: [ + { + name: "payment_id", + type: "string", + description: "Payment identifier to watch.", + }, + ], + }, + { + id: "host-payment-top-up-error", + name: "HostPaymentTopUpError", + category: "payment", + definition: + 'export type HostPaymentTopUpError =\n | { tag: "InsufficientFunds"; value?: undefined }\n | { tag: "InvalidSource"; value?: undefined }\n | { tag: "PartialPayment"; value: { credited: Balance } }\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: + "Error from [`crate::api::Payment::top_up`].\n\nSee [RFC 0006].\n\n[RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94", + variants: [ + { + name: "InsufficientFunds", + type: '{ tag: "InsufficientFunds"; value?: undefined }', + description: "The source account does not hold sufficient funds.", + }, + { + name: "InvalidSource", + type: '{ tag: "InvalidSource"; value?: undefined }', + description: "The source account was not found or is invalid.", + }, + { + name: "PartialPayment", + type: '{ tag: "PartialPayment"; value: { credited: Balance } }', + description: + "Some coins were claimed but the total fell short of the requested amount.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-payment-top-up-request", + name: "HostPaymentTopUpRequest", + category: "payment", + definition: + "export interface HostPaymentTopUpRequest {\n into?: PaymentPurseId;\n amount: Balance;\n source: PaymentTopUpSource;\n}", + description: "Request to top up the product payment balance.", + fields: [ + { + name: "into", + type: "PaymentPurseId | undefined", + description: "Optional purse selector. `None` means MAIN_PURSE.", + }, + { + name: "amount", + type: "Balance", + description: "Amount to top up.", + }, + { + name: "source", + type: "PaymentTopUpSource", + description: "Funding source for the top-up.", + }, + ], + }, + { + id: "host-push-notification-cancel-request", + name: "HostPushNotificationCancelRequest", + category: "notifications", + definition: + "export interface HostPushNotificationCancelRequest {\n id: NotificationId;\n}", + description: "Request to cancel a previously scheduled notification.", + fields: [ + { + name: "id", + type: "NotificationId", + description: + "The notification identifier returned by [`HostPushNotificationResponse`].", + }, + ], + }, + { + id: "host-push-notification-error", + name: "HostPushNotificationError", + category: "notifications", + definition: + 'export type HostPushNotificationError =\n | { tag: "ScheduleLimitReached"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: "Push notification error.", + variants: [ + { + name: "ScheduleLimitReached", + type: '{ tag: "ScheduleLimitReached"; value?: undefined }', + description: + "The host-wide queue of pending scheduled notifications is full.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-push-notification-request", + name: "HostPushNotificationRequest", + category: "notifications", + definition: + "export interface HostPushNotificationRequest {\n text: string;\n deeplink?: string;\n scheduledAt?: bigint;\n}", + description: + "Push notification payload.\n\nWhen `scheduled_at` is `Some`, the notification is deferred to the given\nwall-clock instant (Unix milliseconds UTC). `None` fires immediately,\npreserving prior behaviour. See [RFC 0019].\n\n[RFC 0019]: https://github.com/paritytech/truapi/blob/main/docs/rfcs/0019-scheduled-notifications.md", + fields: [ + { + name: "text", + type: "string", + description: "Notification text.", + }, + { + name: "deeplink", + type: "string | undefined", + description: "Optional URL to open on tap.", + }, + { + name: "scheduled_at", + type: "bigint | undefined", + description: + "Optional Unix timestamp in milliseconds (UTC) at which the notification\nshould fire. `None` fires immediately.", + }, + ], + }, + { + id: "host-push-notification-response", + name: "HostPushNotificationResponse", + category: "notifications", + definition: + "export interface HostPushNotificationResponse {\n id: NotificationId;\n}", + description: + "Successful push notification response carrying the assigned id.", + fields: [ + { + name: "id", + type: "NotificationId", + description: "Host-assigned notification identifier.", + }, + ], + }, + { + id: "host-request-login-error", + name: "HostRequestLoginError", + category: "account", + definition: + 'export type HostRequestLoginError =\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: "Login request error.", + variants: [ + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-request-login-request", + name: "HostRequestLoginRequest", + category: "account", + definition: + "export interface HostRequestLoginRequest {\n reason?: string;\n}", + description: "Request to present the host login flow.", + fields: [ + { + name: "reason", + type: "string | undefined", + description: "Optional human-readable reason shown in the login UI.", + }, + ], + }, + { + id: "host-request-login-response", + name: "HostRequestLoginResponse", + category: "account", + definition: + 'export type HostRequestLoginResponse = "Success" | "AlreadyConnected" | "Rejected";', + description: "Result of a login request.", + variants: [ + { + name: "Success", + type: '{ tag: "Success"; value?: undefined }', + description: "User successfully authenticated.", + }, + { + name: "AlreadyConnected", + type: '{ tag: "AlreadyConnected"; value?: undefined }', + description: "User is already authenticated — no action was taken.", + }, + { + name: "Rejected", + type: '{ tag: "Rejected"; value?: undefined }', + description: "User dismissed/rejected the login UI.", + }, + ], + }, + { + id: "host-request-resource-allocation-request", + name: "HostRequestResourceAllocationRequest", + category: "resource_allocation", + definition: + "export interface HostRequestResourceAllocationRequest {\n resources: Array;\n}", + description: "Batched resource pre-allocation request (RFC 0010).", + fields: [ + { + name: "resources", + type: "Array", + description: "Resources to allocate.", + }, + ], + }, + { + id: "host-request-resource-allocation-response", + name: "HostRequestResourceAllocationResponse", + category: "resource_allocation", + definition: + "export interface HostRequestResourceAllocationResponse {\n outcomes: Array;\n}", + description: + "Per-resource outcomes for a batched allocation request (RFC 0010).", + fields: [ + { + name: "outcomes", + type: "Array", + description: + "Per-resource allocation outcomes, in the same order as the request.", + }, + ], + }, + { + id: "host-sign-payload-data", + name: "HostSignPayloadData", + category: "signing", + definition: + "export interface HostSignPayloadData {\n blockHash: HexString;\n blockNumber: HexString;\n era: HexString;\n genesisHash: HexString;\n method: HexString;\n nonce: HexString;\n specVersion: HexString;\n tip: HexString;\n transactionVersion: HexString;\n signedExtensions: Array;\n version: number;\n assetId?: HexString;\n metadataHash?: HexString;\n mode?: number;\n withSignedTransaction?: boolean;\n}", + description: + "Full Substrate extrinsic signing payload with all fields needed for signature\ngeneration.", + fields: [ + { + name: "block_hash", + type: "HexString", + description: "Reference block hash.", + }, + { + name: "block_number", + type: "HexString", + description: "Reference block number.", + }, + { + name: "era", + type: "HexString", + description: "Mortality era encoding.", + }, + { + name: "genesis_hash", + type: "HexString", + description: "Chain genesis hash.", + }, + { + name: "method", + type: "HexString", + description: "SCALE-encoded call data.", + }, + { + name: "nonce", + type: "HexString", + description: "Account nonce.", + }, + { + name: "spec_version", + type: "HexString", + description: "Runtime spec version.", + }, + { + name: "tip", + type: "HexString", + description: "Transaction tip.", + }, + { + name: "transaction_version", + type: "HexString", + description: "Transaction format version.", + }, + { + name: "signed_extensions", + type: "Array", + description: "Extension identifiers.", + }, + { + name: "version", + type: "number", + description: "Extrinsic version.", + }, + { + name: "asset_id", + type: "HexString | undefined", + description: "For multi-asset tips.", + }, + { + name: "metadata_hash", + type: "HexString | undefined", + description: "CheckMetadataHash extension.", + }, + { + name: "mode", + type: "number | undefined", + description: "Metadata mode.", + }, + { + name: "with_signed_transaction", + type: "boolean | undefined", + description: "Request signed transaction back.", + }, + ], + }, + { + id: "host-sign-payload-error", + name: "HostSignPayloadError", + category: "signing", + definition: + 'export type HostSignPayloadError =\n | { tag: "FailedToDecode"; value?: undefined }\n | { tag: "Rejected"; value?: undefined }\n | { tag: "PermissionDenied"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: "Signing operation error.", + variants: [ + { + name: "FailedToDecode", + type: '{ tag: "FailedToDecode"; value?: undefined }', + description: "Payload could not be deserialized.", + }, + { + name: "Rejected", + type: '{ tag: "Rejected"; value?: undefined }', + description: "User rejected signing.", + }, + { + name: "PermissionDenied", + type: '{ tag: "PermissionDenied"; value?: undefined }', + description: "Not authenticated.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "host-sign-payload-request", + name: "HostSignPayloadRequest", + category: "signing", + definition: + "export interface HostSignPayloadRequest {\n account: ProductAccountId;\n payload: HostSignPayloadData;\n}", + description: "Request to sign an extrinsic payload with a product account.", + fields: [ + { + name: "account", + type: "ProductAccountId", + description: "Product account that will sign this payload.", + }, + { + name: "payload", + type: "HostSignPayloadData", + description: "The extrinsic payload to sign.", + }, + ], + }, + { + id: "host-sign-payload-response", + name: "HostSignPayloadResponse", + category: "signing", + definition: + "export interface HostSignPayloadResponse {\n signature: HexString;\n signedTransaction?: HexString;\n}", + description: "Result of a signing operation.", + fields: [ + { + name: "signature", + type: "HexString", + description: "The cryptographic signature.", + }, + { + name: "signed_transaction", + type: "HexString | undefined", + description: "Full signed transaction, if requested.", + }, + ], + }, + { + id: "host-sign-payload-with-legacy-account-request", + name: "HostSignPayloadWithLegacyAccountRequest", + category: "signing", + definition: + "export interface HostSignPayloadWithLegacyAccountRequest {\n signer: string;\n payload: HostSignPayloadData;\n}", + description: + "Sign a Substrate extrinsic payload with a non-product (legacy) account.\nContains the same fields as [`HostSignPayloadRequest`] minus `address`\n(replaced by `signer`).", + fields: [ + { + name: "signer", + type: "string", + description: "Signer address (SS58 or hex) of the legacy account.", + }, + { + name: "payload", + type: "HostSignPayloadData", + description: "The extrinsic payload to sign.", + }, + ], + }, + { + id: "host-sign-raw-request", + name: "HostSignRawRequest", + category: "signing", + definition: + "export interface HostSignRawRequest {\n account: ProductAccountId;\n payload: RawPayload;\n}", + description: + "A raw signing request pairing an account with the payload to sign.", + fields: [ + { + name: "account", + type: "ProductAccountId", + description: "Product account that will sign this payload.", + }, + { + name: "payload", + type: "RawPayload", + description: "The payload to sign.", + }, + ], + }, + { + id: "host-sign-raw-with-legacy-account-request", + name: "HostSignRawWithLegacyAccountRequest", + category: "signing", + definition: + "export interface HostSignRawWithLegacyAccountRequest {\n signer: string;\n payload: RawPayload;\n}", + description: + "Sign raw bytes with a non-product (legacy) account. The signer field\nidentifies which legacy account to use.", + fields: [ + { + name: "signer", + type: "string", + description: "Signer address (SS58 or hex) of the legacy account.", + }, + { + name: "payload", + type: "RawPayload", + description: "The data to sign.", + }, + ], + }, + { + id: "host-theme-subscribe-item", + name: "HostThemeSubscribeItem", + category: "theme", + definition: + "export interface HostThemeSubscribeItem {\n name: ThemeName;\n variant: ThemeVariant;\n}", + description: "Current theme state pushed to subscribers.", + fields: [ + { + name: "name", + type: "ThemeName", + description: "Theme name.", + }, + { + name: "variant", + type: "ThemeVariant", + description: "Light or dark variant.", + }, + ], + }, + { + id: "legacy-account", + name: "LegacyAccount", + category: "account", + definition: + "export interface LegacyAccount {\n publicKey: HexString;\n name?: string;\n}", + description: + "A user-imported (legacy) account: public key plus an optional user-chosen\ndisplay name.\n\nReturned by [`HostGetLegacyAccountsResponse`]. Distinct from\n[`ProductAccount`], which is protocol-derived and never carries a label.", + fields: [ + { + name: "public_key", + type: "HexString", + description: "The account public key (variable-length bytes).", + }, + { + name: "name", + type: "string | undefined", + description: "Optional user-chosen display name.", + }, + ], + }, + { + id: "legacy-account-tx-payload", + name: "LegacyAccountTxPayload", + category: "transaction", + definition: + "export interface LegacyAccountTxPayload {\n signer: AccountId;\n genesisHash: GenesisHash;\n callData: HexString;\n extensions: Array;\n txExtVersion: number;\n}", + description: + "Transaction payload for a legacy (non-product) account.\n\nIdentical to [`ProductAccountTxPayload`] except the signer is a raw\n32-byte [`AccountId`].", + fields: [ + { + name: "signer", + type: "AccountId", + description: "Raw 32-byte public key of the legacy account.", + }, + { + name: "genesis_hash", + type: "GenesisHash", + description: "Chain where the transaction will execute.", + }, + { + name: "call_data", + type: "HexString", + description: "SCALE-encoded Call data.", + }, + { + name: "extensions", + type: "Array", + description: "Transaction extensions supplied by the caller.", + }, + { + name: "tx_ext_version", + type: "number", + description: "0 for Extrinsic V4, runtime-supported value for V5.", + }, + ], + }, + { + id: "modifier", + name: "Modifier", + category: "chat", + definition: + 'export type Modifier =\n | { tag: "Margin"; value: Dimensions }\n | { tag: "Padding"; value: Dimensions }\n | { tag: "Background"; value: Background }\n | { tag: "Border"; value: BorderStyle }\n | { tag: "Height"; value: { height: Size } }\n | { tag: "Width"; value: { width: Size } }\n | { tag: "MinWidth"; value: { width: Size } }\n | { tag: "MinHeight"; value: { height: Size } }\n | { tag: "FillWidth"; value: { enabled: boolean } }\n | { tag: "FillHeight"; value: { enabled: boolean } }\n;', + description: + "Layout and styling modifiers applied to custom renderer components.", + variants: [ + { + name: "Margin", + type: '{ tag: "Margin"; value: Dimensions }', + description: "Outer spacing.", + }, + { + name: "Padding", + type: '{ tag: "Padding"; value: Dimensions }', + description: "Inner spacing.", + }, + { + name: "Background", + type: '{ tag: "Background"; value: Background }', + description: "Background fill.", + }, + { + name: "Border", + type: '{ tag: "Border"; value: BorderStyle }', + description: "Border style.", + }, + { + name: "Height", + type: '{ tag: "Height"; value: { height: Size } }', + description: "Fixed height.", + }, + { + name: "Width", + type: '{ tag: "Width"; value: { width: Size } }', + description: "Fixed width.", + }, + { + name: "MinWidth", + type: '{ tag: "MinWidth"; value: { width: Size } }', + description: "Minimum width.", + }, + { + name: "MinHeight", + type: '{ tag: "MinHeight"; value: { height: Size } }', + description: "Minimum height.", + }, + { + name: "FillWidth", + type: '{ tag: "FillWidth"; value: { enabled: boolean } }', + description: "Fill available width.", + }, + { + name: "FillHeight", + type: '{ tag: "FillHeight"; value: { enabled: boolean } }', + description: "Fill available height.", + }, + ], + }, + { + id: "notification-id", + name: "NotificationId", + category: "notifications", + definition: "export type NotificationId = number;", + description: + "Opaque identifier for a push notification, unique per product.", + }, + { + id: "operation-started-result", + name: "OperationStartedResult", + category: "chain", + definition: + 'export type OperationStartedResult =\n | { tag: "Started"; value: { operationId: string } }\n | { tag: "LimitReached"; value?: undefined }\n;', + variants: [ + { + name: "Started", + type: '{ tag: "Started"; value: { operationId: string } }', + }, + { + name: "LimitReached", + type: '{ tag: "LimitReached"; value?: undefined }', + }, + ], + }, + { + id: "payment-purse-id", + name: "PaymentPurseId", + category: "payment", + definition: "export type PaymentPurseId = number;", + description: "Identifier selecting a product payment purse.", + }, + { + id: "payment-top-up-source", + name: "PaymentTopUpSource", + category: "payment", + definition: + 'export type PaymentTopUpSource =\n | { tag: "ProductAccount"; value: { derivationIndex: number } }\n | { tag: "PrivateKey"; value: { sr25519SecretKey: HexString } }\n | { tag: "Coins"; value: { sr25519SecretKeys: Array } }\n;', + description: + "Source for a payment top-up operation.\n\nSee [RFC 0006].\n\n[RFC 0006]: https://github.com/paritytech/triangle-js-sdks/pull/94", + variants: [ + { + name: "ProductAccount", + type: '{ tag: "ProductAccount"; value: { derivationIndex: number } }', + description: "Fund from one of the calling product's scoped accounts.", + }, + { + name: "PrivateKey", + type: '{ tag: "PrivateKey"; value: { sr25519SecretKey: HexString } }', + description: + "Fund from a one-time account represented by its private key. This is a\nstandard account holding public funds, not a coin key.", + }, + { + name: "Coins", + type: '{ tag: "Coins"; value: { sr25519SecretKeys: Array } }', + description: + "Fund directly from coin secret keys. Each key is an sr25519 secret\ncontrolling a single coin.", + }, + ], + }, + { + id: "preimage-submit-error", + name: "PreimageSubmitError", + category: "preimage", + definition: + 'export type PreimageSubmitError =\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: "Preimage submission error.", + variants: [ + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "product-account", + name: "ProductAccount", + category: "account", + definition: "export interface ProductAccount {\n publicKey: HexString;\n}", + description: "A product account: public key only, no display name.", + fields: [ + { + name: "public_key", + type: "HexString", + description: "The account public key (variable-length bytes).", + }, + ], + }, + { + id: "product-account-id", + name: "ProductAccountId", + category: "account", + definition: + "export interface ProductAccountId {\n dotNsIdentifier: string;\n derivationIndex: number;\n}", + description: + "Identifies a product-specific account by combining a dotNS domain name with a\nderivation index.", + fields: [ + { + name: "dot_ns_identifier", + type: "string", + description: + 'A dotNS domain name identifier (e.g., `"my-product.dot"`).', + }, + { + name: "derivation_index", + type: "number", + description: + "Key derivation index for generating product-specific accounts.", + }, + ], + }, + { + id: "product-account-tx-payload", + name: "ProductAccountTxPayload", + category: "transaction", + definition: + "export interface ProductAccountTxPayload {\n signer: ProductAccountId;\n genesisHash: GenesisHash;\n callData: HexString;\n extensions: Array;\n txExtVersion: number;\n}", + description: + "Transaction payload for a product account.\n\nContains everything the host needs to construct a signed extrinsic.\nThe signer is a [`ProductAccountId`]; the host resolves the\ncorresponding key pair through its account management layer.", + fields: [ + { + name: "signer", + type: "ProductAccountId", + description: "Product account that will sign the transaction.", + }, + { + name: "genesis_hash", + type: "GenesisHash", + description: "Chain where the transaction will execute.", + }, + { + name: "call_data", + type: "HexString", + description: "SCALE-encoded Call data.", + }, + { + name: "extensions", + type: "Array", + description: "Transaction extensions supplied by the caller.", + }, + { + name: "tx_ext_version", + type: "number", + description: "0 for Extrinsic V4, runtime-supported value for V5.", + }, + ], + }, + { + id: "product-chat-custom-message-render-subscribe-request", + name: "ProductChatCustomMessageRenderSubscribeRequest", + category: "chat", + definition: + "export interface ProductChatCustomMessageRenderSubscribeRequest {\n messageId: string;\n messageType: string;\n payload: HexString;\n}", + description: + "Subscribe payload identifying the chat message to render. The host responds\nwith a stream of [`CustomRendererNode`] trees describing the rendered UI.", + fields: [ + { + name: "message_id", + type: "string", + description: "Message identifier.", + }, + { + name: "message_type", + type: "string", + description: "Application-defined message type.", + }, + { + name: "payload", + type: "HexString", + description: "Binary payload.", + }, + ], + }, + { + id: "raw-payload", + name: "RawPayload", + category: "signing", + definition: + 'export type RawPayload =\n | { tag: "Bytes"; value: { bytes: HexString } }\n | { tag: "Payload"; value: { payload: string } }\n;', + description: "Raw data to sign -- either binary bytes or a string message.", + variants: [ + { + name: "Bytes", + type: '{ tag: "Bytes"; value: { bytes: HexString } }', + description: "Raw binary data to sign.", + }, + { + name: "Payload", + type: '{ tag: "Payload"; value: { payload: string } }', + description: "String message to sign.", + }, + ], + }, + { + id: "remote-chain-head-body-request", + name: "RemoteChainHeadBodyRequest", + category: "chain", + definition: + "export interface RemoteChainHeadBodyRequest {\n genesisHash: HexString;\n followSubscriptionId: string;\n hash: HexString;\n}", + fields: [ + { + name: "genesis_hash", + type: "HexString", + description: "Chain genesis hash.", + }, + { + name: "follow_subscription_id", + type: "string", + description: "Follow subscription identifier.", + }, + { + name: "hash", + type: "HexString", + description: "Block hash.", + }, + ], + }, + { + id: "remote-chain-head-body-response", + name: "RemoteChainHeadBodyResponse", + category: "chain", + definition: + "export interface RemoteChainHeadBodyResponse {\n operation: OperationStartedResult;\n}", + fields: [ + { + name: "operation", + type: "OperationStartedResult", + description: "Started operation result.", + }, + ], + }, + { + id: "remote-chain-head-call-request", + name: "RemoteChainHeadCallRequest", + category: "chain", + definition: + "export interface RemoteChainHeadCallRequest {\n genesisHash: HexString;\n followSubscriptionId: string;\n hash: HexString;\n function: string;\n callParameters: HexString;\n}", + fields: [ + { + name: "genesis_hash", + type: "HexString", + description: "Chain genesis hash.", + }, + { + name: "follow_subscription_id", + type: "string", + description: "Follow subscription identifier.", + }, + { + name: "hash", + type: "HexString", + description: "Block hash.", + }, + { + name: "function", + type: "string", + description: "Runtime API function name.", + }, + { + name: "call_parameters", + type: "HexString", + description: "SCALE-encoded call parameters.", + }, + ], + }, + { + id: "remote-chain-head-call-response", + name: "RemoteChainHeadCallResponse", + category: "chain", + definition: + "export interface RemoteChainHeadCallResponse {\n operation: OperationStartedResult;\n}", + fields: [ + { + name: "operation", + type: "OperationStartedResult", + description: "Started operation result.", + }, + ], + }, + { + id: "remote-chain-head-continue-request", + name: "RemoteChainHeadContinueRequest", + category: "chain", + definition: + "export interface RemoteChainHeadContinueRequest {\n genesisHash: HexString;\n followSubscriptionId: string;\n operationId: string;\n}", + fields: [ + { + name: "genesis_hash", + type: "HexString", + description: "Chain genesis hash.", + }, + { + name: "follow_subscription_id", + type: "string", + description: "Follow subscription identifier.", + }, + { + name: "operation_id", + type: "string", + description: "Operation identifier.", + }, + ], + }, + { + id: "remote-chain-head-follow-item", + name: "RemoteChainHeadFollowItem", + category: "chain", + definition: + 'export type RemoteChainHeadFollowItem =\n | { tag: "Initialized"; value: { finalizedBlockHashes: Array; finalizedBlockRuntime?: RuntimeType } }\n | { tag: "NewBlock"; value: { blockHash: HexString; parentBlockHash: HexString; newRuntime?: RuntimeType } }\n | { tag: "BestBlockChanged"; value: { bestBlockHash: HexString } }\n | { tag: "Finalized"; value: { finalizedBlockHashes: Array; prunedBlockHashes: Array } }\n | { tag: "OperationBodyDone"; value: { operationId: string; value: Array } }\n | { tag: "OperationCallDone"; value: { operationId: string; output: HexString } }\n | { tag: "OperationStorageItems"; value: { operationId: string; items: Array } }\n | { tag: "OperationStorageDone"; value: { operationId: string } }\n | { tag: "OperationWaitingForContinue"; value: { operationId: string } }\n | { tag: "OperationInaccessible"; value: { operationId: string } }\n | { tag: "OperationError"; value: { operationId: string; error: string } }\n | { tag: "Stop"; value?: undefined }\n;', + variants: [ + { + name: "Initialized", + type: '{ tag: "Initialized"; value: { finalizedBlockHashes: Array; finalizedBlockRuntime?: RuntimeType } }', + }, + { + name: "NewBlock", + type: '{ tag: "NewBlock"; value: { blockHash: HexString; parentBlockHash: HexString; newRuntime?: RuntimeType } }', + }, + { + name: "BestBlockChanged", + type: '{ tag: "BestBlockChanged"; value: { bestBlockHash: HexString } }', + }, + { + name: "Finalized", + type: '{ tag: "Finalized"; value: { finalizedBlockHashes: Array; prunedBlockHashes: Array } }', + }, + { + name: "OperationBodyDone", + type: '{ tag: "OperationBodyDone"; value: { operationId: string; value: Array } }', + }, + { + name: "OperationCallDone", + type: '{ tag: "OperationCallDone"; value: { operationId: string; output: HexString } }', + }, + { + name: "OperationStorageItems", + type: '{ tag: "OperationStorageItems"; value: { operationId: string; items: Array } }', + }, + { + name: "OperationStorageDone", + type: '{ tag: "OperationStorageDone"; value: { operationId: string } }', + }, + { + name: "OperationWaitingForContinue", + type: '{ tag: "OperationWaitingForContinue"; value: { operationId: string } }', + }, + { + name: "OperationInaccessible", + type: '{ tag: "OperationInaccessible"; value: { operationId: string } }', + }, + { + name: "OperationError", + type: '{ tag: "OperationError"; value: { operationId: string; error: string } }', + }, + { + name: "Stop", + type: '{ tag: "Stop"; value?: undefined }', + }, + ], + }, + { + id: "remote-chain-head-follow-request", + name: "RemoteChainHeadFollowRequest", + category: "chain", + definition: + "export interface RemoteChainHeadFollowRequest {\n genesisHash: HexString;\n withRuntime: boolean;\n}", + fields: [ + { + name: "genesis_hash", + type: "HexString", + description: "Chain genesis hash.", + }, + { + name: "with_runtime", + type: "boolean", + description: "Whether to include runtime information in events.", + }, + ], + }, + { + id: "remote-chain-head-header-request", + name: "RemoteChainHeadHeaderRequest", + category: "chain", + definition: + "export interface RemoteChainHeadHeaderRequest {\n genesisHash: HexString;\n followSubscriptionId: string;\n hash: HexString;\n}", + fields: [ + { + name: "genesis_hash", + type: "HexString", + description: "Chain genesis hash.", + }, + { + name: "follow_subscription_id", + type: "string", + description: "Follow subscription identifier.", + }, + { + name: "hash", + type: "HexString", + description: "Block hash.", + }, + ], + }, + { + id: "remote-chain-head-header-response", + name: "RemoteChainHeadHeaderResponse", + category: "chain", + definition: + "export interface RemoteChainHeadHeaderResponse {\n header?: HexString;\n}", + fields: [ + { + name: "header", + type: "HexString | undefined", + description: "SCALE-encoded block header.", + }, + ], + }, + { + id: "remote-chain-head-stop-operation-request", + name: "RemoteChainHeadStopOperationRequest", + category: "chain", + definition: + "export interface RemoteChainHeadStopOperationRequest {\n genesisHash: HexString;\n followSubscriptionId: string;\n operationId: string;\n}", + fields: [ + { + name: "genesis_hash", + type: "HexString", + description: "Chain genesis hash.", + }, + { + name: "follow_subscription_id", + type: "string", + description: "Follow subscription identifier.", + }, + { + name: "operation_id", + type: "string", + description: "Operation identifier.", + }, + ], + }, + { + id: "remote-chain-head-storage-request", + name: "RemoteChainHeadStorageRequest", + category: "chain", + definition: + "export interface RemoteChainHeadStorageRequest {\n genesisHash: HexString;\n followSubscriptionId: string;\n hash: HexString;\n items: Array;\n childTrie?: HexString;\n}", + fields: [ + { + name: "genesis_hash", + type: "HexString", + description: "Chain genesis hash.", + }, + { + name: "follow_subscription_id", + type: "string", + description: "Follow subscription identifier.", + }, + { + name: "hash", + type: "HexString", + description: "Block hash.", + }, + { + name: "items", + type: "Array", + description: "Storage items to query.", + }, + { + name: "child_trie", + type: "HexString | undefined", + description: "Optional child trie.", + }, + ], + }, + { + id: "remote-chain-head-storage-response", + name: "RemoteChainHeadStorageResponse", + category: "chain", + definition: + "export interface RemoteChainHeadStorageResponse {\n operation: OperationStartedResult;\n}", + fields: [ + { + name: "operation", + type: "OperationStartedResult", + description: "Started operation result.", + }, + ], + }, + { + id: "remote-chain-head-unpin-request", + name: "RemoteChainHeadUnpinRequest", + category: "chain", + definition: + "export interface RemoteChainHeadUnpinRequest {\n genesisHash: HexString;\n followSubscriptionId: string;\n hashes: Array;\n}", + fields: [ + { + name: "genesis_hash", + type: "HexString", + description: "Chain genesis hash.", + }, + { + name: "follow_subscription_id", + type: "string", + description: "Follow subscription identifier.", + }, + { + name: "hashes", + type: "Array", + description: "Block hashes to unpin.", + }, + ], + }, + { + id: "remote-chain-spec-chain-name-request", + name: "RemoteChainSpecChainNameRequest", + category: "chain", + definition: + "export interface RemoteChainSpecChainNameRequest {\n genesisHash: HexString;\n}", + fields: [ + { + name: "genesis_hash", + type: "HexString", + description: "Chain genesis hash.", + }, + ], + }, + { + id: "remote-chain-spec-chain-name-response", + name: "RemoteChainSpecChainNameResponse", + category: "chain", + definition: + "export interface RemoteChainSpecChainNameResponse {\n chainName: string;\n}", + fields: [ + { + name: "chain_name", + type: "string", + description: "Chain display name.", + }, + ], + }, + { + id: "remote-chain-spec-genesis-hash-request", + name: "RemoteChainSpecGenesisHashRequest", + category: "chain", + definition: + "export interface RemoteChainSpecGenesisHashRequest {\n genesisHash: HexString;\n}", + fields: [ + { + name: "genesis_hash", + type: "HexString", + description: "Chain genesis hash requested by the product.", + }, + ], + }, + { + id: "remote-chain-spec-genesis-hash-response", + name: "RemoteChainSpecGenesisHashResponse", + category: "chain", + definition: + "export interface RemoteChainSpecGenesisHashResponse {\n genesisHash: HexString;\n}", + fields: [ + { + name: "genesis_hash", + type: "HexString", + description: "Chain genesis hash.", + }, + ], + }, + { + id: "remote-chain-spec-properties-request", + name: "RemoteChainSpecPropertiesRequest", + category: "chain", + definition: + "export interface RemoteChainSpecPropertiesRequest {\n genesisHash: HexString;\n}", + fields: [ + { + name: "genesis_hash", + type: "HexString", + description: "Chain genesis hash.", + }, + ], + }, + { + id: "remote-chain-spec-properties-response", + name: "RemoteChainSpecPropertiesResponse", + category: "chain", + definition: + "export interface RemoteChainSpecPropertiesResponse {\n properties: string;\n}", + fields: [ + { + name: "properties", + type: "string", + description: "JSON-encoded properties.", + }, + ], + }, + { + id: "remote-chain-transaction-broadcast-request", + name: "RemoteChainTransactionBroadcastRequest", + category: "chain", + definition: + "export interface RemoteChainTransactionBroadcastRequest {\n genesisHash: HexString;\n transaction: HexString;\n}", + fields: [ + { + name: "genesis_hash", + type: "HexString", + description: "Chain genesis hash.", + }, + { + name: "transaction", + type: "HexString", + description: "Signed transaction bytes.", + }, + ], + }, + { + id: "remote-chain-transaction-broadcast-response", + name: "RemoteChainTransactionBroadcastResponse", + category: "chain", + definition: + "export interface RemoteChainTransactionBroadcastResponse {\n operationId?: string;\n}", + fields: [ + { + name: "operation_id", + type: "string | undefined", + description: "Broadcast operation identifier, if available.", + }, + ], + }, + { + id: "remote-chain-transaction-stop-request", + name: "RemoteChainTransactionStopRequest", + category: "chain", + definition: + "export interface RemoteChainTransactionStopRequest {\n genesisHash: HexString;\n operationId: string;\n}", + fields: [ + { + name: "genesis_hash", + type: "HexString", + description: "Chain genesis hash.", + }, + { + name: "operation_id", + type: "string", + description: "Operation identifier of the broadcast to stop.", + }, + ], + }, + { + id: "remote-permission", + name: "RemotePermission", + category: "permissions", + definition: + 'export type RemotePermission =\n | { tag: "Remote"; value: { domains: Array } }\n | { tag: "WebRtc"; value?: undefined }\n | { tag: "ChainSubmit"; value?: undefined }\n | { tag: "PreimageSubmit"; value?: undefined }\n | { tag: "StatementSubmit"; value?: undefined }\n;', + description: + "One remote-operation permission requested by the product (RFC 0002).\n\n`ChainSubmit`, `PreimageSubmit`, and `StatementSubmit` are also triggered\nimplicitly by the corresponding business calls when not yet granted.", + variants: [ + { + name: "Remote", + type: '{ tag: "Remote"; value: { domains: Array } }', + description: "Outbound HTTP/WebSocket access to a set of domains.", + }, + { + name: "WebRtc", + type: '{ tag: "WebRtc"; value?: undefined }', + description: "WebRTC media access.", + }, + { + name: "ChainSubmit", + type: '{ tag: "ChainSubmit"; value?: undefined }', + description: + "Submitting transactions on behalf of the user via `remote_chain_transaction_broadcast`.", + }, + { + name: "PreimageSubmit", + type: '{ tag: "PreimageSubmit"; value?: undefined }', + description: + "Submitting preimages on behalf of the user via `remote_preimage_submit`.", + }, + { + name: "StatementSubmit", + type: '{ tag: "StatementSubmit"; value?: undefined }', + description: + "Submitting statements on behalf of the user via `remote_statement_store_submit`.", + }, + ], + }, + { + id: "remote-permission-request", + name: "RemotePermissionRequest", + category: "permissions", + definition: + "export interface RemotePermissionRequest {\n permission: RemotePermission;\n}", + description: "remote-permission request (RFC 0002).", + fields: [ + { + name: "permission", + type: "RemotePermission", + description: "Permission requested by the product.", + }, + ], + }, + { + id: "remote-permission-response", + name: "RemotePermissionResponse", + category: "permissions", + definition: + "export interface RemotePermissionResponse {\n granted: boolean;\n}", + description: "Outcome of a remote-permission request.", + fields: [ + { + name: "granted", + type: "boolean", + description: "Whether the permission was granted.", + }, + ], + }, + { + id: "remote-preimage-lookup-subscribe-item", + name: "RemotePreimageLookupSubscribeItem", + category: "preimage", + definition: + "export interface RemotePreimageLookupSubscribeItem {\n value?: HexString;\n}", + description: "Item containing an optional preimage lookup result.", + fields: [ + { + name: "value", + type: "HexString | undefined", + description: "Preimage data, if found.", + }, + ], + }, + { + id: "remote-preimage-lookup-subscribe-request", + name: "RemotePreimageLookupSubscribeRequest", + category: "preimage", + definition: + "export interface RemotePreimageLookupSubscribeRequest {\n key: HexString;\n}", + description: "Request to subscribe to preimage lookup results.", + fields: [ + { + name: "key", + type: "HexString", + description: "Hash of the preimage.", + }, + ], + }, + { + id: "remote-statement-store-create-proof-error", + name: "RemoteStatementStoreCreateProofError", + category: "statement_store", + definition: + 'export type RemoteStatementStoreCreateProofError =\n | { tag: "UnableToSign"; value?: undefined }\n | { tag: "UnknownAccount"; value?: undefined }\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: "Statement proof creation error.", + variants: [ + { + name: "UnableToSign", + type: '{ tag: "UnableToSign"; value?: undefined }', + description: "Signing operation failed.", + }, + { + name: "UnknownAccount", + type: '{ tag: "UnknownAccount"; value?: undefined }', + description: "Account not recognized.", + }, + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "remote-statement-store-create-proof-request", + name: "RemoteStatementStoreCreateProofRequest", + category: "statement_store", + definition: + "export interface RemoteStatementStoreCreateProofRequest {\n productAccountId: ProductAccountId;\n statement: Statement;\n}", + description: "Request to create a cryptographic proof for a statement.", + fields: [ + { + name: "product_account_id", + type: "ProductAccountId", + description: "Product account that should create the proof.", + }, + { + name: "statement", + type: "Statement", + description: "Statement to prove.", + }, + ], + }, + { + id: "remote-statement-store-create-proof-response", + name: "RemoteStatementStoreCreateProofResponse", + category: "statement_store", + definition: + "export interface RemoteStatementStoreCreateProofResponse {\n proof: StatementProof;\n}", + description: "Response containing a statement proof.", + fields: [ + { + name: "proof", + type: "StatementProof", + description: "Created statement proof.", + }, + ], + }, + { + id: "remote-statement-store-subscribe-item", + name: "RemoteStatementStoreSubscribeItem", + category: "statement_store", + definition: + "export interface RemoteStatementStoreSubscribeItem {\n statements: Array;\n isComplete: boolean;\n}", + description: + "Page of signed statements delivered by the statement store subscription\n(RFC 0008). The `is_complete` flag distinguishes the historical-dump phase\n(`false`) from the live-update phase (`true`).", + fields: [ + { + name: "statements", + type: "Array", + description: "Signed statements matching the subscription.", + }, + { + name: "is_complete", + type: "boolean", + description: + "`false` while the host is still streaming the historical dump (more\npages to follow). `true` once the dump is complete; all subsequent\npages are also `true` and carry only newly-arrived statements.", + }, + ], + }, + { + id: "remote-statement-store-subscribe-request", + name: "RemoteStatementStoreSubscribeRequest", + category: "statement_store", + definition: + 'export type RemoteStatementStoreSubscribeRequest =\n | { tag: "MatchAll"; value: Array }\n | { tag: "MatchAny"; value: Array }\n;', + description: + "Request to subscribe to statements via a topic filter (RFC 0008).", + variants: [ + { + name: "MatchAll", + type: '{ tag: "MatchAll"; value: Array }', + description: "AND: statement must contain every listed topic.", + }, + { + name: "MatchAny", + type: '{ tag: "MatchAny"; value: Array }', + description: "OR: statement must contain at least one listed topic.", + }, + ], + }, + { + id: "resource-allocation-error", + name: "ResourceAllocationError", + category: "resource_allocation", + definition: + 'export type ResourceAllocationError =\n | { tag: "Unknown"; value: { reason: string } }\n;', + description: "Error from [`crate::api::ResourceAllocation::request`].", + variants: [ + { + name: "Unknown", + type: '{ tag: "Unknown"; value: { reason: string } }', + description: "Catch-all.", + }, + ], + }, + { + id: "ring-location", + name: "RingLocation", + category: "account", + definition: + "export interface RingLocation {\n genesisHash: HexString;\n ringRootHash: HexString;\n hints?: RingLocationHint;\n}", + description: + "Locates a specific ring on a specific chain for ring VRF operations.", + fields: [ + { + name: "genesis_hash", + type: "HexString", + description: "Chain genesis hash.", + }, + { + name: "ring_root_hash", + type: "HexString", + description: "Root hash of the ring.", + }, + { + name: "hints", + type: "RingLocationHint | undefined", + description: "Optional location hints.", + }, + ], + }, + { + id: "ring-location-hint", + name: "RingLocationHint", + category: "account", + definition: + "export interface RingLocationHint {\n palletInstance?: number;\n}", + description: "Hints for locating a ring on-chain.", + fields: [ + { + name: "pallet_instance", + type: "number | undefined", + description: "Optional pallet instance index.", + }, + ], + }, + { + id: "row-props", + name: "RowProps", + category: "chat", + definition: + "export interface RowProps {\n verticalAlignment?: VerticalAlignment;\n horizontalArrangement?: Arrangement;\n}", + description: "Properties for a [`CustomRendererNode::Row`] layout.", + fields: [ + { + name: "vertical_alignment", + type: "VerticalAlignment | undefined", + description: "Vertical alignment of children.", + }, + { + name: "horizontal_arrangement", + type: "Arrangement | undefined", + description: "Horizontal arrangement of children.", + }, + ], + }, + { + id: "runtime-api", + name: "RuntimeApi", + category: "chain", + definition: + "export interface RuntimeApi {\n name: string;\n version: number;\n}", + fields: [ + { + name: "name", + type: "string", + description: "Runtime API name.", + }, + { + name: "version", + type: "number", + description: "Runtime API version.", + }, + ], + }, + { + id: "runtime-spec", + name: "RuntimeSpec", + category: "chain", + definition: + "export interface RuntimeSpec {\n specName: string;\n implName: string;\n specVersion: number;\n implVersion: number;\n transactionVersion?: number;\n apis: Array;\n}", + fields: [ + { + name: "spec_name", + type: "string", + description: "Specification name.", + }, + { + name: "impl_name", + type: "string", + description: "Implementation name.", + }, + { + name: "spec_version", + type: "number", + description: "Spec version number.", + }, + { + name: "impl_version", + type: "number", + description: "Implementation version.", + }, + { + name: "transaction_version", + type: "number | undefined", + description: "Transaction format version.", + }, + { + name: "apis", + type: "Array", + description: "Supported runtime APIs.", + }, + ], + }, + { + id: "runtime-type", + name: "RuntimeType", + category: "chain", + definition: + 'export type RuntimeType =\n | { tag: "Valid"; value: RuntimeSpec }\n | { tag: "Invalid"; value: { error: string } }\n;', + variants: [ + { + name: "Valid", + type: '{ tag: "Valid"; value: RuntimeSpec }', + }, + { + name: "Invalid", + type: '{ tag: "Invalid"; value: { error: string } }', + }, + ], + }, + { + id: "shape", + name: "Shape", + category: "chat", + definition: + 'export type Shape =\n | { tag: "Rounded"; value: { radius: Size } }\n | { tag: "Circle"; value?: undefined }\n;', + description: "Shape for borders and backgrounds.", + variants: [ + { + name: "Rounded", + type: '{ tag: "Rounded"; value: { radius: Size } }', + description: "Border radius value.", + }, + { + name: "Circle", + type: '{ tag: "Circle"; value?: undefined }', + description: "Circular shape.", + }, + ], + }, + { + id: "signed-statement", + name: "SignedStatement", + category: "statement_store", + definition: + "export interface SignedStatement {\n proof: StatementProof;\n decryptionKey?: HexString;\n expiry?: bigint;\n channel?: HexString;\n topics: Array;\n data?: HexString;\n}", + description: "A statement with a required (not optional) proof.", + fields: [ + { + name: "proof", + type: "StatementProof", + description: "Required cryptographic proof.", + }, + { + name: "decryption_key", + type: "HexString | undefined", + description: "Optional decryption key.", + }, + { + name: "expiry", + type: "bigint | undefined", + description: "Optional Unix timestamp expiry.", + }, + { + name: "channel", + type: "HexString | undefined", + description: "Optional channel.", + }, + { + name: "topics", + type: "Array", + description: "[u8; 32] tags.", + }, + { + name: "data", + type: "HexString | undefined", + description: "Optional data payload.", + }, + ], + }, + { + id: "size", + name: "Size", + category: "chat", + definition: "export type Size = number | bigint;", + description: + "A size/dimension value (logical pixels) used across the custom renderer.\n\nEncoded as a SCALE `Compact`: the common small values cost a single\nbyte on the wire instead of eight.", + }, + { + id: "statement", + name: "Statement", + category: "statement_store", + definition: + "export interface Statement {\n proof?: StatementProof;\n decryptionKey?: HexString;\n expiry?: bigint;\n channel?: HexString;\n topics: Array;\n data?: HexString;\n}", + description: "A statement with optional proof and metadata.", + fields: [ + { + name: "proof", + type: "StatementProof | undefined", + description: "Optional cryptographic proof.", + }, + { + name: "decryption_key", + type: "HexString | undefined", + description: "Optional decryption key.", + }, + { + name: "expiry", + type: "bigint | undefined", + description: "Optional Unix timestamp expiry.", + }, + { + name: "channel", + type: "HexString | undefined", + description: "Optional channel.", + }, + { + name: "topics", + type: "Array", + description: "[u8; 32] tags.", + }, + { + name: "data", + type: "HexString | undefined", + description: "Optional data payload.", + }, + ], + }, + { + id: "statement-proof", + name: "StatementProof", + category: "statement_store", + definition: + 'export type StatementProof =\n | { tag: "Sr25519"; value: { signature: HexString; signer: HexString } }\n | { tag: "Ed25519"; value: { signature: HexString; signer: HexString } }\n | { tag: "Ecdsa"; value: { signature: HexString; signer: HexString } }\n | { tag: "OnChain"; value: { who: HexString; blockHash: HexString; event: bigint } }\n;', + description: "Cryptographic proof for a statement.", + variants: [ + { + name: "Sr25519", + type: '{ tag: "Sr25519"; value: { signature: HexString; signer: HexString } }', + description: "Sr25519 signature proof.", + }, + { + name: "Ed25519", + type: '{ tag: "Ed25519"; value: { signature: HexString; signer: HexString } }', + description: "Ed25519 signature proof.", + }, + { + name: "Ecdsa", + type: '{ tag: "Ecdsa"; value: { signature: HexString; signer: HexString } }', + description: "ECDSA signature proof.", + }, + { + name: "OnChain", + type: '{ tag: "OnChain"; value: { who: HexString; blockHash: HexString; event: bigint } }', + description: "On-chain event proof.", + }, + ], + }, + { + id: "storage-query-item", + name: "StorageQueryItem", + category: "chain", + definition: + "export interface StorageQueryItem {\n key: HexString;\n queryType: StorageQueryType;\n}", + fields: [ + { + name: "key", + type: "HexString", + description: "Storage key to query.", + }, + { + name: "query_type", + type: "StorageQueryType", + description: "What to return.", + }, + ], + }, + { + id: "storage-query-type", + name: "StorageQueryType", + category: "chain", + definition: + 'export type StorageQueryType = "Value" | "Hash" | "ClosestDescendantMerkleValue" | "DescendantsValues" | "DescendantsHashes";', + variants: [ + { + name: "Value", + type: '{ tag: "Value"; value?: undefined }', + }, + { + name: "Hash", + type: '{ tag: "Hash"; value?: undefined }', + }, + { + name: "ClosestDescendantMerkleValue", + type: '{ tag: "ClosestDescendantMerkleValue"; value?: undefined }', + }, + { + name: "DescendantsValues", + type: '{ tag: "DescendantsValues"; value?: undefined }', + }, + { + name: "DescendantsHashes", + type: '{ tag: "DescendantsHashes"; value?: undefined }', + }, + ], + }, + { + id: "storage-result-item", + name: "StorageResultItem", + category: "chain", + definition: + "export interface StorageResultItem {\n key: HexString;\n value?: HexString;\n hash?: HexString;\n closestDescendantMerkleValue?: HexString;\n}", + fields: [ + { + name: "key", + type: "HexString", + description: "The queried key.", + }, + { + name: "value", + type: "HexString | undefined", + description: "Value, if requested.", + }, + { + name: "hash", + type: "HexString | undefined", + description: "Hash, if requested.", + }, + { + name: "closest_descendant_merkle_value", + type: "HexString | undefined", + description: "Merkle value, if requested.", + }, + ], + }, + { + id: "text-field-props", + name: "TextFieldProps", + category: "chat", + definition: + "export interface TextFieldProps {\n text: string;\n placeholder?: string;\n label?: string;\n enabled: boolean | undefined;\n valueChangeAction?: string;\n}", + description: "Properties for a [`CustomRendererNode::TextField`].", + fields: [ + { + name: "text", + type: "string", + description: "Current text value.", + }, + { + name: "placeholder", + type: "string | undefined", + description: "Placeholder text.", + }, + { + name: "label", + type: "string | undefined", + description: "Field label.", + }, + { + name: "enabled", + type: "boolean | undefined", + description: + "Whether the field is enabled. Absent leaves the default to the host.", + }, + { + name: "value_change_action", + type: "string | undefined", + description: "Action identifier triggered when the value changes.", + }, + ], + }, + { + id: "text-props", + name: "TextProps", + category: "chat", + definition: + "export interface TextProps {\n style?: TypographyStyle;\n color?: ColorToken;\n}", + description: "Properties for a [`CustomRendererNode::Text`] display.", + fields: [ + { + name: "style", + type: "TypographyStyle | undefined", + description: "Typography preset.", + }, + { + name: "color", + type: "ColorToken | undefined", + description: "Text color.", + }, + ], + }, + { + id: "theme-name", + name: "ThemeName", + category: "theme", + definition: + 'export type ThemeName =\n | { tag: "Custom"; value: string }\n | { tag: "Default"; value?: undefined }\n;', + description: "Identifies a named theme.", + variants: [ + { + name: "Custom", + type: '{ tag: "Custom"; value: string }', + description: "A custom named theme.", + }, + { + name: "Default", + type: '{ tag: "Default"; value?: undefined }', + description: "The host's default theme.", + }, + ], + }, + { + id: "theme-variant", + name: "ThemeVariant", + category: "theme", + definition: 'export type ThemeVariant = "Light" | "Dark";', + description: "Light or dark variant.", + variants: [ + { + name: "Light", + type: '{ tag: "Light"; value?: undefined }', + }, + { + name: "Dark", + type: '{ tag: "Dark"; value?: undefined }', + }, + ], + }, + { + id: "topic", + name: "Topic", + category: "statement_store", + definition: "export type Topic = HexString;", + description: "32-byte statement topic.", + }, + { + id: "tx-payload-extension", + name: "TxPayloadExtension", + category: "transaction", + definition: + "export interface TxPayloadExtension {\n id: string;\n extra: HexString;\n additionalSigned: HexString;\n}", + description: "A signed extension for a transaction payload.", + fields: [ + { + name: "id", + type: "string", + description: 'Extension name (e.g., `"CheckSpecVersion"`).', + }, + { + name: "extra", + type: "HexString", + description: "SCALE-encoded extra data (in extrinsic body).", + }, + { + name: "additional_signed", + type: "HexString", + description: "SCALE-encoded implicit data (signed, not in body).", + }, + ], + }, + { + id: "typography-style", + name: "TypographyStyle", + category: "chat", + definition: + 'export type TypographyStyle = "HeadlineLarge" | "TitleMediumRegular" | "BodyLargeRegular" | "BodyMediumRegular" | "BodySmallRegular";', + description: "Text typography presets.", + variants: [ + { + name: "HeadlineLarge", + type: '{ tag: "HeadlineLarge"; value?: undefined }', + }, + { + name: "TitleMediumRegular", + type: '{ tag: "TitleMediumRegular"; value?: undefined }', + }, + { + name: "BodyLargeRegular", + type: '{ tag: "BodyLargeRegular"; value?: undefined }', + }, + { + name: "BodyMediumRegular", + type: '{ tag: "BodyMediumRegular"; value?: undefined }', + }, + { + name: "BodySmallRegular", + type: '{ tag: "BodySmallRegular"; value?: undefined }', + }, + ], + }, + { + id: "vertical-alignment", + name: "VerticalAlignment", + category: "chat", + definition: 'export type VerticalAlignment = "Top" | "Center" | "Bottom";', + description: "Vertical alignment options.", + variants: [ + { + name: "Top", + type: '{ tag: "Top"; value?: undefined }', + }, + { + name: "Center", + type: '{ tag: "Center"; value?: undefined }', + }, + { + name: "Bottom", + type: '{ tag: "Bottom"; value?: undefined }', + }, + ], + }, +]; From 3faed7e2b4d61abf335716548546a0a9a2d2fe88 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 1 Jul 2026 22:29:15 +0200 Subject: [PATCH 8/8] fixup! feat: port Rust core runtime --- hosts/dotli | 2 +- playground/package.json | 4 +- playground/src/lib/auto-test.ts | 11 +- 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 + .../src/host_logic/sso/messages.rs | 365 +++++++++++--- .../src/host_logic/sso/pairing.rs | 7 +- rust/crates/truapi-server/src/runtime.rs | 131 +++++- rust/crates/truapi/src/api/account.rs | 5 +- rust/crates/truapi/src/api/signing.rs | 54 ++- rust/crates/truapi/src/lib.rs | 4 +- 14 files changed, 1001 insertions(+), 123 deletions(-) diff --git a/hosts/dotli b/hosts/dotli index 9235eb93..41b51b5c 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit 9235eb9361462dbabd36d8c91c7fff84e4b59bbd +Subproject commit 41b51b5c2f44964dc9197512433de00c36730820 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/auto-test.ts b/playground/src/lib/auto-test.ts index 617072d8..260f67a8 100644 --- a/playground/src/lib/auto-test.ts +++ b/playground/src/lib/auto-test.ts @@ -17,7 +17,9 @@ const SIGNING_TIMEOUT_MS = 30_000; const SSO_TIMEOUT_MS = 60_000; // Services skipped wholesale in the diagnosis until hosts wire them up. -const SKIPPED_SERVICES = new Set(["Coin Payment"]); +const SKIPPED_SERVICES = new Set(["Chat", "Coin Payment", "Payment"]); +// Individual methods skipped while the host surface is intentionally deferred. +const SKIPPED_METHODS = new Set(["Account/create_account_proof"]); // Methods whose first call implicitly triggers a host permission/signing // prompt, so they need the longer signing-class timeout to allow for the user // to respond. `get_account_alias` and `Preimage/submit` prompt on first use. @@ -35,6 +37,9 @@ const LONG_TIMEOUT_METHODS = new Set([ const METHOD_TIMEOUT_MS = new Map([ ["Account/get_account_alias", SSO_TIMEOUT_MS], + ["Resource Allocation/request", SSO_TIMEOUT_MS], + ["Preimage/lookup_subscribe", SSO_TIMEOUT_MS], + ["Signing/create_transaction", SSO_TIMEOUT_MS], ]); type RunOneOpts = { @@ -54,6 +59,10 @@ async function runOne({ onUpdate(id, { status: "skipped" }); return; } + if (SKIPPED_METHODS.has(id)) { + onUpdate(id, { status: "skipped" }); + return; + } if (!method.exampleSource) { onUpdate(id, { status: "fail", output: "no runnable example" }); return; 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 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 { 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 = 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;