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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

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

(dispatcher: &mut Dispatcher, host: Arc

) where P: Theme + Send + Sync + 'static, diff --git a/rust/crates/truapi-server/src/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs index 1d529c9f..12a72a94 100644 --- a/rust/crates/truapi-server/src/generated/wire_table.rs +++ b/rust/crates/truapi-server/src/generated/wire_table.rs @@ -460,20 +460,6 @@ pub const COIN_PAYMENT_LISTEN_FOR_PAYMENT: SubscriptionFrameIds = SubscriptionFr receive_id: 163, }; -#[cfg(debug_assertions)] -/// Wire discriminants for `testing_version_probe`. -pub const TESTING_VERSION_PROBE: RequestFrameIds = RequestFrameIds { - request_id: 164, - response_id: 165, -}; - -#[cfg(debug_assertions)] -/// Wire discriminants for `testing_echo_error`. -pub const TESTING_ECHO_ERROR: RequestFrameIds = RequestFrameIds { - request_id: 166, - response_id: 167, -}; - /// The full wire table. Ordering is part of the wire protocol; /// only ever append. Removed methods leave their slot empty. pub const WIRE_TABLE: &[WireEntry] = &[ @@ -733,14 +719,4 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "coin_payment_listen_for_payment", kind: WireKind::Subscription(COIN_PAYMENT_LISTEN_FOR_PAYMENT), }, - #[cfg(debug_assertions)] - WireEntry { - method: "testing_version_probe", - kind: WireKind::Request(TESTING_VERSION_PROBE), - }, - #[cfg(debug_assertions)] - WireEntry { - method: "testing_echo_error", - kind: WireKind::Request(TESTING_ECHO_ERROR), - }, ]; From 99d1b7e42defd5ec1f5ed6d2ea8eb551cce3b308 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 2 Jul 2026 10:51:53 +0200 Subject: [PATCH 10/19] fixup! feat(truapi-codegen): emit Rust dispatcher, wire table, and host callbacks --- rust/crates/truapi-codegen/tests/golden/host-callbacks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 4aab10b0..d111c323 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -488,7 +488,7 @@ export interface UserConfirmation { /** * Confirm a reviewed action before the core asks the SSO peer. */ - confirmUserAction?(review: UserConfirmationReview): Promise; + confirmUserAction(review: UserConfirmationReview): Promise; } /** From 1c463531b568feeef9af1831e8209ab7575ffa7c Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 2 Jul 2026 12:58:24 +0200 Subject: [PATCH 11/19] fixup! feat(truapi-codegen): emit Rust dispatcher, wire table, and host callbacks --- rust/crates/truapi-codegen/src/platform.rs | 119 ++++++++++++++++-- rust/crates/truapi-codegen/src/rust.rs | 103 +++++++++++++++ .../truapi-codegen/src/rust/dispatcher.rs | 48 ++++--- 3 files changed, 241 insertions(+), 29 deletions(-) diff --git a/rust/crates/truapi-codegen/src/platform.rs b/rust/crates/truapi-codegen/src/platform.rs index f37b5e91..0f6a0a3a 100644 --- a/rust/crates/truapi-codegen/src/platform.rs +++ b/rust/crates/truapi-codegen/src/platform.rs @@ -7,7 +7,7 @@ //! module walks the rustdoc index for every public trait in the platform crate //! and produces a [`PlatformDefinition`] the TS emitter can render directly. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use anyhow::{Context, Result, bail}; @@ -175,6 +175,28 @@ fn collect_referenced_local_types( } } + let mut local_type_candidates = BTreeMap::new(); + for (item_id, item_path) in &krate.paths { + if item_path.crate_id != 0 || !matches!(item_path.kind.as_str(), "struct" | "enum") { + continue; + } + let Some(name) = item_path.path.last() else { + continue; + }; + local_type_candidates + .entry(name.clone()) + .or_insert_with(Vec::new) + .push((item_id, item_path)); + } + for candidates in local_type_candidates.values_mut() { + candidates.sort_by(|(left_id, left_path), (right_id, right_path)| { + left_path + .path + .cmp(&right_path.path) + .then_with(|| left_id.cmp(right_id)) + }); + } + // Local types can reference further local types from their fields or // variant payloads (e.g. `AuthState::Connected(SessionUiInfo)`), so keep // extracting until the referenced set stops growing. @@ -182,16 +204,24 @@ fn collect_referenced_local_types( let mut extracted: BTreeSet = BTreeSet::new(); loop { let mut grew = false; - for (item_id, item_path) in &krate.paths { - if item_path.crate_id != 0 || !matches!(item_path.kind.as_str(), "struct" | "enum") { - continue; - } - let Some(name) = item_path.path.last() else { + let pending = referenced + .iter() + .filter(|name| !extracted.contains(*name)) + .cloned() + .collect::>(); + for name in pending { + let Some(candidates) = local_type_candidates.get(&name) else { continue; }; - if !referenced.contains(name) || extracted.contains(name) { - continue; + if candidates.len() > 1 { + let paths = candidates + .iter() + .map(|(_, item_path)| item_path.path.join("::")) + .collect::>() + .join(", "); + bail!("platform type name `{name}` is ambiguous: defined by {paths}"); } + let (item_id, item_path) = candidates[0]; let item = krate.index.get(item_id).with_context(|| { format!( "Missing rustdoc item `{item_id}` for {} `{name}`", @@ -205,7 +235,7 @@ fn collect_referenced_local_types( extract_enum(item_id, item, krate, names, module_path)? }; collect_type_def_references(&type_def, &mut referenced); - extracted.insert(name.clone()); + extracted.insert(name); types.push(type_def); grew = true; } @@ -614,8 +644,12 @@ fn value_to_id(value: &serde_json::Value) -> Result { #[cfg(test)] mod tests { + use std::collections::HashMap; + use serde_json::json; + use crate::rustdoc::ItemPath; + use super::*; #[test] @@ -687,4 +721,71 @@ mod tests { Some(json!({ "primitive": "u8" })) ); } + + #[test] + fn referenced_platform_type_names_must_be_unambiguous() { + let krate = Crate { + format_version: Some(57), + index: HashMap::new(), + paths: HashMap::from([ + ( + "1".to_string(), + ItemPath { + crate_id: 0, + path: vec![ + "truapi_platform".to_string(), + "one".to_string(), + "Shared".to_string(), + ], + kind: "struct".to_string(), + }, + ), + ( + "2".to_string(), + ItemPath { + crate_id: 0, + path: vec![ + "truapi_platform".to_string(), + "two".to_string(), + "Shared".to_string(), + ], + kind: "enum".to_string(), + }, + ), + ]), + }; + let traits = [PlatformTrait { + name: "Storage".to_string(), + docs: None, + methods: vec![PlatformMethod { + name: "write".to_string(), + docs: None, + params: vec![PlatformParam { + name: "value".to_string(), + type_ref: TypeRef::Named { + name: "Shared".to_string(), + args: Vec::new(), + }, + }], + return_shape: PlatformReturn { + is_async: false, + inner: PlatformInner::Unit, + }, + has_default: false, + }], + }]; + + let err = + collect_referenced_local_types(&krate, &traits, &NameContext::default()).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("platform type name `Shared` is ambiguous"), + "unexpected error: {msg}" + ); + assert!( + msg.contains("truapi_platform::one::Shared") + && msg.contains("truapi_platform::two::Shared"), + "unexpected error: {msg}" + ); + } } diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index 15a2d904..1ca6f77c 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -601,4 +601,107 @@ mod tests { "unexpected error message: {msg}", ); } + + #[test] + fn dispatcher_versioned_request_with_raw_error_errors() { + let mut method = make_request_method("alpha", 10); + method.return_type = ReturnType::Result { + ok: TypeRef::Named { + name: "RespWrapper".to_string(), + args: vec![], + }, + err: TypeRef::Named { + name: "CallError".to_string(), + args: vec![TypeRef::Named { + name: "RawError".to_string(), + args: vec![], + }], + }, + }; + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Permissions".to_string(), + module_path: Vec::new(), + methods: vec![method], + docs: None, + }], + public_trait_order: vec!["Permissions".to_string()], + types: vec![ + versioned_test_type("ReqWrapper"), + versioned_test_type("RespWrapper"), + ], + }; + + let err = generate_dispatcher(&api).expect_err("raw error wrapper must error"); + let msg = format!("{err}"); + assert!( + msg.contains("versioned request methods must use versioned errors"), + "unexpected error message: {msg}", + ); + } + + #[test] + fn dispatcher_raw_request_with_versioned_response_errors() { + let mut method = make_request_method("alpha", 10); + method.params[0].type_ref = TypeRef::Named { + name: "RawRequest".to_string(), + args: vec![], + }; + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Permissions".to_string(), + module_path: Vec::new(), + methods: vec![method], + docs: None, + }], + public_trait_order: vec!["Permissions".to_string()], + types: vec![ + versioned_test_type("RespWrapper"), + versioned_test_type("ErrWrapper"), + ], + }; + + let err = generate_dispatcher(&api).expect_err("missing target version must error"); + let msg = format!("{err}"); + assert!( + msg.contains("versioned responses require a target version"), + "unexpected error message: {msg}", + ); + } + + #[test] + fn dispatcher_result_subscription_with_raw_error_errors() { + let mut method = make_subscription_method("alpha_subscribe", 20); + method.kind = MethodKind::ResultSubscription; + method.return_type = ReturnType::ResultSubscription { + item: TypeRef::Named { + name: "ItemWrapper".to_string(), + args: vec![], + }, + err: TypeRef::Named { + name: "CallError".to_string(), + args: vec![TypeRef::Named { + name: "RawError".to_string(), + args: vec![], + }], + }, + }; + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Account".to_string(), + module_path: Vec::new(), + methods: vec![method], + docs: None, + }], + public_trait_order: vec!["Account".to_string()], + types: vec![versioned_test_type("ItemWrapper")], + }; + + let err = generate_dispatcher(&api).expect_err("raw result subscription error must error"); + let msg = format!("{err}"); + assert!( + msg.contains("result subscription methods must have an error wrapper"), + "unexpected error message: {msg}", + ); + } } diff --git a/rust/crates/truapi-codegen/src/rust/dispatcher.rs b/rust/crates/truapi-codegen/src/rust/dispatcher.rs index 15486b1d..183327ed 100644 --- a/rust/crates/truapi-codegen/src/rust/dispatcher.rs +++ b/rust/crates/truapi-codegen/src/rust/dispatcher.rs @@ -16,7 +16,7 @@ use std::collections::BTreeMap; use std::collections::BTreeSet; use std::fmt::Write; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use indoc::{formatdoc, indoc, writedoc}; use crate::rustdoc::*; @@ -128,7 +128,7 @@ fn build_module(api: &ApiDefinition, trait_def: &TraitDef) -> Result Result<()> { match self.kind { MethodKind::Request => self.write_request(out, host_expr), MethodKind::Subscription | MethodKind::ResultSubscription => { @@ -262,7 +262,7 @@ impl MethodEmission { && matches!(self.error_payload, WirePayload::Raw(_)) } - fn write_request(&self, out: &mut String, host_expr: &str) { + fn write_request(&self, out: &mut String, host_expr: &str) -> Result<()> { let module = &self.module; let method = &self.name; let ids = const_name(&self.wire_name); @@ -282,10 +282,9 @@ impl MethodEmission { ); let (call_args, target_version_expr) = match &self.request_payload { Some(WirePayload::Versioned(request)) => { - let error = self - .error_payload - .versioned_name() - .expect("versioned request methods must use versioned errors"); + let Some(error) = self.error_payload.versioned_name() else { + bail!("Method `{method}`: versioned request methods must use versioned errors"); + }; write_indented( out, 16, @@ -312,11 +311,15 @@ impl MethodEmission { ) } Some(WirePayload::Raw(request)) => { - let request_ty = rust_type_ref(request).expect("raw request type"); + let request_ty = rust_type_ref(request).with_context(|| { + format!("Method `{method}`: raw request type cannot be emitted") + })?; let error_ty = self .error_payload .rust_error_type(module) - .expect("raw request methods must have error type"); + .with_context(|| { + format!("Method `{method}`: raw request methods must have error type") + })?; write_indented( out, 16, @@ -351,9 +354,9 @@ impl MethodEmission { .unwrap(); match &self.response_wrapper { Some(response) => { - let target_version_expr = target_version_expr - .as_deref() - .expect("versioned responses require a target version"); + let Some(target_version_expr) = target_version_expr.as_deref() else { + bail!("Method `{method}`: versioned responses require a target version"); + }; write_indented( out, 16, @@ -401,7 +404,9 @@ impl MethodEmission { }, ); } - (WirePayload::Versioned(_), None) => unreachable!("missing versioned target"), + (WirePayload::Versioned(_), None) => { + bail!("Method `{method}`: versioned unit responses require a target version") + } }, } write_indented( @@ -415,16 +420,16 @@ impl MethodEmission { "# }, ); + Ok(()) } - fn write_subscription(&self, out: &mut String, host_expr: &str) { + fn write_subscription(&self, out: &mut String, host_expr: &str) -> Result<()> { let module = &self.module; let method = &self.name; let ids = const_name(&self.wire_name); - let item = self - .item_wrapper - .as_deref() - .expect("subscription methods must have an item wrapper"); + let Some(item) = self.item_wrapper.as_deref() else { + bail!("Method `{method}`: subscription methods must have an item wrapper"); + }; let error = self.error_payload.versioned_name(); let is_result_sub = matches!(self.kind, MethodKind::ResultSubscription); @@ -502,7 +507,9 @@ impl MethodEmission { ) .unwrap(); if is_result_sub { - let _ = error.expect("result subscription methods must have an error wrapper"); + if error.is_none() { + bail!("Method `{method}`: result subscription methods must have an error wrapper"); + } write_indented( out, 16, @@ -540,6 +547,7 @@ impl MethodEmission { "# }, ); + Ok(()) } } From 2c52262ef914183fcf8bbb6e2804d58271705234 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 17:09:09 +0200 Subject: [PATCH 12/19] fixup! feat(truapi-codegen): emit Rust dispatcher, wire table, and host callbacks --- .../tests/golden/host-callbacks.ts | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index d111c323..407e2050 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -304,17 +304,6 @@ export interface CoreAdmin { */ disconnectSession(): Promise; - /** - * Cancel any in-flight pairing request. - */ - cancelPairing(): void; - - /** - * Notify the core that the host-global auth session slot may have - * changed. The core re-reads storage and emits any resulting auth state. - */ - notifySessionStoreChanged(): void; - /** * Read a stored permission authorization status without prompting. */ @@ -418,9 +407,27 @@ export interface Notifications { } /** - * Permission prompts. v0.1 keeps device permissions (camera, mic, NFC, ...) - * separate from remote permissions (domain access, chain submit, ...), so the - * platform surface mirrors that split. + * Pairing-host-only administration API exposed to host UI. + */ +export interface PairingHostAdmin { + /** + * Cancel any in-flight pairing request. + */ + cancelPairing(): void; + + /** + * Notify the core that the persisted auth-session blob may have changed. + * + * The host owns persistence and change detection. The pairing core owns + * decoding that blob into live `SessionState` / `AuthState`. + */ + notifySessionStoreChanged(): void; +} + +/** + * Permission prompts. Device permissions (camera, mic, NFC, ...) are separate + * from remote permissions (domain access, chain submit, ...), so the platform + * surface mirrors that split. */ export interface Permissions { /** @@ -451,8 +458,10 @@ export interface PreimageHost { } /** - * Product-scoped key-value storage. The platform namespaces keys so different - * products cannot read each other's data. + * Product-scoped key-value storage. + * + * The core namespaces product keys before calling this trait. Host + * implementations should treat `key` as an opaque OS-style storage key. */ export interface ProductStorage { /** From 7bd57b863f01a065383b923edebc3d5d655c606a Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 17:20:39 +0200 Subject: [PATCH 13/19] fixup! feat(truapi-codegen): emit Rust dispatcher, wire table, and host callbacks --- rust/crates/truapi-codegen/src/rustdoc.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index 0dd136ee..8901fc0a 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -29,7 +29,6 @@ pub struct Item { /// Local item name as it appears in source. pub name: Option, /// Rustdoc comment on the item, if any. - #[allow(dead_code)] pub docs: Option, /// Kind-dependent rustdoc payload, parsed lazily by helpers in this module. pub inner: serde_json::Value, From 6964df37a1f1bb129f88fbd2f41dfe73dbd34486 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 1 Jul 2026 11:18:30 +0200 Subject: [PATCH 14/19] feat(truapi-server): add host logic primitives --- Cargo.lock | 3869 +++++++++++++++-- Cargo.toml | 1 - deny.toml | 3 + rust/crates/truapi-server/Cargo.toml | 66 + rust/crates/truapi-server/README.md | 33 + rust/crates/truapi-server/src/host_logic.rs | 16 + .../truapi-server/src/host_logic/dotns.rs | 473 ++ .../truapi-server/src/host_logic/entropy.rs | 113 + .../truapi-server/src/host_logic/features.rs | 66 + .../truapi-server/src/host_logic/identity.rs | 123 + .../src/host_logic/permissions.rs | 706 +++ .../src/host_logic/product_account.rs | 175 + .../truapi-server/src/host_logic/session.rs | 416 ++ .../src/host_logic/session_store.rs | 97 + .../truapi-server/src/host_logic/sso.rs | 2 + .../src/host_logic/sso/messages.rs | 772 ++++ .../src/host_logic/sso/pairing.rs | 758 ++++ .../src/host_logic/statement_store.rs | 38 + .../src/host_logic/statement_store/rpc.rs | 115 + .../host_logic/statement_store/statement.rs | 675 +++ rust/crates/truapi-server/src/lib.rs | 9 + 21 files changed, 8090 insertions(+), 436 deletions(-) create mode 100644 rust/crates/truapi-server/Cargo.toml create mode 100644 rust/crates/truapi-server/README.md create mode 100644 rust/crates/truapi-server/src/host_logic.rs create mode 100644 rust/crates/truapi-server/src/host_logic/dotns.rs create mode 100644 rust/crates/truapi-server/src/host_logic/entropy.rs create mode 100644 rust/crates/truapi-server/src/host_logic/features.rs create mode 100644 rust/crates/truapi-server/src/host_logic/identity.rs create mode 100644 rust/crates/truapi-server/src/host_logic/permissions.rs create mode 100644 rust/crates/truapi-server/src/host_logic/product_account.rs create mode 100644 rust/crates/truapi-server/src/host_logic/session.rs create mode 100644 rust/crates/truapi-server/src/host_logic/session_store.rs create mode 100644 rust/crates/truapi-server/src/host_logic/sso.rs create mode 100644 rust/crates/truapi-server/src/host_logic/sso/messages.rs create mode 100644 rust/crates/truapi-server/src/host_logic/sso/pairing.rs create mode 100644 rust/crates/truapi-server/src/host_logic/statement_store.rs create mode 100644 rust/crates/truapi-server/src/host_logic/statement_store/rpc.rs create mode 100644 rust/crates/truapi-server/src/host_logic/statement_store/statement.rs create mode 100644 rust/crates/truapi-server/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index be16e446..23c9c80a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,47 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "1.0.0" @@ -38,7 +79,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,7 +90,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,12 +99,146 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9fd44efafa8690358b7408d253adf110036b88f55672a933f01d616ad9b1b9" +dependencies = [ + "nodrop", +] + [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -75,11 +250,71 @@ dependencies = [ "syn", ] +[[package]] +name = "atomic-take" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8ab6b55fe97976e46f91ddbed8d147d966475dc29b2032757ba47e02376fbc3" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "hex-conservative", +] + [[package]] name = "bitflags" -version = "2.13.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bitvec" @@ -93,18 +328,140 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2-rfc" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d530bdd2d52966a6d03b7a964add7ae1a288d25214066fd4b600f0f796400" +dependencies = [ + "arrayvec 0.4.12", + "constant_time_eq 0.1.5", +] + +[[package]] +name = "blake2b_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +dependencies = [ + "arrayref", + "arrayvec 0.7.6", + "constant_time_eq 0.4.2", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "byte-slice-cast" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -151,6 +508,41 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const_format" version = "0.2.36" @@ -172,6 +564,18 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.6.0" @@ -191,129 +595,136 @@ dependencies = [ ] [[package]] -name = "derive_more" -version = "2.1.1" +name = "core-foundation" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "derive_more-impl", + "core-foundation-sys", + "libc", ] [[package]] -name = "derive_more-impl" -version = "2.1.1" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "convert_case 0.10.0", - "proc-macro2", - "quote", - "rustc_version", - "syn", - "unicode-xid", + "libc", ] [[package]] -name = "displaydoc" -version = "0.2.5" +name = "crossbeam-queue" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" dependencies = [ - "proc-macro2", - "quote", - "syn", + "crossbeam-utils", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "crossbeam-utils" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "errno" -version = "0.3.14" +name = "crunchy" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "libc", - "windows-sys", + "generic-array", + "rand_core", + "subtle", + "zeroize", ] [[package]] -name = "fastrand" -version = "2.4.1" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "percent-encoding", + "generic-array", + "rand_core", + "typenum", ] [[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futures" -version = "0.3.32" +name = "crypto-mac" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab" dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", + "generic-array", + "subtle", ] [[package]] -name = "futures-channel" -version = "0.3.32" +name = "ctr" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "futures-core", - "futures-sink", + "cipher", ] [[package]] -name = "futures-core" -version = "0.3.32" +name = "curve25519-dalek" +version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] [[package]] -name = "futures-executor" -version = "0.3.32" +name = "curve25519-dalek-derive" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ - "futures-core", - "futures-task", - "futures-util", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "futures-io" -version = "0.3.32" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] [[package]] -name = "futures-macro" -version = "0.3.32" +name = "derive-where" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" dependencies = [ "proc-macro2", "quote", @@ -321,656 +732,3098 @@ dependencies = [ ] [[package]] -name = "futures-sink" -version = "0.3.32" +name = "derive_more" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] [[package]] -name = "futures-task" -version = "0.3.32" +name = "derive_more" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl 2.1.1", +] [[package]] -name = "futures-util" -version = "0.3.32" +name = "derive_more-impl" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "getrandom" -version = "0.4.3" +name = "derive_more-impl" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "cfg-if", - "libc", - "r-efi", + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", ] [[package]] -name = "hashbrown" -version = "0.17.0" +name = "digest" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] [[package]] -name = "heck" -version = "0.5.0" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", + "subtle", +] [[package]] -name = "hex" -version = "0.4.3" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "icu_collections" -version = "2.2.0" +name = "downcast-rs" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] -name = "icu_locale_core" -version = "2.2.0" +name = "ed25519" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", + "pkcs8", + "signature", ] [[package]] -name = "icu_normalizer" -version = "2.2.0" +name = "ed25519-zebra" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "775765289f7c6336c18d3d66127527820dd45ffd9eb3b6b8ee4708590e6c20f5" dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", + "curve25519-dalek", + "ed25519", + "hashbrown 0.16.1", + "pkcs8", + "rand_core", + "sha2 0.10.9", + "subtle", + "zeroize", ] [[package]] -name = "icu_normalizer_data" -version = "2.2.0" +name = "either" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] -name = "icu_properties" -version = "2.2.0" +name = "elliptic-curve" +version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "rand_core", + "sec1", + "subtle", + "zeroize", ] [[package]] -name = "icu_properties_data" -version = "2.2.0" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "icu_provider" -version = "2.2.0" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", + "libc", + "windows-sys 0.61.2", ] [[package]] -name = "idna" -version = "1.1.0" +name = "event-listener" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", + "concurrent-queue", + "parking", + "pin-project-lite", ] [[package]] -name = "idna_adapter" -version = "1.2.2" +name = "event-listener-strategy" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "icu_normalizer", - "icu_properties", + "event-listener", + "pin-project-lite", ] [[package]] -name = "impl-trait-for-tuples" -version = "0.2.3" +name = "fastbloom" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" dependencies = [ - "proc-macro2", - "quote", - "syn", + "foldhash 0.2.0", + "libm", + "portable-atomic", + "siphasher", ] [[package]] -name = "indexmap" -version = "2.14.0" +name = "fastrand" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "indoc" -version = "2.0.7" +name = "ff" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rustversion", + "rand_core", + "subtle", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "fiat-crypto" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] -name = "itoa" -version = "1.0.18" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "konst" -version = "0.2.20" +name = "finito" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +checksum = "2384245d85162258a14b43567a9ee3598f5ae746a1581fb5d3d2cb780f0dbf95" dependencies = [ - "konst_macro_rules", + "futures-timer", + "pin-project", ] [[package]] -name = "konst_macro_rules" -version = "0.2.19" +name = "fixed-hash" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand", + "rustc-hex", + "static_assertions", +] [[package]] -name = "libc" -version = "0.2.186" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "litemap" -version = "0.8.2" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] -name = "memchr" -version = "2.8.0" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] [[package]] -name = "once_cell" -version = "1.21.4" +name = "frame-metadata" +version = "23.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" +checksum = "9ba5be0edbdb824843a0f9c6f0906ecfc66c5316218d74457003218b24909ed0" +dependencies = [ + "cfg-if", + "parity-scale-codec", + "scale-info", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +dependencies = [ + "gloo-timers", + "send_wrapper 0.4.0", +] + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "getrandom_or_panic" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea1015b5a70616b688dc230cfe50c8af89d972cb132d5a622814d29773b10b9" +dependencies = [ + "rand_core", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec 0.7.6", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126888268dcc288495a26bf004b38c5fdbb31682f992c84ceb046a1f0fe38840" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac-drbg" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ea0a1394df5b6574da6e0c1ade9e78868c9fb0a4e5ef4428e32da4676b85b1" +dependencies = [ + "digest 0.9.0", + "generic-array", + "hmac 0.8.1", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-codec" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d40b9d5e17727407e55028eafc22b2dc68781786e6d7eb8a21103f5058e3a14" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-serde" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a143eada6a1ec4aefa5049037a26a6d597bfd64f8c026d07b77133e02b7dd0b" +dependencies = [ + "serde", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonrpsee" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c4b1f204b655b36b24dc4939af20366c649431d4711863bbbae5c495f3eeb4" +dependencies = [ + "jsonrpsee-client-transport", + "jsonrpsee-core", + "jsonrpsee-types", + "jsonrpsee-wasm-client", + "jsonrpsee-ws-client", +] + +[[package]] +name = "jsonrpsee-client-transport" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3e1420b1792cff778e2a1ebaa44115f156ee62a94dd106eaa51163f037d2023" +dependencies = [ + "base64", + "futures-channel", + "futures-util", + "gloo-net", + "http", + "jsonrpsee-core", + "pin-project", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "soketto", + "thiserror 1.0.69", + "tokio", + "tokio-rustls", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "jsonrpsee-core" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49bfa9334963e1c85866b39dff3ffcc81f1c286eb23334267c5cb97677543a4" +dependencies = [ + "async-trait", + "futures-timer", + "futures-util", + "jsonrpsee-types", + "pin-project", + "rustc-hash", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "wasm-bindgen-futures", +] + +[[package]] +name = "jsonrpsee-types" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d86fc943f81dab0ecdd6c0240b6e0f55ad57a2ea9ad8ad7efe8456fb9cc7a4" +dependencies = [ + "http", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonrpsee-wasm-client" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "735df2088674c87f7fecdf51c80878a7aa19a8116b32d703b000f5b1a7acf95a" +dependencies = [ + "jsonrpsee-client-transport", + "jsonrpsee-core", + "jsonrpsee-types", +] + +[[package]] +name = "jsonrpsee-ws-client" +version = "0.24.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df5bd5c38c0906a6e8b3a38c8c22cc8525fda25fd1a03a3fe010686aea66b70" +dependencies = [ + "http", + "jsonrpsee-client-transport", + "jsonrpsee-core", + "jsonrpsee-types", + "url", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsecp256k1" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79019718125edc905a079a70cfa5f3820bc76139fc91d6f9abc27ea2a887139" +dependencies = [ + "arrayref", + "base64", + "digest 0.9.0", + "hmac-drbg", + "libsecp256k1-core", + "libsecp256k1-gen-ecmult", + "libsecp256k1-gen-genmult", + "rand", + "serde", + "sha2 0.9.9", + "typenum", +] + +[[package]] +name = "libsecp256k1-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be9b9bb642d8522a44d533eab56c16c738301965504753b03ad1de3425d5451" +dependencies = [ + "crunchy", + "digest 0.9.0", + "subtle", +] + +[[package]] +name = "libsecp256k1-gen-ecmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3038c808c55c87e8a172643a7d87187fc6c4174468159cb3090659d55bcb4809" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "libsecp256k1-gen-genmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db8d6ba2cec9eacc40e6e8ccc98931840301f1006e95647ceb2dd5c3aa06f7c" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core", + "zeroize", +] + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multi-stash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685a9ac4b61f4e728e1d2c6a7844609c16527aeb5e6c865915c08e619c16410f" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "elliptic-curve", + "primeorder", +] + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec 0.7.6", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "primitive-types" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15600a7d856470b7d278b3fe0e311fe28c2526348549f8ef2ff7db3299c87f5" +dependencies = [ + "fixed-hash", + "impl-codec", + "impl-serde", + "uint", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs 0.26.11", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ruzstd" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scale-info" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b" +dependencies = [ + "cfg-if", + "derive_more 1.0.0", + "parity-scale-codec", + "scale-info-derive", +] + +[[package]] +name = "scale-info-derive" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6630024bf739e2179b91fb424b28898baf819414262c5d376677dbff1fe7ebf" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schnorrkel" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9fcb6c2e176e86ec703e22560d99d65a5ee9056ae45a08e13e84ebf796296f" +dependencies = [ + "aead", + "arrayref", + "arrayvec 0.7.6", + "curve25519-dalek", + "getrandom_or_panic", + "merlin", + "rand_core", + "serde_bytes", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f638d531eccd6e23b980caf34876660d38e265409d8e99b397ab71eb3612fad0" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smol" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" +dependencies = [ + "async-channel", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite", +] + +[[package]] +name = "smoldot" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e8b3be57abd860ec235a62084ad8a72772d4bf799ba26aa3aefc282273fcf5e" +dependencies = [ + "arrayvec 0.7.6", + "async-lock", + "atomic-take", + "base32", + "base64", + "bip39", + "blake2-rfc", + "bs58", + "chacha20", + "crossbeam-queue", + "derive_more 2.1.1", + "ed25519-zebra", + "either", + "event-listener", + "fastbloom", + "fnv", + "futures-lite", + "futures-util", + "hashbrown 0.16.1", + "hex", + "hmac 0.12.1", + "itertools", + "libm", + "libsecp256k1", + "merlin", + "nom", + "num-bigint", + "num-rational", + "num-traits", + "pbkdf2", + "pin-project", + "poly1305", + "rand", + "rand_chacha", + "ruzstd", + "schnorrkel", + "serde", + "serde_json", + "sha2 0.10.9", + "sha3", + "siphasher", + "slab", + "smallvec", + "soketto", + "twox-hash 2.1.2", + "wasmi", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "smoldot" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f753549b68687bf8e27cda1e26dfbe8762701216ea722d43bd992a3a2576daa1" +dependencies = [ + "arrayvec 0.7.6", + "async-lock", + "atomic-take", + "base32", + "base64", + "bip39", + "blake2-rfc", + "bs58", + "chacha20", + "crossbeam-queue", + "derive_more 2.1.1", + "ed25519-zebra", + "either", + "event-listener", + "fastbloom", + "fnv", + "futures-lite", + "futures-util", + "hashbrown 0.16.1", + "hex", + "hmac 0.12.1", + "itertools", + "libm", + "libsecp256k1", + "merlin", + "nom", + "num-bigint", + "num-rational", + "num-traits", + "pbkdf2", + "pin-project", + "poly1305", + "rand", + "rand_chacha", + "ruzstd", + "schnorrkel", + "serde", + "serde_json", + "sha2 0.10.9", + "sha3", + "siphasher", + "slab", + "smallvec", + "soketto", + "twox-hash 2.1.2", + "wasmi", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "smoldot-light" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50345b88d93e5fd400b04cc9c64ab34fa87235e2c0aba4f1973916d7547feb4f" +dependencies = [ + "async-channel", + "async-lock", + "base64", + "blake2-rfc", + "bs58", + "derive_more 2.1.1", + "either", + "event-listener", + "fnv", + "futures-channel", + "futures-lite", + "futures-util", + "hashbrown 0.16.1", + "hex", + "itertools", + "log", + "lru", + "parking_lot", + "pin-project", + "rand", + "rand_chacha", + "serde", + "serde_json", + "siphasher", + "slab", + "smol", + "smoldot 2.0.0", + "zeroize", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "soketto" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e859df029d160cb88608f5d7df7fb4753fd20fdfb4de5644f3d8b8440841721" +dependencies = [ + "base64", + "bytes", + "futures", + "httparse", + "log", + "rand", + "sha1", +] + +[[package]] +name = "sp-crypto-hashing" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9927a7f81334ed5b8a98a4a978c81324d12bd9713ec76b5c68fd410174c5eb" +dependencies = [ + "blake2b_simd", + "byteorder", + "digest 0.10.7", + "sha2 0.10.9", + "sha3", + "twox-hash 1.6.3", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "subxt-lightclient" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961bd1b7d5531f7a6b8086364eb4c6c09b21675e5e8b29b56ea281187d151eef" +dependencies = [ + "futures", + "futures-timer", + "futures-util", + "getrandom 0.2.17", + "js-sys", + "pin-project", + "send_wrapper 0.6.0", + "serde", + "serde_json", + "smoldot 1.2.0", + "smoldot-light", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", +] + +[[package]] +name = "subxt-rpcs" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e4b23044ba59654f30e25cc48f8865e70439ee137764793cdfb0f8452dc638" +dependencies = [ + "derive-where", + "finito", + "frame-metadata", + "futures", + "getrandom 0.2.17", + "hex", + "impl-serde", + "jsonrpsee", + "parity-scale-codec", + "primitive-types", + "serde", + "serde_json", + "subxt-lightclient", + "thiserror 2.0.18", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] [[package]] -name = "parity-scale-codec" -version = "3.7.5" +name = "tracing" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "arrayvec", - "bitvec", - "byte-slice-cast", - "const_format", - "impl-trait-for-tuples", - "parity-scale-codec-derive", - "rustversion", - "serde", + "pin-project-lite", + "tracing-attributes", + "tracing-core", ] [[package]] -name = "parity-scale-codec-derive" -version = "3.7.5" +name = "tracing-attributes" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ - "proc-macro-crate", "proc-macro2", "quote", "syn", ] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "tracing-core" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "tracing-subscriber" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] [[package]] -name = "potential_utf" -version = "0.1.5" +name = "truapi" +version = "0.3.1" +dependencies = [ + "derive_more 2.1.1", + "futures", + "hex", + "parity-scale-codec", + "truapi-macros", +] + +[[package]] +name = "truapi-codegen" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "convert_case 0.6.0", + "indoc", + "serde", + "serde_json", + "tempfile", + "truapi", +] + +[[package]] +name = "truapi-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "truapi-platform" +version = "0.1.0" +dependencies = [ + "async-trait", + "derive_more 2.1.1", + "futures", + "parity-scale-codec", + "truapi", + "unicode-normalization", + "url", +] + +[[package]] +name = "truapi-server" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "async-trait", + "blake2-rfc", + "bs58", + "console_error_panic_hook", + "derive_more 2.1.1", + "futures", + "futures-timer", + "futures-util", + "getrandom 0.2.17", + "hex", + "hkdf", + "js-sys", + "p256", + "parity-scale-codec", + "pin-project", + "primitive-types", + "schnorrkel", + "send_wrapper 0.6.0", + "serde", + "serde_json", + "sha2 0.10.9", + "sp-crypto-hashing", + "subxt-rpcs", + "thiserror 1.0.69", + "tracing", + "tracing-subscriber", + "truapi", + "truapi-platform", + "unicode-normalization", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", + "web-time", +] + +[[package]] +name = "twox-hash" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ - "zerovec", + "cfg-if", + "digest 0.10.7", + "static_assertions", ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "twox-hash" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "uint" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909988d098b2f738727b161a106cfc7cab00c539c2687a8836f8e565976fb53e" dependencies = [ - "toml_edit", + "byteorder", + "crunchy", + "hex", + "static_assertions", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "unicode-ident" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ - "unicode-ident", + "tinyvec", ] [[package]] -name = "quote" -version = "1.0.45" +name = "unicode-segmentation" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "proc-macro2", + "crypto-common", + "subtle", ] [[package]] -name = "r-efi" -version = "6.0.0" +name = "untrusted" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "radium" -version = "0.7.0" +name = "url" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] [[package]] -name = "rustc_version" -version = "0.4.1" +name = "utf8_iter" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ - "semver", + "same-file", + "winapi-util", ] [[package]] -name = "rustix" -version = "1.1.4" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "wit-bindgen 0.57.1", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] [[package]] -name = "semver" -version = "1.0.28" +name = "wasm-bindgen" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] [[package]] -name = "serde" -version = "1.0.228" +name = "wasm-bindgen-futures" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ - "serde_core", - "serde_derive", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "wasm-bindgen-macro" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ - "serde_derive", + "quote", + "wasm-bindgen-macro-support", ] [[package]] -name = "serde_derive" -version = "1.0.228" +name = "wasm-bindgen-macro-support" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", + "wasm-bindgen-shared", ] [[package]] -name = "serde_json" -version = "1.0.149" +name = "wasm-bindgen-shared" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ - "itoa", - "memchr", + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af5ec93229ad9ccd0a545a516dec76dc276613f278f6a91aa6b463d5b33d42d0" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", "serde", - "serde_core", - "zmij", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", ] [[package]] -name = "slab" -version = "0.4.12" +name = "wasm-bindgen-test-macro" +version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "3c81b9fef827e575e0e54431736d1baa0d700315d8c62cfef1f61fa3aad0cbeb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "smallvec" -version = "1.15.1" +name = "wasm-bindgen-test-shared" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "4f4d8ae7ad5440360e9799dfd42857d126454a88441ddf72d288ef83fa47f527" + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasmi" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19af97fcb96045dd1d6b4d23e2b4abdbbe81723dbc5c9f016eb52145b320063" +dependencies = [ + "arrayvec 0.7.6", + "multi-stash", + "smallvec", + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser 0.221.3", +] [[package]] -name = "stable_deref_trait" -version = "1.2.1" +name = "wasmi_collections" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +checksum = "e80d6b275b1c922021939d561574bf376613493ae2b61c6963b15db0e8813562" [[package]] -name = "strsim" -version = "0.11.1" +name = "wasmi_core" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "3a8c51482cc32d31c2c7ff211cd2bedd73c5bd057ba16a2ed0110e7a96097c33" +dependencies = [ + "downcast-rs", + "libm", +] [[package]] -name = "syn" -version = "2.0.117" +name = "wasmi_ir" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "6e431a14c186db59212a88516788bd68ed51f87aa1e08d1df742522867b5289a" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "wasmi_core", ] [[package]] -name = "synstructure" -version = "0.13.2" +name = "wasmparser" +version = "0.221.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" dependencies = [ - "proc-macro2", - "quote", - "syn", + "bitflags", ] [[package]] -name = "tap" -version = "1.0.1" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] [[package]] -name = "tempfile" -version = "3.27.0" +name = "web-sys" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "tinystr" -version = "0.8.3" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "displaydoc", - "zerovec", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "tinyvec" -version = "1.11.0" +name = "webpki-root-certs" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" dependencies = [ - "tinyvec_macros", + "webpki-root-certs 1.0.8", ] [[package]] -name = "tinyvec_macros" -version = "0.1.1" +name = "webpki-root-certs" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] [[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +name = "winapi-util" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "serde_core", + "windows-sys 0.61.2", ] [[package]] -name = "toml_edit" -version = "0.25.11+spec-1.1.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", + "windows-targets 0.42.2", ] [[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "winnow", + "windows-targets 0.52.6", ] [[package]] -name = "truapi" -version = "0.3.1" +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "derive_more", - "futures", - "hex", - "parity-scale-codec", - "truapi-macros", + "windows-targets 0.52.6", ] [[package]] -name = "truapi-codegen" -version = "0.1.0" +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "anyhow", - "clap", - "convert_case 0.6.0", - "indoc", - "serde", - "serde_json", - "tempfile", - "truapi", + "windows-link", ] [[package]] -name = "truapi-macros" -version = "0.1.0" +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", ] [[package]] -name = "truapi-platform" -version = "0.1.0" +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "async-trait", - "derive_more", - "futures", - "parity-scale-codec", - "truapi", - "unicode-normalization", - "url", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "windows_aarch64_gnullvm" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" [[package]] -name = "unicode-normalization" -version = "0.1.25" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "unicode-segmentation" -version = "1.13.2" +name = "windows_aarch64_msvc" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "url" -version = "2.5.8" +name = "windows_i686_gnu" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" [[package]] -name = "utf8_iter" -version = "1.0.4" +name = "windows_i686_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] -name = "utf8parse" -version = "0.2.2" +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "windows-link" -version = "0.2.1" +name = "windows_i686_msvc" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" [[package]] -name = "windows-sys" -version = "0.61.2" +name = "windows_i686_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" @@ -981,6 +3834,100 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser 0.244.0", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] + [[package]] name = "writeable" version = "0.6.3" @@ -996,6 +3943,18 @@ dependencies = [ "tap", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + [[package]] name = "yoke" version = "0.8.2" @@ -1019,6 +3978,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -1040,6 +4019,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index d657d267..042aff82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] resolver = "2" members = ["rust/crates/*"] -exclude = ["rust/crates/truapi-server"] [workspace.package] edition = "2024" diff --git a/deny.toml b/deny.toml index 707c6f77..4fdc03c6 100644 --- a/deny.toml +++ b/deny.toml @@ -5,6 +5,9 @@ allow = [ "MIT", "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", "Unicode-3.0", "Unlicense", "Zlib", diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml new file mode 100644 index 00000000..6cecb5f6 --- /dev/null +++ b/rust/crates/truapi-server/Cargo.toml @@ -0,0 +1,66 @@ +[package] +name = "truapi-server" +version = "0.1.0" +edition.workspace = true +description = "TrUAPI server runtime: dispatcher, frames, SCALE, streams" +license = "MIT" + +[lib] +crate-type = ["rlib", "cdylib"] + +[features] +default = [] + +[dependencies] +truapi = { path = "../truapi" } +truapi-platform = { path = "../truapi-platform" } +async-trait = "0.1" +derive_more = { version = "2", features = ["display"] } +futures = "0.3" +futures-timer = { version = "3", features = ["wasm-bindgen"] } +parity-scale-codec = { version = "3", features = ["derive"] } +primitive-types = { version = "0.13", default-features = false, features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +unicode-normalization = "0.1" +url = "2" +hex = "0.4" +blake2-rfc = { version = "0.2", default-features = false } +sp-crypto-hashing = { version = "0.1", default-features = false } +bs58 = { version = "0.5", default-features = false, features = ["alloc"] } +schnorrkel = { version = "0.11.5", default-features = false, features = ["alloc", "getrandom"] } +getrandom = { version = "0.2", features = ["js"] } +p256 = { version = "0.13", default-features = false, features = ["ecdh"] } +hkdf = "0.12" +sha2 = "0.10" +aes-gcm = { version = "0.10", default-features = false, features = ["aes", "alloc"] } +tracing = "0.1" +# `registry` + `std` only: pulls the Registry + per-layer filter/reload, but +# not `env-filter` (which drags in `regex`, heavy on wasm). +tracing-subscriber = { version = "0.3", default-features = false, features = ["registry", "std"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +subxt-rpcs = { version = "0.50.1", default-features = false, features = ["native"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = "0.3" +subxt-rpcs = { version = "0.50.1", default-features = false, features = ["web"] } +wasm-bindgen = "0.2.118" +wasm-bindgen-futures = "0.4" +console_error_panic_hook = "0.1" +futures-util = "0.3" +pin-project = "1" +send_wrapper = { version = "0.6", features = ["futures"] } +web-time = "1" +web-sys = { version = "0.3", features = [ + "BinaryType", + "CloseEvent", + "console", + "Event", + "MessageEvent", + "WebSocket", +] } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md new file mode 100644 index 00000000..c4a91856 --- /dev/null +++ b/rust/crates/truapi-server/README.md @@ -0,0 +1,33 @@ +# truapi-server + +_Runtime core for TrUAPI: dispatcher, protocol frames, SCALE-coded wire envelope._ + +## What this crate is for + +`truapi-server` is the runtime that turns trait implementations of the +`truapi` API into a working host. It owns: + +- the [`ProtocolMessage`] wire envelope and SCALE codec +- the [`Dispatcher`] that routes incoming frames to per-method handlers +- the subscription lifecycle (start/receive/stop/interrupt) +- the [`Transport`] trait that platform-specific IPC backends implement +- the auto-generated dispatcher/wire-table tables shipped under + [`crate::generated`] + +## Wire envelope + +Every frame on the wire is encoded as: + +```text +[requestId: SCALE str][discriminant: u8][payload bytes...] +``` + +The discriminant identifies a method + frame kind via the auto-generated +[`crate::generated::wire_table::WIRE_TABLE`]. Each method's ids are exposed +as a named const (`PREIMAGE_SUBMIT`, ...); both `WIRE_TABLE` and the generated +dispatcher reference those consts. Method ordering is part of the wire +protocol; only ever append. + +The payload bytes are the SCALE-encoded inner value, inlined without a +length prefix. The discriminant is carried directly as `Payload::id`, and the +dispatcher routes on that numeric id via id-keyed tables. diff --git a/rust/crates/truapi-server/src/host_logic.rs b/rust/crates/truapi-server/src/host_logic.rs new file mode 100644 index 00000000..183688fa --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic.rs @@ -0,0 +1,16 @@ +//! Host-agnostic logic the Rust core owns on behalf of every platform host. +//! +//! Platform callbacks are a syscall layer for OS primitives (modals, native +//! storage, URL handler, notification center). Everything else lives here so +//! iOS, Android, and web hosts share one canonical implementation. + +pub mod dotns; +pub mod entropy; +pub mod features; +pub mod identity; +pub mod permissions; +pub mod product_account; +pub mod session; +pub mod session_store; +pub mod sso; +pub mod statement_store; diff --git a/rust/crates/truapi-server/src/host_logic/dotns.rs b/rust/crates/truapi-server/src/host_logic/dotns.rs new file mode 100644 index 00000000..67f93351 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/dotns.rs @@ -0,0 +1,473 @@ +//! dotns URL parsing, normalization, and classification. +//! +//! The Rust core owns the whole decision so every platform host sees the +//! same categorization and the `navigate_to` callback only receives +//! already-validated input. + +use unicode_normalization::UnicodeNormalization; +use url::Url; + +/// How the input URL should be opened. Kept in one enum rather than passing +/// a raw string so the dispatcher can reject invalid input before reaching +/// any platform callback. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NavigateDecision { + /// A `.dot` identifier plus path/query/hash suffix (no leading `/`). + DotName { + /// Lower-cased `.dot` host (e.g. `mytestapp.dot`). + identifier: String, + /// Path/query/hash suffix without a leading `/`. + path: String, + }, + /// A `localhost[:port]` URL plus path/query/hash suffix (no leading `/`). + Localhost { + /// `localhost` with optional `:port` suffix. + host: String, + /// Path/query/hash suffix without a leading `/`. + path: String, + }, + /// An absolute external URL with an `http(s):` scheme prepended if missing. + External { + /// Canonical URL string. + url: String, + }, + /// Input that fails every branch: empty, unparseable, or a `.dot` URL + /// carrying port/userinfo (both forbidden since dotns resolves via the + /// chain and has no notion of either). + Reject { + /// Human-readable reason for the rejection. + reason: String, + }, +} + +impl NavigateDecision { + /// Canonical URL string for the three `Open*` variants; `None` for + /// `Reject`. `DotName` and `Localhost` keep the dotns/localhost identity + /// visible so env-aware hosts (e.g. dotli rewriting `.dot` to `.dot.li`) + /// can re-parse and do their own assembly without losing information. + pub fn canonical_url(&self) -> Option { + match self { + Self::DotName { identifier, path } => Some(join_url("https://", identifier, path)), + Self::Localhost { host, path } => Some(join_url("http://", host, path)), + Self::External { url } => Some(url.clone()), + Self::Reject { .. } => None, + } + } +} + +fn join_url(scheme: &str, host: &str, path: &str) -> String { + if path.is_empty() { + format!("{scheme}{host}") + } else { + format!("{scheme}{host}/{path}") + } +} + +/// Classify a URL the way dotli's `handleNavigateTo` does: try `.dot` first, +/// then `localhost`, then normalize as external. +pub fn parse_navigate(input: &str) -> NavigateDecision { + let trimmed = input.trim(); + if trimmed.is_empty() { + return NavigateDecision::Reject { + reason: "empty input".to_string(), + }; + } + + if let Some(decision) = classify_dot(trimmed) { + return decision; + } + + if let Some(decision) = classify_localhost(trimmed) { + return decision; + } + + match normalize_external(trimmed) { + Ok(url) => NavigateDecision::External { url }, + Err(reason) => NavigateDecision::Reject { reason }, + } +} + +/// Canonical host form: case-folded and NFC-normalized (belt-and-suspenders; +/// `url` already applies IDNA to parsed hosts), with a trailing root dot +/// dropped so the absolute form `example.dot.` keys identically to +/// `example.dot`. +fn normalize_host(host: &str) -> String { + let normalized: String = host.nfc().collect::().to_lowercase(); + normalized + .strip_suffix('.') + .unwrap_or(&normalized) + .to_string() +} + +/// `.dot` TLD check, applied to the [`normalize_host`] form so `Example.DOT` +/// and the trailing-dot FQDN `example.dot.` classify like `example.dot`. +fn is_dot_domain(host: &str) -> bool { + normalize_host(host).ends_with(".dot") +} + +fn parse_with_explicit_https(input: &str) -> Option { + if let Ok(direct) = Url::parse(input) { + return Some(direct); + } + Url::parse(&format!("https://{input}")).ok() +} + +/// Recognize `.dot` URLs (including the `polkadot://` scheme). Returns: +/// - `Some(DotName)` for a clean `.dot` URL +/// - `Some(Reject)` for a `.dot` URL with port or userinfo +/// - `None` when the input isn't a `.dot` URL (caller falls through to +/// localhost / external) +fn classify_dot(input: &str) -> Option { + let parsed = if input.starts_with("polkadot://") { + Url::parse(input).ok()? + } else { + parse_with_explicit_https(input)? + }; + + let hostname = parsed.host_str()?; + if !is_dot_domain(hostname) { + return None; + } + + if parsed.port().is_some() || !parsed.username().is_empty() || parsed.password().is_some() { + return Some(NavigateDecision::Reject { + reason: format!("{hostname} carries port or userinfo; dotns forbids both"), + }); + } + + Some(NavigateDecision::DotName { + identifier: normalize_host(hostname), + path: strip_leading_slash(parsed.path()) + &suffix(&parsed), + }) +} + +/// Recognize `localhost[:port]` URLs, with or without an explicit scheme. +fn classify_localhost(input: &str) -> Option { + let with_scheme = if input.starts_with("localhost") { + format!("http://{input}") + } else { + input.to_string() + }; + + let parsed = Url::parse(&with_scheme).ok()?; + if parsed.host_str()? != "localhost" { + return None; + } + + let host = match parsed.port() { + Some(port) => format!("localhost:{port}"), + None => "localhost".to_string(), + }; + + Some(NavigateDecision::Localhost { + host, + path: strip_leading_slash(parsed.path()) + &suffix(&parsed), + }) +} + +/// External URL scheme allowlist. Anything outside this set is treated as +/// a [`NavigateDecision::Reject`] so dangerous schemes (`javascript:`, +/// `data:`, `file:`, `vbscript:`, ...) cannot reach `Platform::navigate_to`. +const ALLOWED_EXTERNAL_SCHEMES: &[&str] = &["http", "https", "mailto", "tel", "polkadot", "dot"]; + +/// Mirrors `normalizeUrl`: prepend `https://` if missing, otherwise pass the +/// URL through as its canonical string form. Returns `Err(reason)` for an +/// unparseable input or a scheme outside [`ALLOWED_EXTERNAL_SCHEMES`]. +fn normalize_external(input: &str) -> Result { + // `parse_with_explicit_https` returns a successful direct parse as-is and + // only prepends `https://` when the direct parse fails, so a disallowed + // scheme (e.g. `javascript:`) is never rewritten to https: the single + // scheme check below rejects it. + let url = parse_with_explicit_https(input) + .ok_or_else(|| "URL constructor rejected input".to_string())?; + if !ALLOWED_EXTERNAL_SCHEMES.contains(&url.scheme()) { + return Err(format!("scheme `{}` is not allowed", url.scheme())); + } + Ok(url.to_string()) +} + +fn strip_leading_slash(path: &str) -> String { + path.strip_prefix('/').unwrap_or(path).to_string() +} + +fn suffix(url: &Url) -> String { + let mut out = String::new(); + if let Some(q) = url.query() { + out.push('?'); + out.push_str(q); + } + if let Some(f) = url.fragment() { + out.push('#'); + out.push_str(f); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + enum Expected { + Decision(NavigateDecision), + AnyExternalOrReject, + Reject, + } + + struct TestCase { + name: &'static str, + input: &'static str, + expected: Expected, + } + + fn dot(identifier: &str, path: &str) -> Expected { + Expected::Decision(NavigateDecision::DotName { + identifier: identifier.to_string(), + path: path.to_string(), + }) + } + + fn localhost(host: &str, path: &str) -> Expected { + Expected::Decision(NavigateDecision::Localhost { + host: host.to_string(), + path: path.to_string(), + }) + } + + fn external(url: &str) -> Expected { + Expected::Decision(NavigateDecision::External { + url: url.to_string(), + }) + } + + #[test] + fn parse_navigate_cases() { + let cases = vec![ + TestCase { + name: "dot bare", + input: "mytestapp.dot", + expected: dot("mytestapp.dot", ""), + }, + TestCase { + name: "dot trailing root dot", + input: "example.dot.", + expected: dot("example.dot", ""), + }, + TestCase { + name: "dot trailing root dot with path", + input: "https://example.dot./path", + expected: dot("example.dot", "path"), + }, + TestCase { + name: "dot li is external", + input: "mytestapp.dot.li", + expected: external("https://mytestapp.dot.li/"), + }, + TestCase { + name: "dot with https", + input: "https://mytestapp.dot", + expected: dot("mytestapp.dot", ""), + }, + TestCase { + name: "dot with http", + input: "http://mytestapp.dot", + expected: dot("mytestapp.dot", ""), + }, + TestCase { + name: "dot with path", + input: "mytestapp.dot/some/path", + expected: dot("mytestapp.dot", "some/path"), + }, + TestCase { + name: "dot with query only", + input: "pr508.faucet.dot?embed=1", + expected: dot("pr508.faucet.dot", "?embed=1"), + }, + TestCase { + name: "dot with hash only", + input: "pr508.faucet.dot#section=main", + expected: dot("pr508.faucet.dot", "#section=main"), + }, + TestCase { + name: "dot with path query hash", + input: "pr508.faucet.dot/nested/path?embed=1#frame=compact", + expected: dot("pr508.faucet.dot", "nested/path?embed=1#frame=compact"), + }, + TestCase { + name: "polkadot scheme dot host", + input: "polkadot://currenthost.dot/mytestapp.dot", + expected: dot("currenthost.dot", "mytestapp.dot"), + }, + TestCase { + name: "polkadot scheme non dot host falls through", + input: "polkadot://example.com/settings", + expected: Expected::AnyExternalOrReject, + }, + TestCase { + name: "polkadot scheme with path", + input: "polkadot://currenthost.dot/mytestapp.dot/settings", + expected: dot("currenthost.dot", "mytestapp.dot/settings"), + }, + TestCase { + name: "polkadot scheme with query and hash", + input: "polkadot://currenthost.dot/mytestapp.dot?embed=1#frame=compact", + expected: dot("currenthost.dot", "mytestapp.dot?embed=1#frame=compact"), + }, + TestCase { + name: "dot subdomain", + input: "sub.acme.dot/path", + expected: dot("sub.acme.dot", "path"), + }, + TestCase { + name: "dot mixed case", + input: "Example.DOT/Path", + expected: dot("example.dot", "Path"), + }, + TestCase { + name: "dot with port is rejected", + input: "https://x.dot:8080/path", + expected: Expected::Reject, + }, + TestCase { + name: "dot with userinfo is rejected", + input: "https://user:pass@x.dot/path", + expected: Expected::Reject, + }, + TestCase { + name: "trim whitespace", + input: " mytestapp.dot/path ", + expected: dot("mytestapp.dot", "path"), + }, + TestCase { + name: "localhost bare with port", + input: "localhost:3000", + expected: localhost("localhost:3000", ""), + }, + TestCase { + name: "localhost with port and path", + input: "localhost:3000/some/path", + expected: localhost("localhost:3000", "some/path"), + }, + TestCase { + name: "localhost with explicit http", + input: "http://localhost:5000", + expected: localhost("localhost:5000", ""), + }, + TestCase { + name: "localhost with http and path", + input: "http://localhost:5000/path", + expected: localhost("localhost:5000", "path"), + }, + TestCase { + name: "localhost with query and hash", + input: "localhost:3000/path?q=1#h", + expected: localhost("localhost:3000", "path?q=1#h"), + }, + TestCase { + name: "localhost without port", + input: "localhost", + expected: localhost("localhost", ""), + }, + TestCase { + name: "localhost without port with path", + input: "localhost/path", + expected: localhost("localhost", "path"), + }, + TestCase { + name: "external bare domain", + input: "google.com", + expected: external("https://google.com/"), + }, + TestCase { + name: "external bare domain with path", + input: "google.com/search?q=test", + expected: external("https://google.com/search?q=test"), + }, + TestCase { + name: "external preserves https", + input: "https://example.com/page", + expected: external("https://example.com/page"), + }, + TestCase { + name: "external preserves http", + input: "http://example.com/page", + expected: external("http://example.com/page"), + }, + TestCase { + name: "external dot li", + input: "acme.dot.li/path/1", + expected: external("https://acme.dot.li/path/1"), + }, + TestCase { + name: "reject empty", + input: "", + expected: Expected::Reject, + }, + TestCase { + name: "reject whitespace", + input: " ", + expected: Expected::Reject, + }, + TestCase { + name: "reject unparseable", + input: ":::invalid", + expected: Expected::Reject, + }, + TestCase { + name: "reject javascript URI", + input: "javascript:alert(1)", + expected: Expected::Reject, + }, + TestCase { + name: "reject file URI", + input: "file:///etc/passwd", + expected: Expected::Reject, + }, + TestCase { + name: "reject data URI", + input: "data:text/html,", + expected: Expected::Reject, + }, + TestCase { + name: "reject vbscript URI", + input: "vbscript:msgbox(1)", + expected: Expected::Reject, + }, + ]; + + for case in cases { + let actual = parse_navigate(case.input); + match case.expected { + Expected::Decision(expected) => assert_eq!(actual, expected, "{}", case.name), + Expected::AnyExternalOrReject => assert!( + matches!( + actual, + NavigateDecision::External { .. } | NavigateDecision::Reject { .. } + ), + "{}: expected External or Reject, got {actual:?}", + case.name, + ), + Expected::Reject => assert!( + matches!(actual, NavigateDecision::Reject { .. }), + "{}: expected Reject, got {actual:?}", + case.name, + ), + } + } + + let nfc = parse_navigate("café.dot"); + let nfd = parse_navigate("cafe\u{0301}.dot"); + match (&nfc, &nfd) { + ( + NavigateDecision::DotName { + identifier: a, + path: _, + }, + NavigateDecision::DotName { + identifier: b, + path: _, + }, + ) => assert_eq!(a, b, "NFC and NFD inputs must normalize to one identifier"), + other => panic!("expected two DotName decisions, got {other:?}"), + } + } +} diff --git a/rust/crates/truapi-server/src/host_logic/entropy.rs b/rust/crates/truapi-server/src/host_logic/entropy.rs new file mode 100644 index 00000000..68e9e1fb --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/entropy.rs @@ -0,0 +1,113 @@ +//! Product-scoped deterministic entropy derivation. +//! +//! Matches dotli's product entropy contract: three keyed BLAKE2b-256 layers +//! over the session secret, product id, and caller key. + +use blake2_rfc::blake2b::blake2b; +use thiserror::Error; + +const DOMAIN_SEPARATOR: &[u8] = b"product-entropy-derivation"; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ProductEntropyError { + #[error("\"key\" must be between 1 and 32 bytes, got {0}")] + InvalidKeyLength(usize), + #[error("entropy secret is missing")] + MissingSecret, +} + +/// Derive product-scoped entropy from the session root entropy secret. +pub fn derive_product_entropy( + entropy_secret: &[u8], + product_id: &str, + key: &[u8], +) -> Result<[u8; 32], ProductEntropyError> { + let root_entropy_source = blake2b256_keyed(entropy_secret, DOMAIN_SEPARATOR); + derive_product_entropy_from_source(&root_entropy_source, product_id, key) +} + +/// Derive product-scoped entropy from an already normalized root entropy source. +pub fn derive_product_entropy_from_source( + root_entropy_source: &[u8; 32], + product_id: &str, + key: &[u8], +) -> Result<[u8; 32], ProductEntropyError> { + if key.is_empty() || key.len() > 32 { + return Err(ProductEntropyError::InvalidKeyLength(key.len())); + } + + let product_id_hash = blake2b256(product_id.as_bytes()); + let per_product_entropy = blake2b256_keyed(root_entropy_source, &product_id_hash); + Ok(blake2b256_keyed(&per_product_entropy, key)) +} + +fn blake2b256_keyed(message: &[u8], key: &[u8]) -> [u8; 32] { + let hash = blake2b(32, key, message); + hash.as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b256_keyed(message, &[]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn secret() -> [u8; 32] { + let mut secret = [0u8; 32]; + for (i, byte) in secret.iter_mut().enumerate() { + *byte = i as u8; + } + secret + } + + #[test] + fn product_entropy_cases() { + struct SuccessCase { + name: &'static str, + product_id: &'static str, + key: Vec, + expected_hex: &'static str, + } + + let success_cases = vec![ + SuccessCase { + name: "single byte key", + product_id: "myapp.dot", + key: vec![1], + expected_hex: "4bafd6a34182959bad8914dcff88c6b6842d551d6f0067afbd407e9584223404", + }, + SuccessCase { + name: "text key", + product_id: "myapp.dot", + key: b"product-key".to_vec(), + expected_hex: "ab1887248c9de3cf4b8c5a255782796d3d35a98c8eb2d7df61a410db8b14da36", + }, + SuccessCase { + name: "localhost product", + product_id: "localhost:3000", + key: (0..32).map(|i| 255 - i).collect(), + expected_hex: "437d0a6236c51fe114cf6a16b79c9c2b5f95b1e105e2d5269cc254a8c593925f", + }, + ]; + + for case in success_cases { + let entropy = derive_product_entropy(&secret(), case.product_id, &case.key).unwrap(); + assert_eq!(hex::encode(entropy), case.expected_hex, "{}", case.name); + } + + let error_cases = vec![ + (Vec::new(), ProductEntropyError::InvalidKeyLength(0)), + (vec![0u8; 33], ProductEntropyError::InvalidKeyLength(33)), + ]; + for (key, expected) in error_cases { + assert_eq!( + derive_product_entropy(&secret(), "myapp.dot", &key).unwrap_err(), + expected, + ); + } + } +} diff --git a/rust/crates/truapi-server/src/host_logic/features.rs b/rust/crates/truapi-server/src/host_logic/features.rs new file mode 100644 index 00000000..a71a106b --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/features.rs @@ -0,0 +1,66 @@ +//! Feature-detection delegation. +//! +//! `feature_supported` is a platform syscall: each host owns the set of +//! chains it can service. This module is a thin shim that forwards the +//! request through to [`truapi_platform::Features`]. + +use truapi::v01::{GenericError, HostFeatureSupportedRequest, HostFeatureSupportedResponse}; +use truapi_platform::Features; + +/// Forward a feature-support query to the platform implementation. +pub async fn feature_supported( + platform: &P, + request: HostFeatureSupportedRequest, +) -> Result { + platform.feature_supported(request).await +} + +#[cfg(test)] +mod tests { + use super::*; + + struct AlwaysSupported; + + #[truapi_platform::async_trait] + impl Features for AlwaysSupported { + async fn feature_supported( + &self, + request: HostFeatureSupportedRequest, + ) -> Result { + assert!(matches!(request, HostFeatureSupportedRequest::Chain { .. })); + Ok(HostFeatureSupportedResponse { supported: true }) + } + } + + struct AlwaysUnsupported; + + #[truapi_platform::async_trait] + impl Features for AlwaysUnsupported { + async fn feature_supported( + &self, + request: HostFeatureSupportedRequest, + ) -> Result { + assert!(matches!(request, HostFeatureSupportedRequest::Chain { .. })); + Ok(HostFeatureSupportedResponse { supported: false }) + } + } + + fn req() -> HostFeatureSupportedRequest { + HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + } + } + + #[test] + fn delegates_supported_to_platform() { + let resp = futures::executor::block_on(feature_supported(&AlwaysSupported, req())).unwrap(); + assert!(resp.supported); + } + + #[test] + fn delegates_unsupported_to_platform() { + let resp = + futures::executor::block_on(feature_supported(&AlwaysUnsupported, req())).unwrap(); + assert!(!resp.supported); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/identity.rs b/rust/crates/truapi-server/src/host_logic/identity.rs new file mode 100644 index 00000000..0fbac76f --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/identity.rs @@ -0,0 +1,123 @@ +//! People-chain identity lookup for paired SSO sessions. +//! +//! dotli's previous host-papp path read `Resources.Consumers[account]` from +//! the People chain and used only the username fields. Keep this module narrow: +//! it builds that storage key and decodes the leading username fields from the +//! SCALE value. The record begins with a fixed identifier public key; credibility +//! and statement-store slots are intentionally ignored. + +use parity_scale_codec::Decode; +use sp_crypto_hashing::{blake2_128, twox_128}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PeopleIdentity { + pub lite_username: Option, + pub full_username: Option, +} + +#[derive(Debug, Decode)] +struct ConsumerUsernamePrefix { + full_username: Option>, + lite_username: Vec, +} + +/// Build the People-chain `Resources.Consumers` storage key for `account_id`. +pub fn resources_consumers_storage_key(account_id: &[u8; 32]) -> Vec { + let mut key = Vec::with_capacity(32 + 16 + account_id.len()); + key.extend_from_slice(&twox_128(b"Resources")); + key.extend_from_slice(&twox_128(b"Consumers")); + key.extend_from_slice(&blake2_128(account_id)); + key.extend_from_slice(account_id); + key +} + +/// Decode the username fields from a `Resources.Consumers` storage value. +pub fn decode_people_identity(value: &[u8]) -> Result { + if value.len() < 65 { + return Err(format!( + "invalid Resources.Consumers record: expected 65-byte identifier key, got {} bytes", + value.len() + )); + } + + // ConsumerInfo starts with a fixed 65-byte P-256 identifier key. The + // username fields follow immediately after it. + let mut input = &value[65..]; + let decoded = ConsumerUsernamePrefix::decode(&mut input) + .map_err(|err| format!("invalid Resources.Consumers record: {err}"))?; + let lite_username = non_empty_string(decoded.lite_username)?; + let full_username = decoded + .full_username + .map(non_empty_string) + .transpose()? + .flatten(); + Ok(PeopleIdentity { + lite_username, + full_username, + }) +} + +fn non_empty_string(bytes: Vec) -> Result, String> { + if bytes.is_empty() { + return Ok(None); + } + let value = String::from_utf8(bytes) + .map_err(|err| format!("Resources.Consumers username is not UTF-8: {err}"))?; + Ok(Some(value)) +} + +#[cfg(test)] +mod tests { + use super::*; + use parity_scale_codec::Encode; + + #[test] + fn resources_consumers_key_uses_expected_prefix() { + let key = resources_consumers_storage_key(&[0x42; 32]); + + assert_eq!(key.len(), 80); + assert_eq!(&key[..16], &twox_128(b"Resources")); + assert_eq!(&key[16..32], &twox_128(b"Consumers")); + assert_eq!(&key[48..], &[0x42; 32]); + } + + #[test] + fn twox128_matches_substrate_storage_prefix_vector() { + assert_eq!( + hex::encode(twox_128(b"System")), + "26aa394eea5630e07c48ae0c9558cef7" + ); + } + + #[test] + fn decodes_username_prefix_and_ignores_trailing_fields() { + let mut value = vec![0x04; 65]; + value.extend((Some(b"Alice Smith".to_vec()), b"alice.01".to_vec()).encode()); + value.extend_from_slice(&[0xff; 8]); + + let decoded = decode_people_identity(&value).expect("identity should decode"); + + assert_eq!(decoded.full_username.as_deref(), Some("Alice Smith")); + assert_eq!(decoded.lite_username.as_deref(), Some("alice.01")); + } + + #[test] + fn empty_full_username_is_none() { + let mut value = vec![0x04; 65]; + value.extend((Some(Vec::::new()), b"alice.01".to_vec()).encode()); + + let decoded = decode_people_identity(&value).expect("identity should decode"); + + assert_eq!(decoded.full_username, None); + assert_eq!(decoded.lite_username.as_deref(), Some("alice.01")); + } + + #[test] + fn rejects_missing_identifier_key() { + let value = (None::>, b"alice.01".to_vec()).encode(); + + let error = decode_people_identity(&value).expect_err("identity should reject"); + + assert!(error.contains("65-byte identifier key")); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs new file mode 100644 index 00000000..b1189689 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -0,0 +1,706 @@ +//! Permission authorization state machine (ask -> authorized | denied), backed +//! by the platform [`CoreStorage`] trait with typed [`CoreStorageKey`] slots. +//! +//! Device permissions (camera, mic, NFC, ...) are separate from remote +//! permissions (domain access, chain submit, ...), so this module exposes two +//! `check_or_prompt` entrypoints that route to the matching platform callback. +//! The cache layer is shared but keys are typed so a device grant cannot +//! authorize a remote operation by accident. Keys are also scoped by product id +//! so one product's authorization never grants another product's request. + +use parity_scale_codec::{Decode, Encode}; + +use truapi::latest::{ + GenericError, HostDevicePermissionRequest, HostDevicePermissionResponse, RemotePermission, + RemotePermissionRequest, RemotePermissionResponse, +}; +use truapi_platform::{ + CoreStorage, CoreStorageKey, PermissionAuthorizationRequest, PermissionAuthorizationStatus, + Permissions, +}; + +/// Persisted answer for a single permission request. Keep `Authorized` at +/// discriminant 0 and `Denied` at 1 to preserve the existing two-variant cache +/// encoding. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +enum StoredAuthorizationStatus { + /// User authorized the permission. + Authorized, + /// User denied the permission. + Denied, +} + +impl From for PermissionAuthorizationStatus { + fn from(status: StoredAuthorizationStatus) -> Self { + match status { + StoredAuthorizationStatus::Authorized => PermissionAuthorizationStatus::Authorized, + StoredAuthorizationStatus::Denied => PermissionAuthorizationStatus::Denied, + } + } +} + +impl From for StoredAuthorizationStatus { + fn from(granted: bool) -> Self { + if granted { + Self::Authorized + } else { + Self::Denied + } + } +} + +/// Coordinator that inspects persisted state first, falls back to the +/// platform's prompt callback, and writes the authorization back so future +/// calls short-circuit. +pub struct PermissionsService<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> { + storage: &'a S, + prompt: &'a P, + product_id: &'a str, +} + +impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a, S, P> { + /// Construct a service backed by the given storage + prompt callbacks. + pub fn new(storage: &'a S, prompt: &'a P, product_id: &'a str) -> Self { + Self { + storage, + prompt, + product_id, + } + } + + /// Returns the stored authorization status for a device permission without prompting. + pub async fn peek_device( + &self, + permission: &HostDevicePermissionRequest, + ) -> Result { + authorization_status( + self.storage, + device_core_storage_key(self.product_id, permission), + ) + .await + } + + /// Returns the stored authorization status for a remote permission without + /// prompting. + pub async fn peek_remote( + &self, + request: &RemotePermissionRequest, + ) -> Result { + authorization_status( + self.storage, + remote_core_storage_key(self.product_id, request), + ) + .await + } + + /// Returns the stored authorization status for a permission request + /// without prompting. + pub async fn authorization_status( + &self, + request: &PermissionAuthorizationRequest, + ) -> Result { + match request { + PermissionAuthorizationRequest::Device(permission) => { + self.peek_device(permission).await + } + PermissionAuthorizationRequest::Remote(request) => self.peek_remote(request).await, + } + } + + /// Returns the stored authorization statuses for permission requests + /// without prompting. Results follow the same order as `requests`. + pub async fn authorization_statuses( + &self, + requests: &[PermissionAuthorizationRequest], + ) -> Result, GenericError> { + let mut statuses = Vec::with_capacity(requests.len()); + for request in requests { + statuses.push(self.authorization_status(request).await?); + } + Ok(statuses) + } + + /// Update the stored authorization status for a permission request. + /// + /// Setting `NotDetermined` clears the stored value so the next product + /// request prompts again. + pub async fn set_authorization_status( + &self, + request: &PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), GenericError> { + let key = match request { + PermissionAuthorizationRequest::Device(permission) => { + device_core_storage_key(self.product_id, permission) + } + PermissionAuthorizationRequest::Remote(request) => { + remote_core_storage_key(self.product_id, request) + } + }; + set_authorization_status(self.storage, key, status).await + } + + /// Returns the cached device authorization if any, otherwise prompts the + /// platform's `device_permission` callback and persists the answer. + pub async fn check_or_prompt_device( + &self, + permission: HostDevicePermissionRequest, + ) -> Result { + let key = device_core_storage_key(self.product_id, &permission); + if let Some(cached) = peek_stored(self.storage, key.clone()).await? { + return Ok(cached.into()); + } + // Only a genuine user authorization is persisted. A prompt-callback error is + // transient (UI unavailable, IPC timeout), not a denial, so fail closed + // for this call but do not cache it — the next request re-prompts rather + // than locking the capability out permanently with no revoke path. + let authorization = match self.prompt.device_permission(permission).await { + Ok(HostDevicePermissionResponse { granted }) => granted.into(), + Err(_) => return Ok(PermissionAuthorizationStatus::Denied), + }; + self.persist_decision(key, authorization).await + } + + /// Returns the cached remote authorization if any, otherwise prompts the + /// platform's `remote_permission` callback and persists the answer. + pub async fn check_or_prompt_remote( + &self, + request: RemotePermissionRequest, + ) -> Result { + let key = remote_core_storage_key(self.product_id, &request); + if let Some(cached) = peek_stored(self.storage, key.clone()).await? { + return Ok(cached.into()); + } + // See `check_or_prompt_device`: persist only a genuine user decision; a + // transient callback error fails closed for this call without caching. + let authorization = match self.prompt.remote_permission(request).await { + Ok(RemotePermissionResponse { granted }) => granted.into(), + Err(_) => return Ok(PermissionAuthorizationStatus::Denied), + }; + self.persist_decision(key, authorization).await + } + + /// Persist a fresh user decision and return its public status. + async fn persist_decision( + &self, + key: CoreStorageKey, + authorization: StoredAuthorizationStatus, + ) -> Result { + self.storage + .write_core_storage(key, authorization.encode()) + .await?; + Ok(authorization.into()) + } +} + +async fn authorization_status( + storage: &S, + key: CoreStorageKey, +) -> Result { + Ok(peek_stored(storage, key) + .await? + .map(Into::into) + .unwrap_or(PermissionAuthorizationStatus::NotDetermined)) +} + +async fn peek_stored( + storage: &S, + key: CoreStorageKey, +) -> Result, GenericError> { + let Some(raw) = storage.read_core_storage(key).await? else { + return Ok(None); + }; + Ok(StoredAuthorizationStatus::decode(&mut &*raw).ok()) +} + +async fn set_authorization_status( + storage: &S, + key: CoreStorageKey, + status: PermissionAuthorizationStatus, +) -> Result<(), GenericError> { + match status_into_stored(status) { + Some(stored) => storage.write_core_storage(key, stored.encode()).await, + None => storage.clear_core_storage(key).await, + } +} + +fn status_into_stored(status: PermissionAuthorizationStatus) -> Option { + match status { + PermissionAuthorizationStatus::NotDetermined => None, + PermissionAuthorizationStatus::Denied => Some(StoredAuthorizationStatus::Denied), + PermissionAuthorizationStatus::Authorized => Some(StoredAuthorizationStatus::Authorized), + } +} + +fn device_core_storage_key( + product_id: &str, + permission: &HostDevicePermissionRequest, +) -> CoreStorageKey { + CoreStorageKey::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::Device(*permission), + } +} + +fn remote_core_storage_key(product_id: &str, request: &RemotePermissionRequest) -> CoreStorageKey { + CoreStorageKey::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::Remote(canonical_remote_request(request)), + } +} + +fn canonical_remote_request(request: &RemotePermissionRequest) -> RemotePermissionRequest { + let permission = match &request.permission { + RemotePermission::Remote { domains } => { + // DNS domains are case-insensitive, so a logically-identical bundle + // requested with different casing or duplicate entries must + // canonicalize to one key (no spurious re-prompt). + let mut canonical: Vec = + domains.iter().map(|d| d.to_ascii_lowercase()).collect(); + canonical.sort(); + canonical.dedup(); + RemotePermission::Remote { domains: canonical } + } + other => other.clone(), + }; + RemotePermissionRequest { permission } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::lock::Mutex; + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + use truapi::v01; + use truapi::v01::GenericError; + + #[derive(Default)] + struct MemStorage { + inner: Mutex>>, + } + + #[truapi_platform::async_trait] + impl CoreStorage for MemStorage { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + Ok(self.inner.lock().await.get(&test_key(key)).cloned()) + } + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + self.inner.lock().await.insert(test_key(key), value); + Ok(()) + } + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), v01::GenericError> { + self.inner.lock().await.remove(&test_key(key)); + Ok(()) + } + } + + fn test_key(key: CoreStorageKey) -> String { + hex::encode(key.encode()) + } + + struct ScriptedPrompt { + device_answers: Mutex>, + remote_answers: Mutex>, + device_calls: AtomicUsize, + remote_calls: AtomicUsize, + } + + impl ScriptedPrompt { + fn new(device_answers: Vec, remote_answers: Vec) -> Self { + Self { + device_answers: Mutex::new(device_answers), + remote_answers: Mutex::new(remote_answers), + device_calls: AtomicUsize::new(0), + remote_calls: AtomicUsize::new(0), + } + } + } + + #[truapi_platform::async_trait] + impl Permissions for ScriptedPrompt { + async fn device_permission( + &self, + _request: HostDevicePermissionRequest, + ) -> Result { + self.device_calls.fetch_add(1, Ordering::SeqCst); + let granted = self + .device_answers + .lock() + .await + .pop() + .expect("ScriptedPrompt ran out of device answers"); + Ok(v01::HostDevicePermissionResponse { granted }) + } + + async fn remote_permission( + &self, + _request: RemotePermissionRequest, + ) -> Result { + self.remote_calls.fetch_add(1, Ordering::SeqCst); + let granted = self + .remote_answers + .lock() + .await + .pop() + .expect("ScriptedPrompt ran out of remote answers"); + Ok(v01::RemotePermissionResponse { granted }) + } + } + + #[test] + fn core_storage_key_separates_product_device_and_remote_variants() { + let camera = device_core_storage_key("product.dot", &HostDevicePermissionRequest::Camera); + let other_product = + device_core_storage_key("other.dot", &HostDevicePermissionRequest::Camera); + let remote = remote_core_storage_key( + "product.dot", + &RemotePermissionRequest { + permission: RemotePermission::ChainSubmit, + }, + ); + + assert_ne!(camera, other_product); + assert_ne!(camera, remote); + } + + #[test] + fn remote_core_storage_key_canonicalizes_domain_sets() { + let unsorted = RemotePermissionRequest { + permission: RemotePermission::Remote { + domains: vec!["b.example.com".into(), "a.example.com".into()], + }, + }; + let sorted = RemotePermissionRequest { + permission: RemotePermission::Remote { + domains: vec!["a.example.com".into(), "b.example.com".into()], + }, + }; + assert_eq!( + remote_core_storage_key("product.dot", &unsorted), + remote_core_storage_key("product.dot", &sorted) + ); + + let mixed = RemotePermissionRequest { + permission: RemotePermission::Remote { + domains: vec!["Example.COM".into(), "a.com".into(), "a.com".into()], + }, + }; + let canonical = RemotePermissionRequest { + permission: RemotePermission::Remote { + domains: vec!["a.com".into(), "example.com".into()], + }, + }; + assert_eq!( + remote_core_storage_key("product.dot", &mixed), + remote_core_storage_key("product.dot", &canonical) + ); + } + + #[test] + fn remote_core_storage_key_handles_separator_chars_in_domains() { + // Domain strings containing separator-looking text must not be able to + // forge a key that matches an unrelated permission. + let injecting = RemotePermissionRequest { + permission: RemotePermission::Remote { + domains: vec!["a|b".into(), "c,d".into(), "remote:web-rtc".into()], + }, + }; + let benign_same_set = RemotePermissionRequest { + permission: RemotePermission::Remote { + domains: vec!["x".into(), "y".into(), "z".into()], + }, + }; + let injecting_key = remote_core_storage_key("product.dot", &injecting); + let benign_key = remote_core_storage_key("product.dot", &benign_same_set); + assert_ne!(injecting_key, benign_key); + + // The injecting permission must also be distinct from the `WebRtc` + // variant it tries to impersonate via crafted strings. + let webrtc = RemotePermissionRequest { + permission: RemotePermission::WebRtc, + }; + assert_ne!( + injecting_key, + remote_core_storage_key("product.dot", &webrtc) + ); + + // Re-ordering the same domains still collapses to a single key + // (canonicalization is order-independent). + let injecting_reordered = RemotePermissionRequest { + permission: RemotePermission::Remote { + domains: vec!["remote:web-rtc".into(), "c,d".into(), "a|b".into()], + }, + }; + assert_eq!( + injecting_key, + remote_core_storage_key("product.dot", &injecting_reordered) + ); + } + + #[test] + fn check_or_prompt_device_caches_grant() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![true], vec![]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let first = futures::executor::block_on( + service.check_or_prompt_device(HostDevicePermissionRequest::Camera), + ) + .unwrap(); + let second = futures::executor::block_on( + service.check_or_prompt_device(HostDevicePermissionRequest::Camera), + ) + .unwrap(); + + assert_eq!(first, PermissionAuthorizationStatus::Authorized); + assert_eq!(second, PermissionAuthorizationStatus::Authorized); + assert_eq!(prompt.device_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn check_or_prompt_remote_caches_denial() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![false]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let request = RemotePermissionRequest { + permission: RemotePermission::ChainSubmit, + }; + let first = + futures::executor::block_on(service.check_or_prompt_remote(request.clone())).unwrap(); + let second = futures::executor::block_on(service.check_or_prompt_remote(request)).unwrap(); + + assert_eq!(first, PermissionAuthorizationStatus::Denied); + assert_eq!(second, PermissionAuthorizationStatus::Denied); + assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn device_and_remote_caches_are_independent() { + let storage = MemStorage::default(); + // Device denies, remote grants. If the caches collided we'd see the + // same answer on the second call. + let prompt = ScriptedPrompt::new(vec![false], vec![true]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let device = futures::executor::block_on( + service.check_or_prompt_device(HostDevicePermissionRequest::Camera), + ) + .unwrap(); + let remote = + futures::executor::block_on(service.check_or_prompt_remote(RemotePermissionRequest { + permission: RemotePermission::ChainSubmit, + })) + .unwrap(); + + assert_eq!(device, PermissionAuthorizationStatus::Denied); + assert_eq!(remote, PermissionAuthorizationStatus::Authorized); + assert_eq!(prompt.device_calls.load(Ordering::SeqCst), 1); + assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn device_prompt_does_not_invoke_remote_callback() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![true], vec![]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let _ = futures::executor::block_on( + service.check_or_prompt_device(HostDevicePermissionRequest::Camera), + ) + .unwrap(); + assert_eq!(prompt.device_calls.load(Ordering::SeqCst), 1); + assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn remote_prompt_does_not_invoke_device_callback() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![true]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let _ = + futures::executor::block_on(service.check_or_prompt_remote(RemotePermissionRequest { + permission: RemotePermission::WebRtc, + })) + .unwrap(); + assert_eq!(prompt.device_calls.load(Ordering::SeqCst), 0); + assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn peek_returns_not_determined_until_authorized() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![true], vec![]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let before = + futures::executor::block_on(service.peek_device(&HostDevicePermissionRequest::Camera)) + .unwrap(); + assert_eq!(before, PermissionAuthorizationStatus::NotDetermined); + + futures::executor::block_on( + service.check_or_prompt_device(HostDevicePermissionRequest::Camera), + ) + .unwrap(); + + let after = + futures::executor::block_on(service.peek_device(&HostDevicePermissionRequest::Camera)) + .unwrap(); + assert_eq!(after, PermissionAuthorizationStatus::Authorized); + } + + #[test] + fn set_authorization_status_writes_and_clears() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + let request = PermissionAuthorizationRequest::Device(HostDevicePermissionRequest::Camera); + + futures::executor::block_on( + service.set_authorization_status(&request, PermissionAuthorizationStatus::Authorized), + ) + .unwrap(); + assert_eq!( + futures::executor::block_on(service.authorization_status(&request)).unwrap(), + PermissionAuthorizationStatus::Authorized + ); + + futures::executor::block_on( + service + .set_authorization_status(&request, PermissionAuthorizationStatus::NotDetermined), + ) + .unwrap(); + assert_eq!( + futures::executor::block_on(service.authorization_status(&request)).unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + } + + /// Prompt callback that always errors, to exercise the transient-failure + /// path (fail closed for the current call, but do not persist the error). + struct FailingPrompt; + + #[truapi_platform::async_trait] + impl Permissions for FailingPrompt { + async fn device_permission( + &self, + _request: HostDevicePermissionRequest, + ) -> Result { + Err(GenericError { + reason: "boom".into(), + }) + } + + async fn remote_permission( + &self, + _request: RemotePermissionRequest, + ) -> Result { + Err(GenericError { + reason: "boom".into(), + }) + } + } + + #[test] + fn prompt_failure_denies_without_persisting() { + let storage = MemStorage::default(); + let prompt = FailingPrompt; + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let decision = futures::executor::block_on( + service.check_or_prompt_device(HostDevicePermissionRequest::Camera), + ) + .unwrap(); + assert_eq!(decision, PermissionAuthorizationStatus::Denied); + + // A transient callback error is fail-closed for this call but NOT + // cached, so peek still sees no authorization and the next request + // re-prompts rather than permanently locking out the capability. + let cached = + futures::executor::block_on(service.peek_device(&HostDevicePermissionRequest::Camera)) + .unwrap(); + assert_eq!( + cached, + PermissionAuthorizationStatus::NotDetermined, + "a transient prompt error must not be persisted" + ); + } + + /// A corrupt SCALE-encoded cache entry must be treated as "no cache", + /// not panic. The service falls back to prompting. + #[test] + fn corrupt_cache_entry_returns_none() { + let storage = MemStorage::default(); + // Write garbage bytes under the canonical key. + futures::executor::block_on(storage.write_core_storage( + device_core_storage_key("product.dot", &HostDevicePermissionRequest::Camera), + vec![0xff, 0xfe, 0xfd], + )) + .unwrap(); + + let prompt = ScriptedPrompt::new(vec![true], vec![]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let peeked = + futures::executor::block_on(service.peek_device(&HostDevicePermissionRequest::Camera)) + .unwrap(); + assert_eq!( + peeked, + PermissionAuthorizationStatus::NotDetermined, + "corrupt entry must decode as absent" + ); + } + + /// Storage failures must propagate to the caller; the service must not + /// swallow them by silently returning a default authorization. + #[derive(Default)] + struct FailingStorage; + + #[truapi_platform::async_trait] + impl CoreStorage for FailingStorage { + async fn read_core_storage( + &self, + _key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + Err(v01::GenericError { + reason: "read failed".into(), + }) + } + async fn write_core_storage( + &self, + _key: CoreStorageKey, + _value: Vec, + ) -> Result<(), v01::GenericError> { + Err(v01::GenericError { + reason: "write failed".into(), + }) + } + async fn clear_core_storage(&self, _key: CoreStorageKey) -> Result<(), v01::GenericError> { + Err(v01::GenericError { + reason: "clear failed".into(), + }) + } + } + + #[test] + fn storage_read_error_propagates() { + let storage = FailingStorage; + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + let err = futures::executor::block_on( + service.check_or_prompt_device(HostDevicePermissionRequest::Camera), + ) + .expect_err("read failure must surface"); + assert!(matches!(err, v01::GenericError { .. })); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/product_account.rs b/rust/crates/truapi-server/src/host_logic/product_account.rs new file mode 100644 index 00000000..c4b16ec0 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -0,0 +1,175 @@ +//! Product account derivation shared by all hosts. +//! +//! Mirrors dotli's `packages/auth/src/account.ts`: derive an sr25519 public +//! key through soft HDKD junctions `["product", product_id, derivation_index]`. + +use blake2_rfc::blake2b::blake2b; +use parity_scale_codec::Encode; +use schnorrkel::PublicKey; +use schnorrkel::derive::{ChainCode, Derivation}; +use thiserror::Error; +use unicode_normalization::UnicodeNormalization; + +const JUNCTION_ID_LEN: usize = 32; +const PRODUCT_JUNCTION: &str = "product"; +const SS58_PREFIX: &[u8] = b"SS58PRE"; +const SUBSTRATE_GENERIC_SS58_PREFIX: u8 = 42; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ProductAccountError { + #[error("invalid sr25519 root public key")] + InvalidRootPublicKey, + #[error("numeric derivation junction is outside u64 range")] + NumericJunctionOutOfRange, +} + +/// Whether `identifier` is a product scope the core is allowed to derive for. +pub fn is_product_identifier(identifier: &str) -> bool { + let normalized = normalize_product_identifier(identifier); + normalized.ends_with(".dot") + || normalized == "localhost" + || normalized.starts_with("localhost:") +} + +/// Normalize product identifiers before derivation and policy checks. +pub fn normalize_product_identifier(identifier: &str) -> String { + identifier.nfc().collect::().to_lowercase() +} + +/// Derive a product account public key from the paired root public key. +pub fn derive_product_public_key( + root_public_key: [u8; 32], + product_id: &str, + derivation_index: u32, +) -> Result<[u8; 32], ProductAccountError> { + let mut public_key = PublicKey::from_bytes(&root_public_key) + .map_err(|_| ProductAccountError::InvalidRootPublicKey)?; + + for junction in [ + PRODUCT_JUNCTION.to_string(), + product_id.to_string(), + derivation_index.to_string(), + ] { + let chain_code = ChainCode(create_chain_code(&junction)?); + let (derived, _) = public_key.derived_key_simple(chain_code, []); + public_key = derived; + } + + Ok(public_key.to_bytes()) +} + +/// Encode a product account public key as a generic Substrate SS58 address. +pub fn product_public_key_to_address(public_key: [u8; 32]) -> String { + let mut payload = Vec::with_capacity(35); + payload.push(SUBSTRATE_GENERIC_SS58_PREFIX); + payload.extend_from_slice(&public_key); + + let mut checksum_input = Vec::with_capacity(SS58_PREFIX.len() + payload.len()); + checksum_input.extend_from_slice(SS58_PREFIX); + checksum_input.extend_from_slice(&payload); + let checksum = blake2b(64, &[], &checksum_input); + payload.extend_from_slice(&checksum.as_bytes()[..2]); + + bs58::encode(payload).into_string() +} + +fn create_chain_code(code: &str) -> Result<[u8; 32], ProductAccountError> { + let encoded = if is_numeric_junction(code) { + code.parse::() + .map_err(|_| ProductAccountError::NumericJunctionOutOfRange)? + .encode() + } else { + code.encode() + }; + + let mut chain_code = [0u8; JUNCTION_ID_LEN]; + if encoded.len() > JUNCTION_ID_LEN { + let hash = blake2b(JUNCTION_ID_LEN, &[], &encoded); + chain_code.copy_from_slice(hash.as_bytes()); + } else { + chain_code[..encoded.len()].copy_from_slice(&encoded); + } + Ok(chain_code) +} + +fn is_numeric_junction(code: &str) -> bool { + !code.is_empty() && code.bytes().all(|byte| byte.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ROOT_PUBLIC_KEY: [u8; 32] = [ + 0x80, 0x05, 0x28, 0xc9, 0x55, 0x87, 0x3e, 0x4c, 0x78, 0xb7, 0xdf, 0x24, 0xf7, 0x1d, 0xb8, + 0xf5, 0x81, 0xaa, 0x99, 0xe3, 0x49, 0x3b, 0xf4, 0x96, 0xed, 0xf1, 0x51, 0xab, 0xc1, 0xd7, + 0x20, 0x23, + ]; + + #[test] + fn derives_dotli_product_account_vector() { + let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", 0).unwrap(); + assert_eq!( + hex::encode(derived), + "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + ); + } + + #[test] + fn derives_different_index_vector() { + let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", 1).unwrap(); + assert_eq!( + hex::encode(derived), + "ec8a80808b46e44c1351b68e295eb975c55bda4855e5ea9fc1325be7296a2a4e" + ); + } + + #[test] + fn derives_long_product_id_vector() { + let derived = derive_product_public_key( + ROOT_PUBLIC_KEY, + "w-credentialless-staticblitz-com.local-credentialless.webcontainer-api.io", + 0, + ) + .unwrap(); + assert_eq!( + hex::encode(derived), + "56769a234038defb62a7ad42f251091cc24846c2473a31b5bdd17d366c38c211" + ); + } + + #[test] + fn ss58_address_matches_dotli_vector() { + let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", 0).unwrap(); + assert_eq!( + product_public_key_to_address(derived), + "5CyFsdhwjXy7wWpDEM6isungQ3LfGnu9UXkt7paBQ6DYRxk1" + ); + } + + #[test] + fn accepts_dot_and_localhost_product_identifiers() { + assert!(is_product_identifier("Example.DOT")); + assert!(is_product_identifier("localhost")); + assert!(is_product_identifier("localhost:3000")); + assert!(!is_product_identifier("example.com")); + } + + #[test] + fn chain_code_matches_dotli_encoding_rules() { + let product = create_chain_code("product").unwrap(); + assert_eq!( + &product[..8], + &[0x1c, b'p', b'r', b'o', b'd', b'u', b'c', b't'] + ); + + let zero = create_chain_code("0").unwrap(); + assert_eq!(&zero[..8], &[0; 8]); + + let long = create_chain_code( + "w-credentialless-staticblitz-com.local-credentialless.webcontainer-api.io", + ) + .unwrap(); + assert_ne!(&long[..8], &[0; 8]); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/session.rs b/rust/crates/truapi-server/src/host_logic/session.rs new file mode 100644 index 00000000..e2b1eeb3 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/session.rs @@ -0,0 +1,416 @@ +//! Core-owned active-session state. Platform entrypoints notify the core when +//! pairing or unpairing changes the session, and account-management methods +//! read this state instead of round-tripping a host callback on every product +//! call. + +use futures::channel::mpsc; +use futures::stream::{self, BoxStream, StreamExt}; +use parity_scale_codec::{Decode, Encode}; +use std::sync::{Arc, Mutex}; + +use truapi::v01::HostAccountConnectionStatusSubscribeItem; +use truapi::versioned::account::HostAccountConnectionStatusSubscribeItem as VersionedItem; + +/// Session info pushed by the host. The 32-byte sr25519 public key plus +/// optional usernames sourced from the People-Chain identity record. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SessionInfo { + /// 32-byte sr25519 root public key of the paired session. + pub public_key: [u8; 32], + /// Core-owned SSO channel state. Core-run pairing fills this; unavailable + /// sessions restored from older test fixtures may leave it empty. + pub sso: Option, + /// Wallet-provided source for deterministic product entropy. + pub root_entropy_source: Option<[u8; 32]>, + /// Wallet identity account id used for People-chain username lookup. + pub identity_account_id: Option<[u8; 32]>, + /// Short username (e.g. `alice`). + pub lite_username: Option, + /// Fully qualified username (e.g. `Alice Smith`). + pub full_username: Option, +} + +/// Core-owned SSO session material negotiated with the wallet during pairing. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SsoSessionInfo { + /// Core's own 64-byte expanded sr25519 statement-store secret. + pub ss_secret: [u8; 64], + /// Core's own session sr25519 statement-store public key. + pub ss_public_key: [u8; 32], + /// Core's P-256 ECDH private key. + pub enc_secret: [u8; 32], + /// Wallet persistent P-256 public key. + pub peer_enc_pubkey: [u8; 65], + /// Wallet identity sr25519 account id. + pub identity_account_id: [u8; 32], + /// Core -> wallet topic id. + pub session_id_own: [u8; 32], + /// Wallet -> core topic id. + pub session_id_peer: [u8; 32], + /// Statement channel for core requests. + pub request_channel: [u8; 32], + /// Statement channel for wallet responses to core requests. + pub response_channel: [u8; 32], + /// Statement channel for wallet-initiated requests. + pub peer_request_channel: [u8; 32], +} + +const PERSISTED_SESSION_VERSION: u8 = 3; + +/// Encode the active-session fields the core currently understands into an +/// opaque host-global session blob. Later SSO channel state should bump +/// `PERSISTED_SESSION_VERSION` instead of extending this layout silently. +pub fn encode_persisted_session(info: &SessionInfo) -> Vec { + (PERSISTED_SESSION_VERSION, info).encode() +} + +/// Decode a core-owned persisted session blob. +pub fn decode_persisted_session(blob: &[u8]) -> Result { + let mut input = blob; + let version = u8::decode(&mut input).map_err(|err| format!("invalid session blob: {err}"))?; + let info = match version { + PERSISTED_SESSION_VERSION => { + SessionInfo::decode(&mut input).map_err(|err| format!("invalid session blob: {err}"))? + } + _ => return Err(format!("unsupported session blob version {version}")), + }; + if !input.is_empty() { + return Err("invalid session blob: trailing bytes".to_string()); + } + Ok(info) +} + +/// Holds the currently-active session and broadcasts connection-status +/// transitions to subscribers. Cheap to clone via `Arc`. +#[derive(Default)] +pub struct SessionState { + inner: Mutex, +} + +#[derive(Default)] +struct Inner { + current: Option, + subscribers: Vec>, +} + +impl SessionState { + /// Construct a fresh session holder, starting in the `Disconnected` state. + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Replace the active session with `info`. Emits a `Connected` event to + /// every live subscriber if this is a transition from no-session or an + /// actual session replacement. + pub fn set_session(&self, info: SessionInfo) { + let mut inner = self.inner.lock().expect("session-state mutex poisoned"); + let should_broadcast = inner.current.as_ref() != Some(&info); + inner.current = Some(info); + if should_broadcast { + broadcast( + &mut inner.subscribers, + HostAccountConnectionStatusSubscribeItem::Connected, + ); + } + } + + /// Drop the active session. Emits a `Disconnected` event to every live + /// subscriber if there was a session to clear. + pub fn clear_session(&self) { + let mut inner = self.inner.lock().expect("session-state mutex poisoned"); + if inner.current.take().is_some() { + broadcast( + &mut inner.subscribers, + HostAccountConnectionStatusSubscribeItem::Disconnected, + ); + } + } + + /// Snapshot of the current session, or `None` when nothing is paired. + pub fn current(&self) -> Option { + self.inner + .lock() + .expect("session-state mutex poisoned") + .current + .clone() + } + + /// Stream of connection-status events. The first item emitted is the + /// current state (so subscribers don't have to read it separately); + /// subsequent items reflect every `set_session` / `clear_session` + /// transition. + pub fn subscribe(&self) -> BoxStream<'static, VersionedItem> { + let (tx, rx) = mpsc::unbounded(); + let mut inner = self.inner.lock().expect("session-state mutex poisoned"); + let initial = match inner.current { + Some(_) => HostAccountConnectionStatusSubscribeItem::Connected, + None => HostAccountConnectionStatusSubscribeItem::Disconnected, + }; + inner.subscribers.push(tx); + let initial_item = VersionedItem::V1(initial); + Box::pin(stream::once(async move { initial_item }).chain(rx)) + } +} + +fn broadcast( + subscribers: &mut Vec>, + status: HostAccountConnectionStatusSubscribeItem, +) { + let item = VersionedItem::V1(status); + // `retain` drops senders whose receiver has been dropped, so the + // subscriber list self-prunes on the next broadcast after a reader + // unsubscribes. + subscribers.retain(|tx| tx.unbounded_send(item.clone()).is_ok()); +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::executor::block_on; + use futures::{FutureExt, StreamExt}; + + fn info(pubkey_byte: u8) -> SessionInfo { + SessionInfo { + public_key: [pubkey_byte; 32], + sso: None, + root_entropy_source: None, + identity_account_id: None, + lite_username: Some("alice".to_string()), + full_username: None, + } + } + + #[test] + fn current_starts_empty() { + let state = SessionState::new(); + assert!(state.current().is_none()); + } + + #[test] + fn set_then_current_returns_session() { + let state = SessionState::new(); + state.set_session(info(0x42)); + let got = state.current().expect("session should be present"); + assert_eq!(got.public_key, [0x42; 32]); + assert_eq!(got.lite_username.as_deref(), Some("alice")); + } + + #[test] + fn persisted_session_round_trips() { + let mut session = info(0x42); + session.root_entropy_source = Some([1; 32]); + session.full_username = Some("Alice Smith".to_string()); + + let blob = encode_persisted_session(&session); + let decoded = decode_persisted_session(&blob).expect("session should decode"); + + assert_eq!(decoded, session); + } + + #[test] + fn persisted_sso_session_round_trips() { + let mut session = info(0x42); + session.sso = Some(SsoSessionInfo { + ss_secret: [1; 64], + ss_public_key: [2; 32], + enc_secret: [3; 32], + peer_enc_pubkey: [4; 65], + identity_account_id: [5; 32], + session_id_own: [6; 32], + session_id_peer: [7; 32], + request_channel: [8; 32], + response_channel: [9; 32], + peer_request_channel: [10; 32], + }); + + let blob = encode_persisted_session(&session); + let decoded = decode_persisted_session(&blob).expect("session should decode"); + + assert_eq!(decoded, session); + } + + #[test] + fn persisted_session_rejects_unknown_version() { + let mut blob = encode_persisted_session(&info(0x42)); + blob[0] = 0xff; + + let err = decode_persisted_session(&blob).unwrap_err(); + + assert_eq!(err, "unsupported session blob version 255"); + } + + #[test] + fn persisted_session_rejects_legacy_v2() { + let blob = vec![2]; + + let err = decode_persisted_session(&blob).unwrap_err(); + + assert_eq!(err, "unsupported session blob version 2"); + } + + #[test] + fn persisted_session_rejects_trailing_bytes() { + let mut blob = encode_persisted_session(&info(0x42)); + blob.push(0); + + let err = decode_persisted_session(&blob).unwrap_err(); + + assert_eq!(err, "invalid session blob: trailing bytes"); + } + + #[test] + fn clear_returns_to_empty() { + let state = SessionState::new(); + state.set_session(info(0x01)); + state.clear_session(); + assert!(state.current().is_none()); + } + + #[test] + fn subscribe_emits_current_state_first() { + let state = SessionState::new(); + state.set_session(info(0x01)); + let mut stream = state.subscribe(); + let first = block_on(stream.next()).expect("expected initial item"); + assert_eq!( + first, + VersionedItem::V1(HostAccountConnectionStatusSubscribeItem::Connected) + ); + } + + #[test] + fn subscribe_emits_disconnected_when_no_session() { + let state = SessionState::new(); + let mut stream = state.subscribe(); + let first = block_on(stream.next()).expect("expected initial item"); + assert_eq!( + first, + VersionedItem::V1(HostAccountConnectionStatusSubscribeItem::Disconnected) + ); + } + + #[test] + fn set_session_broadcasts_connected_to_existing_subscribers() { + let state = SessionState::new(); + let mut stream = state.subscribe(); + let _ = block_on(stream.next()); + + state.set_session(info(0x01)); + let next = block_on(stream.next()).expect("expected Connected event"); + assert_eq!( + next, + VersionedItem::V1(HostAccountConnectionStatusSubscribeItem::Connected) + ); + } + + #[test] + fn clear_session_broadcasts_disconnected_to_existing_subscribers() { + let state = SessionState::new(); + state.set_session(info(0x01)); + let mut stream = state.subscribe(); + let _ = block_on(stream.next()); + + state.clear_session(); + let next = block_on(stream.next()).expect("expected Disconnected event"); + assert_eq!( + next, + VersionedItem::V1(HostAccountConnectionStatusSubscribeItem::Disconnected) + ); + } + + #[test] + fn set_session_with_same_info_does_not_re_emit_connected() { + let state = SessionState::new(); + state.set_session(info(0x01)); + let mut stream = state.subscribe(); + let _ = block_on(stream.next()); + + state.set_session(info(0x01)); + + let pending = stream.next().now_or_never(); + assert!( + pending.is_none(), + "no transition event expected for equivalent session" + ); + } + + #[test] + fn set_session_with_replacement_re_emits_connected() { + let state = SessionState::new(); + state.set_session(info(0x01)); + let mut stream = state.subscribe(); + let _ = block_on(stream.next()); + + state.set_session(info(0x02)); + + let next = block_on(stream.next()).expect("expected replacement Connected event"); + assert_eq!( + next, + VersionedItem::V1(HostAccountConnectionStatusSubscribeItem::Connected) + ); + } + + #[test] + fn multi_subscriber_broadcast() { + let state = SessionState::new(); + let mut a = state.subscribe(); + let mut b = state.subscribe(); + // Drain initial Disconnected from both. + let _ = block_on(a.next()); + let _ = block_on(b.next()); + + state.set_session(info(0x77)); + let a_next = block_on(a.next()).expect("a should receive Connected"); + let b_next = block_on(b.next()).expect("b should receive Connected"); + assert_eq!( + a_next, + VersionedItem::V1(HostAccountConnectionStatusSubscribeItem::Connected) + ); + assert_eq!( + b_next, + VersionedItem::V1(HostAccountConnectionStatusSubscribeItem::Connected) + ); + } + + /// Clearing a never-set session is a no-op and must not synthesize a + /// spurious `Disconnected` event for live subscribers. + #[test] + fn clear_when_empty_is_silent_no_op() { + let state = SessionState::new(); + let mut stream = state.subscribe(); + // Drain the initial Disconnected. + let _ = block_on(stream.next()); + + state.clear_session(); + + let pending = stream.next().now_or_never(); + assert!(pending.is_none(), "no event expected when clear is a no-op",); + } + + /// Dropping a subscriber's stream must remove that sender from the + /// broadcast list. The next broadcast prunes it; the surviving stream + /// still receives the event. + #[test] + fn dropped_subscriber_is_pruned() { + let state = SessionState::new(); + let mut survivor = state.subscribe(); + let dropping = state.subscribe(); + let _ = block_on(survivor.next()); + // Drain the initial item from the dropping stream too so we don't + // accidentally test buffered-but-undelivered. + drop(dropping); + + state.set_session(info(0x33)); + let next = block_on(survivor.next()).expect("survivor must receive Connected"); + assert_eq!( + next, + VersionedItem::V1(HostAccountConnectionStatusSubscribeItem::Connected), + ); + + // Internally, `set_session`'s broadcast call `retain`-prunes any + // dropped senders. After the call the subscribers list should have + // exactly one entry (the survivor). + let inner = state.inner.lock().unwrap(); + assert_eq!(inner.subscribers.len(), 1, "dropped subscriber not pruned"); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/session_store.rs b/rust/crates/truapi-server/src/host_logic/session_store.rs new file mode 100644 index 00000000..45cc0edb --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/session_store.rs @@ -0,0 +1,97 @@ +//! Core-side invalidation signal for host-global session storage. +//! +//! The host owns persistence; the core owns decoding and projecting the +//! current blob into `SessionState` and `AuthState`. This notifier is just the +//! "the backing store may have changed" signal that drives a re-read. + +use std::sync::{Arc, Mutex}; + +use futures::channel::mpsc; +use futures::stream::{self, BoxStream, StreamExt}; + +#[derive(Default)] +pub struct SessionStoreChangeNotifier { + subscribers: Mutex>>, +} + +impl SessionStoreChangeNotifier { + /// Create a notifier with no subscribers. + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Broadcast a storage-change tick to current subscribers. + pub fn notify(&self) { + let mut subscribers = self + .subscribers + .lock() + .expect("session-store notifier mutex poisoned"); + subscribers.retain(|tx| tx.unbounded_send(()).is_ok()); + } + + /// Subscribe to storage-change ticks, including one initial tick. + pub fn subscribe(&self) -> BoxStream<'static, ()> { + let (tx, rx) = mpsc::unbounded(); + self.subscribers + .lock() + .expect("session-store notifier mutex poisoned") + .push(tx); + Box::pin(stream::once(async {}).chain(rx)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::executor::block_on; + use futures::{FutureExt, StreamExt}; + + #[test] + fn subscribe_emits_initial_tick() { + let notifier = SessionStoreChangeNotifier::new(); + let mut ticks = notifier.subscribe(); + + assert!(block_on(ticks.next()).is_some()); + } + + #[test] + fn notify_broadcasts_to_subscribers() { + let notifier = SessionStoreChangeNotifier::new(); + let mut first = notifier.subscribe(); + let mut second = notifier.subscribe(); + let _ = block_on(first.next()); + let _ = block_on(second.next()); + + notifier.notify(); + + assert!(block_on(first.next()).is_some()); + assert!(block_on(second.next()).is_some()); + } + + #[test] + fn dropped_subscriber_is_pruned_on_next_notify() { + let notifier = SessionStoreChangeNotifier::new(); + let dropped = notifier.subscribe(); + drop(dropped); + + notifier.notify(); + + assert_eq!( + notifier + .subscribers + .lock() + .expect("session-store notifier mutex poisoned") + .len(), + 0 + ); + } + + #[test] + fn no_tick_without_notify_after_initial() { + let notifier = SessionStoreChangeNotifier::new(); + let mut ticks = notifier.subscribe(); + let _ = block_on(ticks.next()); + + assert!(ticks.next().now_or_never().is_none()); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/sso.rs b/rust/crates/truapi-server/src/host_logic/sso.rs new file mode 100644 index 00000000..05da0780 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/sso.rs @@ -0,0 +1,2 @@ +pub mod messages; +pub mod pairing; diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs new file mode 100644 index 00000000..31b69045 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -0,0 +1,772 @@ +//! SCALE codecs for host-papp 0.7.9 SSO session-channel messages. +//! +//! These are the encrypted payloads carried inside statement-store +//! `SsoStatementData::Request` / `Response` frames. +//! The remote-message and signing codecs mirror host-papp: +//! +//! + +use parity_scale_codec::{Decode, Encode, OptionBool}; +use truapi::latest::{ + AllocatableResource, HostAccountGetAliasResponse, ProductAccountId, ProductAccountTxPayload, + RawPayload, +}; +use truapi::v01::{HostSignPayloadRequest, HostSignRawRequest}; + +use crate::host_logic::session::SsoSessionInfo; +use crate::host_logic::sso::pairing::{ + AES_GCM_NONCE_LEN, SsoStatementData, decrypt_session_statement_data, + encrypt_session_statement_data, encrypt_session_statement_data_with_nonce, +}; +use crate::host_logic::statement_store::{ + build_signed_session_request_statement, current_unix_secs, decode_verified_statement_data, + statement_expiry_elapsed, +}; + +const SSO_RESPONSE_CODE_SUCCESS: u8 = 0; + +/// Top-level wallet remote message sent over the encrypted SSO channel. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RemoteMessage { + /// Correlation id used to match wallet responses to host requests. + pub message_id: String, + /// Versioned remote message body. + pub data: RemoteMessageData, +} + +/// Versioned remote message body. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum RemoteMessageData { + V1(RemoteMessageV1), +} + +/// Host-papp v1 remote message variants. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum RemoteMessageV1 { + Disconnected, + SignRequest(Box), + SignResponse(SigningResponse), + RingVrfAliasRequest(RingVrfAliasRequest), + RingVrfAliasResponse(RingVrfAliasResponse), + ResourceAllocationRequest(ResourceAllocationRequest), + ResourceAllocationResponse(ResourceAllocationResponse), + CreateTransactionRequest(CreateTransactionRequest), + CreateTransactionResponse(CreateTransactionResponse), +} + +/// Signing request flavor sent to the wallet. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SigningRequest { + Payload(Box), + Raw(SigningRawRequest), +} + +/// Product-account payload signing request mirrored from host-papp. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SigningPayloadRequest { + pub product_account_id: ProductAccountId, + pub block_hash: Vec, + pub block_number: Vec, + pub era: Vec, + pub genesis_hash: Vec, + pub method: Vec, + pub nonce: Vec, + pub spec_version: Vec, + pub tip: Vec, + pub transaction_version: Vec, + pub signed_extensions: Vec, + pub version: u32, + pub asset_id: Option>, + pub metadata_hash: Option>, + pub mode: Option, + pub with_signed_transaction: OptionBool, +} + +impl From for SigningPayloadRequest { + fn from(value: HostSignPayloadRequest) -> Self { + let payload = value.payload; + Self { + product_account_id: value.account, + block_hash: payload.block_hash, + block_number: payload.block_number, + era: payload.era, + genesis_hash: payload.genesis_hash, + method: payload.method, + nonce: payload.nonce, + spec_version: payload.spec_version, + tip: payload.tip, + transaction_version: payload.transaction_version, + signed_extensions: payload.signed_extensions, + version: payload.version, + asset_id: payload.asset_id, + metadata_hash: payload.metadata_hash, + mode: payload.mode, + with_signed_transaction: OptionBool(payload.with_signed_transaction), + } + } +} + +/// Raw signing request mirrored from host-papp. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SigningRawRequest { + pub product_account_id: ProductAccountId, + pub data: SigningRawPayload, +} + +impl From for SigningRawRequest { + fn from(value: HostSignRawRequest) -> Self { + Self { + product_account_id: value.account, + data: value.payload.into(), + } + } +} + +/// Raw signing payload shape mirrored from host-papp. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SigningRawPayload { + Bytes(Vec), + Payload(String), +} + +impl From for SigningRawPayload { + fn from(value: RawPayload) -> Self { + match value { + RawPayload::Bytes { bytes } => Self::Bytes(bytes), + RawPayload::Payload { payload } => Self::Payload(payload), + } + } +} + +/// Wallet response to a signing request. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SigningResponse { + pub responding_to: String, + pub payload: Result, +} + +/// Successful signing response payload. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SigningPayloadResponseData { + pub signature: Vec, + pub signed_transaction: Option>, +} + +/// Wallet alias request for a product account. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RingVrfAliasRequest { + pub product_account_id: ProductAccountId, + pub product_id: String, +} + +/// Wallet alias response. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RingVrfAliasResponse { + pub responding_to: String, + pub payload: Result, +} + +/// Wallet resource-allocation request. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ResourceAllocationRequest { + pub calling_product_id: String, + pub resources: Vec, + pub on_existing: OnExistingAllowancePolicy, +} + +/// Resources the wallet may allocate for the calling product. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SsoAllocatableResource { + StatementStoreAllowance, + BulletInAllowance, + SmartContractAllowance(u32), + AutoSigning, +} + +impl From for SsoAllocatableResource { + fn from(value: AllocatableResource) -> Self { + match value { + AllocatableResource::StatementStoreAllowance => Self::StatementStoreAllowance, + AllocatableResource::BulletinAllowance => Self::BulletInAllowance, + AllocatableResource::SmartContractAllowance(index) => { + Self::SmartContractAllowance(index) + } + AllocatableResource::AutoSigning => Self::AutoSigning, + } + } +} + +/// Wallet policy for already-existing resource allowance. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum OnExistingAllowancePolicy { + Ignore, + Increase, +} + +/// Wallet resource-allocation response. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ResourceAllocationResponse { + pub responding_to: String, + pub payload: Result, String>, +} + +/// Per-resource allocation result from the wallet. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SsoAllocationOutcome { + Allocated(SsoAllocatedResource), + Rejected, + NotAvailable, +} + +/// Resource material allocated by the wallet. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SsoAllocatedResource { + StatementStoreAllowance { + slot_account_key: Vec, + }, + BulletInAllowance { + slot_account_key: Vec, + }, + SmartContractAllowance, + AutoSigning { + product_derivation_secret: String, + product_root_private_key: Vec, + }, +} + +/// Wallet transaction-creation request. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct CreateTransactionRequest { + pub payload: CreateTransactionPayload, +} + +/// Versioned transaction-creation payload. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum CreateTransactionPayload { + V1(ProductAccountTxPayload), +} + +/// Wallet transaction-creation response. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct CreateTransactionResponse { + pub responding_to: String, + pub signed_transaction: Result, String>, +} + +/// Decoded inbound statement-channel outcome. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SsoSessionStatement { + RequestAccepted, + RemoteResponse(SsoRemoteResponse), + Disconnected, +} + +/// Wallet response variants that can satisfy a pending remote request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SsoRemoteResponse { + Sign(SigningResponse), + RingVrfAlias(RingVrfAliasResponse), + ResourceAllocation(ResourceAllocationResponse), + CreateTransaction(CreateTransactionResponse), +} + +/// Decode and classify an inbound encrypted SSO session statement. +pub fn decode_sso_session_statement( + session: &SsoSessionInfo, + statement: &[u8], + expected_statement_request_id: &str, + expected_remote_message_id: &str, +) -> Result, String> { + let verified = + decode_verified_statement_data(statement, None).map_err(|err| err.to_string())?; + // Freshness gate against replay: a statement whose expiry is in the past + // is ignored. Trusts the local clock. + if verified + .expiry + .is_some_and(|expiry| statement_expiry_elapsed(expiry, current_unix_secs())) + { + return Ok(None); + } + let encrypted = verified.data; + let data = decrypt_session_statement_data(session, &encrypted)?; + if verified.signer == session.ss_public_key { + return match data { + SsoStatementData::Response { + request_id, + response_code, + } if request_id == expected_statement_request_id => { + classify_response_ack(request_id, response_code).map(Some) + } + _ => Ok(None), + }; + } + if verified.signer != session.identity_account_id { + return Err("statement proof signer does not match expected peer".to_string()); + } + match data { + SsoStatementData::Response { + request_id, + response_code, + } if request_id == expected_statement_request_id => { + classify_response_ack(request_id, response_code).map(Some) + } + SsoStatementData::Response { .. } => Ok(None), + SsoStatementData::Request { data, .. } => { + for message in data { + let message = RemoteMessage::decode(&mut message.as_slice()) + .map_err(|err| format!("invalid SSO remote message: {err}"))?; + if matches!( + &message.data, + RemoteMessageData::V1(RemoteMessageV1::Disconnected) + ) { + return Ok(Some(SsoSessionStatement::Disconnected)); + } + if let Some(response) = + remote_response_for_message(message, expected_remote_message_id) + { + return Ok(Some(SsoSessionStatement::RemoteResponse(response))); + } + } + Ok(None) + } + } +} + +fn classify_response_ack( + request_id: String, + response_code: u8, +) -> Result { + if response_code == SSO_RESPONSE_CODE_SUCCESS { + Ok(SsoSessionStatement::RequestAccepted) + } else { + Err(format!( + "SSO request {request_id} was rejected: {}", + sso_response_code_name(response_code) + )) + } +} + +fn remote_response_for_message( + message: RemoteMessage, + expected_remote_message_id: &str, +) -> Option { + let RemoteMessageData::V1(data) = message.data; + match data { + RemoteMessageV1::SignResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::Sign(response)) + } + RemoteMessageV1::RingVrfAliasResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::RingVrfAlias(response)) + } + RemoteMessageV1::ResourceAllocationResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::ResourceAllocation(response)) + } + RemoteMessageV1::CreateTransactionResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::CreateTransaction(response)) + } + _ => None, + } +} + +fn sso_response_code_name(code: u8) -> &'static str { + match code { + 1 => "decodingFailed", + 2 => "decryptionFailed", + 3 => "unknown", + _ => "unrecognized response code", + } +} + +/// Build a wallet payload-signing request message. +pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(RemoteMessageV1::SignRequest(Box::new( + SigningRequest::Payload(Box::new(request.into())), + ))), + } +} + +/// Build a wallet raw-signing request message. +pub fn sign_raw_message(message_id: String, request: HostSignRawRequest) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(RemoteMessageV1::SignRequest(Box::new(SigningRequest::Raw( + request.into(), + )))), + } +} + +/// Build a wallet account-alias request message. +pub fn alias_request_message( + message_id: String, + product_account_id: ProductAccountId, + product_id: String, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(RemoteMessageV1::RingVrfAliasRequest(RingVrfAliasRequest { + product_account_id, + product_id, + })), + } +} + +/// Build a wallet resource-allocation request message. +pub fn resource_allocation_message( + message_id: String, + calling_product_id: String, + resources: Vec, + on_existing: OnExistingAllowancePolicy, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(RemoteMessageV1::ResourceAllocationRequest( + ResourceAllocationRequest { + calling_product_id, + resources: resources.into_iter().map(Into::into).collect(), + on_existing, + }, + )), + } +} + +/// Build a wallet transaction-creation request message. +pub fn create_transaction_message( + message_id: String, + payload: ProductAccountTxPayload, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(RemoteMessageV1::CreateTransactionRequest( + CreateTransactionRequest { + payload: CreateTransactionPayload::V1(payload), + }, + )), + } +} + +/// Build a signed outbound SSO request statement with a random nonce. +pub fn build_outgoing_request_statement( + session: &SsoSessionInfo, + statement_request_id: String, + messages: Vec, + expiry: u64, +) -> Result, String> { + let encrypted = encrypt_outgoing_request_data(session, statement_request_id, messages)?; + build_signed_session_request_statement(session, encrypted, expiry) +} + +/// Build a signed outbound SSO request statement with a caller-supplied nonce. +pub fn build_outgoing_request_statement_with_nonce( + session: &SsoSessionInfo, + statement_request_id: String, + messages: Vec, + expiry: u64, + nonce: [u8; AES_GCM_NONCE_LEN], +) -> Result, String> { + let encrypted = + encrypt_outgoing_request_data_with_nonce(session, statement_request_id, messages, nonce)?; + build_signed_session_request_statement(session, encrypted, expiry) +} + +fn encrypt_outgoing_request_data( + session: &SsoSessionInfo, + statement_request_id: String, + messages: Vec, +) -> Result, String> { + encrypt_session_statement_data( + session, + &outgoing_request_data(statement_request_id, messages), + ) +} + +fn encrypt_outgoing_request_data_with_nonce( + session: &SsoSessionInfo, + statement_request_id: String, + messages: Vec, + nonce: [u8; AES_GCM_NONCE_LEN], +) -> Result, String> { + encrypt_session_statement_data_with_nonce( + session, + &outgoing_request_data(statement_request_id, messages), + nonce, + ) +} + +fn outgoing_request_data( + statement_request_id: String, + messages: Vec, +) -> SsoStatementData { + SsoStatementData::Request { + request_id: statement_request_id, + data: messages + .into_iter() + .map(|message| message.encode()) + .collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_logic::sso::pairing::decrypt_session_statement_data; + use crate::host_logic::statement_store::{ + StatementField, build_signed_statement, decode_statement_data, + }; + use p256::SecretKey as P256SecretKey; + use p256::elliptic_curve::sec1::ToEncodedPoint; + use schnorrkel::{ExpansionMode, MiniSecretKey}; + use truapi::latest::HostSignPayloadData; + + fn account() -> ProductAccountId { + ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 7, + } + } + + fn fresh_expiry() -> u64 { + (current_unix_secs() + 60) << 32 + } + + fn elapsed_expiry() -> u64 { + (current_unix_secs() - 60) << 32 + } + + fn session() -> SsoSessionInfo { + let mini_secret = MiniSecretKey::from_bytes(&[7; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + let core_secret = P256SecretKey::from_slice(&[1; 32]).unwrap(); + let peer_secret = P256SecretKey::from_slice(&[2; 32]).unwrap(); + SsoSessionInfo { + ss_secret: keypair.secret.to_bytes(), + ss_public_key: keypair.public.to_bytes(), + enc_secret: core_secret.to_bytes().into(), + peer_enc_pubkey: peer_secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(), + identity_account_id: [3; 32], + session_id_own: [4; 32], + session_id_peer: [5; 32], + request_channel: [6; 32], + response_channel: [7; 32], + peer_request_channel: [8; 32], + } + } + + #[test] + fn disconnected_message_matches_host_papp_variant_order() { + let message = RemoteMessage { + message_id: String::new(), + data: RemoteMessageData::V1(RemoteMessageV1::Disconnected), + }; + + assert_eq!(message.encode(), vec![0, 0, 0]); + } + + #[test] + fn raw_sign_request_uses_remote_message_variant_indices() { + let message = sign_raw_message( + "m1".to_string(), + HostSignRawRequest { + account: account(), + payload: RawPayload::Bytes { + bytes: vec![0xde, 0xad], + }, + }, + ); + let encoded = message.encode(); + + assert_eq!(&encoded[..3], &[8, b'm', b'1']); + assert_eq!(encoded[3], 0); + assert_eq!(encoded[4], 1); + assert_eq!(encoded[5], 1); + } + + #[test] + fn option_bool_matches_host_papp_option_bool_encoding() { + let mut request = HostSignPayloadRequest { + account: account(), + payload: HostSignPayloadData { + block_hash: vec![], + block_number: vec![], + era: vec![], + genesis_hash: vec![], + method: vec![], + nonce: vec![], + spec_version: vec![], + tip: vec![], + transaction_version: vec![], + signed_extensions: vec![], + version: 4, + asset_id: None, + metadata_hash: None, + mode: None, + with_signed_transaction: Some(true), + }, + }; + let true_encoded = SigningPayloadRequest::from(request.clone()).encode(); + request.payload.with_signed_transaction = Some(false); + let false_encoded = SigningPayloadRequest::from(request.clone()).encode(); + request.payload.with_signed_transaction = None; + let none_encoded = SigningPayloadRequest::from(request).encode(); + + assert_eq!(true_encoded.last(), Some(&1)); + assert_eq!(false_encoded.last(), Some(&2)); + assert_eq!(none_encoded.last(), Some(&0)); + } + + #[test] + fn maps_public_resource_names_to_sso_dialect() { + let message = resource_allocation_message( + "alloc".to_string(), + "myapp.dot".to_string(), + vec![ + AllocatableResource::StatementStoreAllowance, + AllocatableResource::BulletinAllowance, + AllocatableResource::SmartContractAllowance(9), + AllocatableResource::AutoSigning, + ], + OnExistingAllowancePolicy::Increase, + ); + let RemoteMessageData::V1(RemoteMessageV1::ResourceAllocationRequest(request)) = + message.data + else { + panic!("expected resource allocation request"); + }; + + assert_eq!( + request.resources, + vec![ + SsoAllocatableResource::StatementStoreAllowance, + SsoAllocatableResource::BulletInAllowance, + SsoAllocatableResource::SmartContractAllowance(9), + SsoAllocatableResource::AutoSigning, + ] + ); + assert_eq!(request.on_existing, OnExistingAllowancePolicy::Increase); + } + + #[test] + fn builds_signed_encrypted_outgoing_request_statement() { + let session = session(); + let remote_message = sign_raw_message( + "remote-1".to_string(), + HostSignRawRequest { + account: account(), + payload: RawPayload::Payload { + payload: "hello".to_string(), + }, + }, + ); + + let statement = build_outgoing_request_statement_with_nonce( + &session, + "statement-1".to_string(), + vec![remote_message.clone()], + 99, + [9; AES_GCM_NONCE_LEN], + ) + .unwrap(); + let encrypted = decode_statement_data(&statement).unwrap(); + let decrypted = decrypt_session_statement_data(&session, &encrypted).unwrap(); + + let SsoStatementData::Request { request_id, data } = decrypted else { + panic!("expected request statement data"); + }; + assert_eq!(request_id, "statement-1"); + assert_eq!(data.len(), 1); + assert_eq!( + RemoteMessage::decode(&mut data[0].as_slice()).unwrap(), + remote_message + ); + + let fields = Vec::::decode(&mut statement.as_slice()).unwrap(); + assert_eq!(fields[1], StatementField::Expiry(99)); + assert_eq!(fields[2], StatementField::Channel(session.request_channel)); + assert_eq!(fields[3], StatementField::Topic1(session.session_id_own)); + } + + #[test] + fn ignores_own_echoed_session_request_statement() { + let session = session(); + let remote_message = sign_raw_message( + "remote-1".to_string(), + HostSignRawRequest { + account: account(), + payload: RawPayload::Payload { + payload: "hello".to_string(), + }, + }, + ); + let statement = build_outgoing_request_statement_with_nonce( + &session, + "statement-1".to_string(), + vec![remote_message], + fresh_expiry(), + [9; AES_GCM_NONCE_LEN], + ) + .unwrap(); + + let decoded = + decode_sso_session_statement(&session, &statement, "statement-1", "remote-1").unwrap(); + + assert_eq!(decoded, None); + } + + fn response_ack_statement(session: &SsoSessionInfo, expiry: u64) -> Vec { + let encrypted = encrypt_session_statement_data_with_nonce( + session, + &SsoStatementData::Response { + request_id: "statement-1".to_string(), + response_code: SSO_RESPONSE_CODE_SUCCESS, + }, + [9; AES_GCM_NONCE_LEN], + ) + .unwrap(); + build_signed_statement( + session, + session.response_channel, + session.session_id_own, + encrypted, + expiry, + ) + .unwrap() + } + + #[test] + fn accepts_own_echoed_session_response_ack() { + let session = session(); + let statement = response_ack_statement(&session, fresh_expiry()); + + let decoded = + decode_sso_session_statement(&session, &statement, "statement-1", "remote-1").unwrap(); + + assert_eq!(decoded, Some(SsoSessionStatement::RequestAccepted)); + } + + /// A statement whose expiry is in the past must be ignored even when it + /// would otherwise match the pending request (replay protection). + #[test] + fn ignores_expired_session_response_ack() { + let session = session(); + let statement = response_ack_statement(&session, elapsed_expiry()); + + let decoded = + decode_sso_session_statement(&session, &statement, "statement-1", "remote-1").unwrap(); + + assert_eq!(decoded, None); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs new file mode 100644 index 00000000..3efecba5 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -0,0 +1,758 @@ +//! SSO pairing bootstrap helpers. +//! +//! This module owns the byte shape of the QR/deeplink payload described in +//! `docs/design/host-contract-and-core-impl/H - sso-pairing-protocol.md`. +//! The SCALE handshake codecs mirror host-papp's v2 handshake codec: +//! + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use blake2_rfc::blake2b::blake2b; +use hkdf::Hkdf; +use p256::ecdh::diffie_hellman; +use p256::elliptic_curve::sec1::ToEncodedPoint; +use p256::{PublicKey, SecretKey}; +use parity_scale_codec::{Decode, Encode}; +use schnorrkel::{ExpansionMode, MiniSecretKey}; +use sha2::Sha256; +use thiserror::Error; +use truapi_platform::RuntimeConfig; +#[cfg(test)] +use truapi_platform::{HostInfo, PlatformInfo}; + +use crate::host_logic::session::SsoSessionInfo; + +const HANDSHAKE_TOPIC_SUFFIX: &[u8] = b"topic"; +const MAX_P256_SECRET_ATTEMPTS: usize = 64; +/// Byte length of the AES-GCM nonce prepended to encrypted SSO payloads. +pub const AES_GCM_NONCE_LEN: usize = 12; +const SESSION_PREFIX: &[u8] = b"session"; +const PIN_SEPARATOR: &[u8] = b"/"; +const REQUEST_CHANNEL_SUFFIX: &[u8] = b"request"; +const RESPONSE_CHANNEL_SUFFIX: &[u8] = b"response"; + +/// QR/deeplink bootstrap material generated by the host for one pairing flow. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairingBootstrap { + pub deeplink: String, + pub topic: [u8; 32], + pub statement_store_public_key: [u8; 32], + pub statement_store_secret: [u8; 64], + pub encryption_public_key: [u8; 65], + pub encryption_secret_key: [u8; 32], +} + +/// Persistable device identity reused across pairing attempts. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct PairingDeviceIdentity { + pub statement_store_secret: [u8; 64], + pub statement_store_public_key: [u8; 32], + pub encryption_secret_key: [u8; 32], + pub encryption_public_key: [u8; 65], +} + +/// Errors that can occur while generating pairing bootstrap material. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum PairingBootstrapError { + #[error("failed to generate random pairing material: {0}")] + Random(String), + #[error("failed to generate P-256 pairing key")] + InvalidP256Secret, +} + +/// Versioned SCALE payload embedded in the pairing deeplink. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum VersionedHandshakeProposal { + #[codec(index = 1)] + V2(HandshakeProposalV2), +} + +/// Host-papp v2 handshake proposal sent by the host. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HandshakeProposalV2 { + pub device: HandshakeDevice, + pub metadata: Vec, +} + +/// Device keys advertised in the v2 handshake proposal. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HandshakeDevice { + pub statement_account_id: [u8; 32], + pub encryption_public_key: [u8; 65], +} + +/// Metadata key/value entry attached to a v2 handshake proposal. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HandshakeMetadataEntry(pub HandshakeMetadataKey, pub String); + +/// Metadata keys understood by the mobile SSO pairing flow. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HandshakeMetadataKey { + Custom(String), + HostName, + HostVersion, + HostIcon, + PlatformType, + PlatformVersion, +} + +/// Versioned encrypted response posted by the wallet to the pairing topic. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum VersionedHandshakeResponse { + #[codec(index = 1)] + V2 { + encrypted_message: Vec, + public_key: [u8; 65], + }, +} + +/// Plaintext v2 wallet response after decrypting the pairing statement. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum EncryptedHandshakeResponseV2 { + Pending(HandshakeStatusV2), + Success(Box), + Failed(String), +} + +/// Intermediate v2 handshake status emitted before success/failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HandshakeStatusV2 { + AllowanceAllocation, +} + +/// Successful v2 handshake payload used to establish the SSO session. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HandshakeSuccessV2 { + pub identity_account_id: [u8; 32], + pub root_account_id: [u8; 32], + pub identity_chat_private_key: [u8; 32], + pub sso_enc_pub_key: [u8; 65], + pub device_enc_pub_key: [u8; 65], + pub root_entropy_source: [u8; 32], +} + +/// Encrypted statement-channel envelope shared with the wallet. +/// +/// Mirrors `@novasamatech/statement-store` session statement data: +/// +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SsoStatementData { + Request { + request_id: String, + data: Vec>, + }, + Response { + request_id: String, + response_code: u8, + }, +} + +/// Decode wallet-posted pairing handshake data from SCALE bytes. +pub fn decode_app_handshake_data(blob: &[u8]) -> Result { + let mut input = blob; + let value: VersionedHandshakeResponse = + Decode::decode(&mut input).map_err(|err| format!("invalid app handshake data: {err}"))?; + if !input.is_empty() { + return Err("invalid app handshake data: trailing bytes".to_string()); + } + Ok(value) +} + +/// Decrypt a v2 handshake response. +pub fn decrypt_v2_handshake_response( + core_encryption_secret_key: [u8; 32], + wallet_ephemeral_public_key: [u8; 65], + encrypted_message: &[u8], +) -> Result { + let plaintext = decrypt_p256_hkdf_aes_gcm( + core_encryption_secret_key, + wallet_ephemeral_public_key, + encrypted_message, + )?; + let mut input = plaintext.as_slice(); + let value = EncryptedHandshakeResponseV2::decode(&mut input) + .map_err(|err| format!("invalid SSO V2 handshake response: {err}"))?; + if !input.is_empty() { + return Err("invalid SSO V2 handshake response: trailing bytes".to_string()); + } + Ok(value) +} + +/// Derive the persistent SSO session channels from a successful handshake. +pub fn establish_sso_session_info( + bootstrap: &PairingBootstrap, + peer_statement_account_id: [u8; 32], + peer_sso_enc_pub_key: [u8; 65], +) -> Result { + let shared_secret = shared_secret(bootstrap.encryption_secret_key, peer_sso_enc_pub_key)?; + let shared_secret_bytes: [u8; 32] = (*shared_secret.raw_secret_bytes()).into(); + let session_id_own = create_session_id( + shared_secret_bytes, + bootstrap.statement_store_public_key, + peer_statement_account_id, + ); + let session_id_peer = create_session_id( + shared_secret_bytes, + peer_statement_account_id, + bootstrap.statement_store_public_key, + ); + + Ok(SsoSessionInfo { + ss_secret: bootstrap.statement_store_secret, + ss_public_key: bootstrap.statement_store_public_key, + enc_secret: bootstrap.encryption_secret_key, + peer_enc_pubkey: peer_sso_enc_pub_key, + identity_account_id: peer_statement_account_id, + session_id_own, + session_id_peer, + request_channel: keyed_hash(session_id_own, REQUEST_CHANNEL_SUFFIX), + response_channel: keyed_hash(session_id_own, RESPONSE_CHANNEL_SUFFIX), + peer_request_channel: keyed_hash(session_id_peer, REQUEST_CHANNEL_SUFFIX), + }) +} + +/// Encrypt session-channel statement data with a random nonce. +pub fn encrypt_session_statement_data( + session: &SsoSessionInfo, + data: &SsoStatementData, +) -> Result, String> { + let mut nonce = [0u8; AES_GCM_NONCE_LEN]; + getrandom::getrandom(&mut nonce) + .map_err(|err| format!("failed to generate AES-GCM nonce: {err}"))?; + encrypt_session_statement_data_with_nonce(session, data, nonce) +} + +/// Encrypt session-channel statement data with a caller-supplied nonce. +pub fn encrypt_session_statement_data_with_nonce( + session: &SsoSessionInfo, + data: &SsoStatementData, + nonce: [u8; AES_GCM_NONCE_LEN], +) -> Result, String> { + let aes_key = session_aes_key(session)?; + let cipher = Aes256Gcm::new_from_slice(&aes_key) + .map_err(|err| format!("failed to initialize AES-GCM: {err}"))?; + let mut encrypted = nonce.to_vec(); + encrypted.extend( + cipher + .encrypt(Nonce::from_slice(&nonce), data.encode().as_slice()) + .map_err(|err| format!("failed to encrypt SSO statement data: {err}"))?, + ); + Ok(encrypted) +} + +/// Decrypt session-channel statement data. +pub fn decrypt_session_statement_data( + session: &SsoSessionInfo, + encrypted_message: &[u8], +) -> Result { + let plaintext = decrypt_session_message(session, encrypted_message)?; + let mut input = plaintext.as_slice(); + let data = SsoStatementData::decode(&mut input) + .map_err(|err| format!("invalid SSO statement data: {err}"))?; + if !input.is_empty() { + return Err("invalid SSO statement data: trailing bytes".to_string()); + } + Ok(data) +} + +fn decrypt_p256_hkdf_aes_gcm( + own_secret_key: [u8; 32], + peer_public_key: [u8; 65], + encrypted_message: &[u8], +) -> Result, String> { + if encrypted_message.len() < AES_GCM_NONCE_LEN { + return Err("encrypted SSO handshake answer is too short".to_string()); + } + let shared_secret = shared_secret(own_secret_key, peer_public_key)?; + let aes_key = aes_key_from_shared_secret(&shared_secret)?; + + decrypt_aes_gcm_with_key(aes_key, encrypted_message, "handshake answer") +} + +fn decrypt_session_message( + session: &SsoSessionInfo, + encrypted_message: &[u8], +) -> Result, String> { + decrypt_aes_gcm_with_key( + session_aes_key(session)?, + encrypted_message, + "statement data", + ) +} + +fn decrypt_aes_gcm_with_key( + aes_key: [u8; 32], + encrypted_message: &[u8], + label: &str, +) -> Result, String> { + if encrypted_message.len() < AES_GCM_NONCE_LEN { + return Err(format!("encrypted SSO {label} is too short")); + } + let (nonce, ciphertext) = encrypted_message.split_at(AES_GCM_NONCE_LEN); + let cipher = Aes256Gcm::new_from_slice(&aes_key) + .map_err(|err| format!("failed to initialize AES-GCM: {err}"))?; + cipher + .decrypt(Nonce::from_slice(nonce), ciphertext) + .map_err(|err| format!("failed to decrypt SSO {label}: {err}")) +} + +fn session_aes_key(session: &SsoSessionInfo) -> Result<[u8; 32], String> { + let shared_secret = shared_secret(session.enc_secret, session.peer_enc_pubkey)?; + aes_key_from_shared_secret(&shared_secret) +} + +fn aes_key_from_shared_secret( + shared_secret: &p256::ecdh::SharedSecret, +) -> Result<[u8; 32], String> { + let hkdf = Hkdf::::new(None, shared_secret.raw_secret_bytes()); + let mut aes_key = [0u8; 32]; + hkdf.expand(&[], &mut aes_key) + .map_err(|err| format!("failed to derive AES key: {err}"))?; + Ok(aes_key) +} + +fn shared_secret( + own_secret_key: [u8; 32], + peer_public_key: [u8; 65], +) -> Result { + let secret = SecretKey::from_slice(&own_secret_key) + .map_err(|err| format!("invalid P-256 secret key: {err}"))?; + let peer_public = PublicKey::from_sec1_bytes(&peer_public_key) + .map_err(|err| format!("invalid P-256 public key: {err}"))?; + Ok(diffie_hellman( + secret.to_nonzero_scalar(), + peer_public.as_affine(), + )) +} + +fn create_session_id( + shared_secret: [u8; 32], + account_a: [u8; 32], + account_b: [u8; 32], +) -> [u8; 32] { + let mut message = Vec::with_capacity(SESSION_PREFIX.len() + 32 + 32 + 2); + message.extend_from_slice(SESSION_PREFIX); + message.extend_from_slice(&account_a); + message.extend_from_slice(&account_b); + message.extend_from_slice(PIN_SEPARATOR); + message.extend_from_slice(PIN_SEPARATOR); + keyed_hash(shared_secret, &message) +} + +fn keyed_hash(key: [u8; 32], message: &[u8]) -> [u8; 32] { + let digest = blake2b(32, &key, message); + let mut output = [0u8; 32]; + output.copy_from_slice(digest.as_bytes()); + output +} + +/// Create one-shot pairing bootstrap material from runtime config. +pub fn create_pairing_bootstrap( + config: &RuntimeConfig, +) -> Result { + create_pairing_bootstrap_from_identity(config, generate_pairing_device_identity()?) +} + +/// Generate a fresh persistable pairing device identity. +pub fn generate_pairing_device_identity() -> Result { + let (statement_store_secret, statement_store_public_key) = generate_statement_store_keypair()?; + let (encryption_secret_key, encryption_public_key) = generate_p256_keypair()?; + + Ok(PairingDeviceIdentity { + statement_store_secret, + statement_store_public_key, + encryption_secret_key, + encryption_public_key, + }) +} + +/// Create pairing bootstrap material from an existing device identity. +pub fn create_pairing_bootstrap_from_identity( + config: &RuntimeConfig, + identity: PairingDeviceIdentity, +) -> Result { + let deeplink = build_pairing_deeplink( + &config.pairing_deeplink_scheme, + identity.statement_store_public_key, + identity.encryption_public_key, + config, + ); + let topic = bootstrap_topic( + identity.statement_store_public_key, + identity.encryption_public_key, + ); + + Ok(PairingBootstrap { + deeplink, + topic, + statement_store_public_key: identity.statement_store_public_key, + statement_store_secret: identity.statement_store_secret, + encryption_public_key: identity.encryption_public_key, + encryption_secret_key: identity.encryption_secret_key, + }) +} + +/// Build the wallet deeplink that carries the v2 handshake proposal. +pub fn build_pairing_deeplink( + scheme: &str, + statement_store_public_key: [u8; 32], + encryption_public_key: [u8; 65], + config: &RuntimeConfig, +) -> String { + let handshake = VersionedHandshakeProposal::V2(HandshakeProposalV2 { + device: HandshakeDevice { + statement_account_id: statement_store_public_key, + encryption_public_key, + }, + metadata: handshake_metadata(config), + }); + format!( + "{scheme}://pair?handshake={}", + hex::encode(handshake.encode()) + ) +} + +fn handshake_metadata(config: &RuntimeConfig) -> Vec { + let mut entries = vec![HandshakeMetadataEntry( + HandshakeMetadataKey::HostName, + config.host_info.name.clone(), + )]; + if let Some(value) = &config.host_info.version { + entries.push(HandshakeMetadataEntry( + HandshakeMetadataKey::HostVersion, + value.clone(), + )); + } + if let Some(value) = &config.host_info.icon { + entries.push(HandshakeMetadataEntry( + HandshakeMetadataKey::HostIcon, + value.clone(), + )); + } + if let Some(value) = &config.platform_info.kind { + entries.push(HandshakeMetadataEntry( + HandshakeMetadataKey::PlatformType, + value.clone(), + )); + } + if let Some(value) = &config.platform_info.version { + entries.push(HandshakeMetadataEntry( + HandshakeMetadataKey::PlatformVersion, + value.clone(), + )); + } + entries +} + +/// Derive the statement-store pairing topic from advertised host keys. +pub fn bootstrap_topic( + statement_store_public_key: [u8; 32], + encryption_public_key: [u8; 65], +) -> [u8; 32] { + let mut message = + Vec::with_capacity(encryption_public_key.len() + HANDSHAKE_TOPIC_SUFFIX.len()); + message.extend_from_slice(&encryption_public_key); + message.extend_from_slice(HANDSHAKE_TOPIC_SUFFIX); + + keyed_hash(statement_store_public_key, &message) +} + +fn generate_statement_store_keypair() -> Result<([u8; 64], [u8; 32]), PairingBootstrapError> { + let mut seed = [0u8; 32]; + getrandom::getrandom(&mut seed) + .map_err(|err| PairingBootstrapError::Random(err.to_string()))?; + let mini_secret = MiniSecretKey::from_bytes(&seed) + .map_err(|err| PairingBootstrapError::Random(err.to_string()))?; + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + Ok((keypair.secret.to_bytes(), keypair.public.to_bytes())) +} + +fn generate_p256_keypair() -> Result<([u8; 32], [u8; 65]), PairingBootstrapError> { + for _ in 0..MAX_P256_SECRET_ATTEMPTS { + let mut candidate = [0u8; 32]; + getrandom::getrandom(&mut candidate) + .map_err(|err| PairingBootstrapError::Random(err.to_string()))?; + let Ok(secret) = SecretKey::from_slice(&candidate) else { + continue; + }; + let public = secret.public_key().to_encoded_point(false); + let public = public.as_bytes(); + if public.len() != 65 { + return Err(PairingBootstrapError::InvalidP256Secret); + } + let mut encryption_public_key = [0u8; 65]; + encryption_public_key.copy_from_slice(public); + let mut encryption_secret_key = [0u8; 32]; + encryption_secret_key.copy_from_slice(secret.to_bytes().as_slice()); + return Ok((encryption_secret_key, encryption_public_key)); + } + + Err(PairingBootstrapError::InvalidP256Secret) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SS_PUBLIC: [u8; 32] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, + 0x1e, 0x1f, + ]; + const ENC_PUBLIC: [u8; 65] = [ + 0x04, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, + 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, + 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, + 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, + ]; + + fn runtime_config() -> RuntimeConfig { + RuntimeConfig { + product_id: "myapp.dot".to_string(), + host_info: HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://example.invalid/dotli.png".to_string()), + version: Some("1.2.3".to_string()), + }, + platform_info: PlatformInfo { + kind: Some("Firefox".to_string()), + version: Some("192.32".to_string()), + }, + people_chain_genesis_hash: [0; 32], + pairing_deeplink_scheme: "polkadotapp".to_string(), + } + } + + #[test] + fn builds_v2_pairing_deeplink() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + + assert!(deeplink.starts_with("polkadotapp://pair?handshake=01")); + let encoded = hex::decode(deeplink.split("handshake=").nth(1).unwrap()).unwrap(); + let decoded = ::decode(&mut &encoded[..]).unwrap(); + let VersionedHandshakeProposal::V2(proposal) = decoded; + assert_eq!(proposal.device.statement_account_id, SS_PUBLIC); + assert_eq!(proposal.device.encryption_public_key, ENC_PUBLIC); + assert!(proposal.metadata.contains(&HandshakeMetadataEntry( + HandshakeMetadataKey::HostName, + "Polkadot Web".to_string() + ))); + } + + #[test] + fn builds_dev_pairing_deeplink() { + let deeplink = + build_pairing_deeplink("polkadotappdev", SS_PUBLIC, ENC_PUBLIC, &runtime_config()); + + assert!(deeplink.starts_with("polkadotappdev://pair?handshake=")); + } + + #[test] + fn derives_bootstrap_topic_vector() { + assert_eq!( + hex::encode(bootstrap_topic(SS_PUBLIC, ENC_PUBLIC)), + "031c589833c39b1dfbe3c1304ced75fa7b0d841035db008e5b407bfadd2779a4" + ); + } + + #[test] + fn generated_bootstrap_uses_real_key_shapes() { + let config = runtime_config(); + + let bootstrap = create_pairing_bootstrap(&config).unwrap(); + + assert!( + bootstrap + .deeplink + .starts_with("polkadotapp://pair?handshake=") + ); + assert_eq!(bootstrap.encryption_public_key[0], 0x04); + assert_eq!( + bootstrap.topic, + bootstrap_topic( + bootstrap.statement_store_public_key, + bootstrap.encryption_public_key + ) + ); + } + + #[test] + fn decodes_app_handshake_response() { + let answer = VersionedHandshakeResponse::V2 { + encrypted_message: vec![0xde, 0xad], + public_key: ENC_PUBLIC, + }; + + assert_eq!(decode_app_handshake_data(&answer.encode()).unwrap(), answer); + } + + #[test] + fn rejects_app_handshake_trailing_bytes() { + let mut encoded = VersionedHandshakeResponse::V2 { + encrypted_message: vec![0xde, 0xad], + public_key: ENC_PUBLIC, + } + .encode(); + encoded.push(0); + + assert_eq!( + decode_app_handshake_data(&encoded).unwrap_err(), + "invalid app handshake data: trailing bytes" + ); + } + + #[test] + fn decrypts_v2_handshake_response() { + let core_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let wallet_ephemeral_secret = SecretKey::from_slice(&[2; 32]).unwrap(); + let wallet_ephemeral_public = wallet_ephemeral_secret.public_key().to_encoded_point(false); + let mut wallet_ephemeral_public_bytes = [0u8; 65]; + wallet_ephemeral_public_bytes.copy_from_slice(wallet_ephemeral_public.as_bytes()); + + let shared_secret = diffie_hellman( + wallet_ephemeral_secret.to_nonzero_scalar(), + core_secret.public_key().as_affine(), + ); + let hkdf = Hkdf::::new(None, shared_secret.raw_secret_bytes()); + let mut aes_key = [0u8; 32]; + hkdf.expand(&[], &mut aes_key).unwrap(); + + let sensitive = EncryptedHandshakeResponseV2::Success(Box::new(HandshakeSuccessV2 { + identity_account_id: [8; 32], + root_account_id: [7; 32], + identity_chat_private_key: [6; 32], + sso_enc_pub_key: ENC_PUBLIC, + device_enc_pub_key: ENC_PUBLIC, + root_entropy_source: [5; 32], + })); + let nonce = [9u8; AES_GCM_NONCE_LEN]; + let cipher = Aes256Gcm::new_from_slice(&aes_key).unwrap(); + let mut encrypted = nonce.to_vec(); + encrypted.extend( + cipher + .encrypt(Nonce::from_slice(&nonce), sensitive.encode().as_slice()) + .unwrap(), + ); + + assert_eq!( + decrypt_v2_handshake_response( + core_secret.to_bytes().into(), + wallet_ephemeral_public_bytes, + &encrypted + ) + .unwrap(), + sensitive + ); + } + + #[test] + fn rejects_short_handshake_ciphertext() { + assert_eq!( + decrypt_v2_handshake_response([1; 32], ENC_PUBLIC, &[0; AES_GCM_NONCE_LEN - 1]) + .unwrap_err(), + "encrypted SSO handshake answer is too short" + ); + } + + #[test] + fn establishes_session_ids_and_channels() { + let core_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let core_public = core_secret.public_key().to_encoded_point(false); + let mut core_public_bytes = [0u8; 65]; + core_public_bytes.copy_from_slice(core_public.as_bytes()); + let bootstrap = PairingBootstrap { + deeplink: "polkadotapp://pair?handshake=00".to_string(), + topic: [0x11; 32], + statement_store_public_key: [0x22; 32], + statement_store_secret: [0x33; 64], + encryption_public_key: core_public_bytes, + encryption_secret_key: [1; 32], + }; + let peer_secret = SecretKey::from_slice(&[2; 32]).unwrap(); + let peer_public = peer_secret.public_key().to_encoded_point(false); + let peer_public: [u8; 65] = peer_public.as_bytes().try_into().unwrap(); + + let info = establish_sso_session_info(&bootstrap, [0x55; 32], peer_public).unwrap(); + + assert_eq!(info.ss_secret, [0x33; 64]); + assert_eq!(info.ss_public_key, [0x22; 32]); + assert_eq!(info.enc_secret, [1; 32]); + assert_eq!(info.peer_enc_pubkey, peer_public); + assert_eq!(info.identity_account_id, [0x55; 32]); + assert_ne!(info.session_id_own, info.session_id_peer); + assert_eq!( + info.request_channel, + keyed_hash(info.session_id_own, b"request") + ); + assert_eq!( + info.response_channel, + keyed_hash(info.session_id_own, b"response") + ); + assert_eq!( + info.peer_request_channel, + keyed_hash(info.session_id_peer, b"request") + ); + } + + #[test] + fn statement_data_codec_round_trips_request_and_response() { + let request = SsoStatementData::Request { + request_id: "req-1".to_string(), + data: vec![vec![0xde, 0xad], vec![0xbe, 0xef]], + }; + let response = SsoStatementData::Response { + request_id: "req-1".to_string(), + response_code: 0, + }; + + assert_eq!( + SsoStatementData::decode(&mut &request.encode()[..]).unwrap(), + request + ); + assert_eq!( + SsoStatementData::decode(&mut &response.encode()[..]).unwrap(), + response + ); + assert_eq!(request.encode()[0], 0); + assert_eq!(response.encode()[0], 1); + } + + #[test] + fn encrypts_and_decrypts_session_statement_data() { + let core_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let core_public = core_secret.public_key().to_encoded_point(false); + let mut core_public_bytes = [0u8; 65]; + core_public_bytes.copy_from_slice(core_public.as_bytes()); + let bootstrap = PairingBootstrap { + deeplink: "polkadotapp://pair?handshake=00".to_string(), + topic: [0x11; 32], + statement_store_public_key: [0x22; 32], + statement_store_secret: [0x33; 64], + encryption_public_key: core_public_bytes, + encryption_secret_key: [1; 32], + }; + let peer_secret = SecretKey::from_slice(&[2; 32]).unwrap(); + let peer_public = peer_secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(); + let session = establish_sso_session_info(&bootstrap, [0x55; 32], peer_public).unwrap(); + let data = SsoStatementData::Request { + request_id: "req-1".to_string(), + data: vec![vec![0xde, 0xad]], + }; + let nonce = [9u8; AES_GCM_NONCE_LEN]; + + let encrypted = encrypt_session_statement_data_with_nonce(&session, &data, nonce).unwrap(); + + assert_eq!(&encrypted[..AES_GCM_NONCE_LEN], nonce); + assert_eq!( + decrypt_session_statement_data(&session, &encrypted).unwrap(), + data + ); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/statement_store.rs b/rust/crates/truapi-server/src/host_logic/statement_store.rs new file mode 100644 index 00000000..06980545 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/statement_store.rs @@ -0,0 +1,38 @@ +//! People-chain statement-store helpers. +//! +//! The core talks to the statement-store pallet through the host-provided +//! `ChainProvider` JSON-RPC connection. Transport mechanics live in +//! `HostRpcClient`; this module owns statement-store payload encoding, +//! proof verification, and subscription-result parsing. + +use thiserror::Error; + +mod rpc; +mod statement; + +pub use rpc::{ + MAX_MATCH_ALL_TOPICS, MAX_MATCH_ANY_TOPICS, NewStatements, SUBMIT_STATEMENT_METHOD, + SUBSCRIBE_STATEMENT_METHOD, TopicFilterKind, UNSUBSCRIBE_STATEMENT_METHOD, + parse_new_statements_result, +}; +pub(crate) use statement::current_unix_secs; +pub use statement::{ + StatementField, StatementProof, VerifiedStatementData, build_signed_session_request_statement, + build_signed_statement, decode_signed_statement, decode_statement_data, + decode_verified_statement_data, hex_topic, sign_statement_fields, signed_statement_to_scale, + statement_expiry_elapsed, statement_fields_from_v01, statement_proof_to_v01, + statement_signing_payload, +}; + +/// Error while parsing statement-store JSON-RPC or SCALE statement payloads. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum StatementStoreParseError { + #[error("invalid statement hex: {0}")] + InvalidStatementHex(String), + #[error("invalid statement scale: {0}")] + InvalidStatementScale(String), + #[error("malformed statement-store frame: {0}")] + Malformed(String), + #[error("invalid statement proof: {0}")] + InvalidStatementProof(String), +} diff --git a/rust/crates/truapi-server/src/host_logic/statement_store/rpc.rs b/rust/crates/truapi-server/src/host_logic/statement_store/rpc.rs new file mode 100644 index 00000000..bedbce5d --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/statement_store/rpc.rs @@ -0,0 +1,115 @@ +//! Statement-store JSON-RPC shapes mirrored from `sp_statement_store`. +//! +//! See the upstream RPC methods plus `TopicFilter` / `StatementEvent` types: +//! +//! +//! + +use serde_json::Value; + +use super::StatementStoreParseError; + +/// Statement-store RPC method used to open a topic subscription. +pub const SUBSCRIBE_STATEMENT_METHOD: &str = "statement_subscribeStatement"; +/// Statement-store RPC method used to close a topic subscription. +pub const UNSUBSCRIBE_STATEMENT_METHOD: &str = "statement_unsubscribeStatement"; +/// Statement-store RPC method used to submit a signed statement. +pub const SUBMIT_STATEMENT_METHOD: &str = "statement_submit"; +/// Maximum `matchAll` topic count accepted by the statement-store RPC. +pub const MAX_MATCH_ALL_TOPICS: usize = 4; +/// Maximum `matchAny` topic count accepted by the statement-store RPC. +pub const MAX_MATCH_ANY_TOPICS: usize = 128; + +/// Decoded `newStatements` subscription notification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewStatements { + /// Remote subscription id included in the notification. + pub remote_subscription_id: String, + /// SCALE-encoded signed statements carried by the notification. + pub statements: Vec>, + /// Optional server-side backlog count. + pub remaining: Option, +} + +/// Topic filter flavor used by statement-store subscribe requests. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TopicFilterKind { + /// Require every listed topic to match. + MatchAll, + /// Accept any listed topic match. + MatchAny, +} + +/// Parse a statement-store subscription result value. +pub fn parse_new_statements_result( + remote_subscription_id: String, + result: &Value, +) -> Result { + if result.get("event").and_then(Value::as_str) != Some("newStatements") { + return Err(StatementStoreParseError::Malformed( + "result is not a newStatements event".to_string(), + )); + } + let data = result + .get("data") + .ok_or_else(|| StatementStoreParseError::Malformed("missing data".to_string()))?; + let statement_values = data + .get("statements") + .and_then(Value::as_array) + .ok_or_else(|| StatementStoreParseError::Malformed("missing statements".to_string()))?; + let statements = statement_values + .iter() + .map(|value| { + let Some(hex) = value.as_str() else { + return Err(StatementStoreParseError::Malformed( + "statement is not a hex string".to_string(), + )); + }; + decode_hex(hex) + }) + .collect::, _>>()?; + let remaining = data + .get("remaining") + .map(|value| { + value.as_u64().ok_or_else(|| { + StatementStoreParseError::Malformed("remaining is not an integer".to_string()) + }) + }) + .transpose()?; + + Ok(NewStatements { + remote_subscription_id, + statements, + remaining, + }) +} + +fn decode_hex(value: &str) -> Result, StatementStoreParseError> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)) + .map_err(|error| StatementStoreParseError::InvalidStatementHex(error.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_dotli_sdk_new_statements_result() { + let result = serde_json::json!({ + "event": "newStatements", + "data": { + "statements": ["0xdeadbeef", "0xcafe"], + "remaining": 0, + }, + }); + + assert_eq!( + parse_new_statements_result("remote-sub".to_string(), &result).unwrap(), + NewStatements { + remote_subscription_id: "remote-sub".to_string(), + statements: vec![vec![0xde, 0xad, 0xbe, 0xef], vec![0xca, 0xfe]], + remaining: Some(0), + } + ); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs new file mode 100644 index 00000000..b4f29302 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs @@ -0,0 +1,675 @@ +use parity_scale_codec::{Compact, Decode, Encode}; +use schnorrkel::{PublicKey, SecretKey, Signature}; +use truapi::v01; + +use super::StatementStoreParseError; +use crate::host_logic::session::SsoSessionInfo; + +const SR25519_SIGNING_CONTEXT: &[u8] = b"substrate"; + +/// Verified statement payload plus the sr25519 signer recovered from proof. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedStatementData { + /// Raw statement data field. + pub data: Vec, + /// Sr25519 signer recovered from the proof. + pub signer: [u8; 32], + /// Raw `Expiry` field, if present: unix seconds in the upper 32 bits. + pub expiry: Option, +} + +/// SCALE statement proof variants mirrored from `sp_statement_store::Proof`. +/// +/// See the current upstream `Proof` codec: +/// +/// +/// `OnChain` is retained for v01 wire compatibility with older +/// statement-store bytes: +/// +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum StatementProof { + Sr25519 { + signature: [u8; 64], + signer: [u8; 32], + }, + Ed25519 { + signature: [u8; 64], + signer: [u8; 32], + }, + Ecdsa { + signature: [u8; 65], + signer: [u8; 33], + }, + OnChain { + who: [u8; 32], + block_hash: [u8; 32], + event: u64, + }, +} + +/// SCALE statement field variants mirrored from `sp_statement_store::Field`. +/// +/// See the upstream statement field vector codec: +/// +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum StatementField { + Proof(StatementProof), + DecryptionKey([u8; 32]), + Expiry(u64), + Channel([u8; 32]), + Topic1([u8; 32]), + Topic2([u8; 32]), + Topic3([u8; 32]), + Topic4([u8; 32]), + Data(Vec), +} + +/// Extract the raw `Data` field from a SCALE-encoded statement. +pub fn decode_statement_data(statement: &[u8]) -> Result, StatementStoreParseError> { + statement_data_from_fields(decode_statement_fields(statement)?) +} + +/// Verify statement proof and extract signer, expiry, and raw `Data` field. +pub fn decode_verified_statement_data( + statement: &[u8], + expected_signer: Option<[u8; 32]>, +) -> Result { + let fields = decode_statement_fields(statement)?; + let signer = verify_statement_proof(&fields, expected_signer)?; + let expiry = fields.iter().find_map(|field| match field { + StatementField::Expiry(value) => Some(*value), + _ => None, + }); + let data = statement_data_from_fields(fields)?; + Ok(VerifiedStatementData { + data, + signer, + expiry, + }) +} + +/// Whether a statement `Expiry` field (unix seconds in the upper 32 bits) is +/// in the past relative to `now_unix_secs`. +pub fn statement_expiry_elapsed(expiry: u64, now_unix_secs: u64) -> bool { + (expiry >> 32) < now_unix_secs +} + +/// Current unix time in seconds, used to stamp outgoing statement expiries +/// and to gate inbound statement freshness. Trusts the local clock on both +/// native and wasm targets. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn current_unix_secs() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Current unix time in seconds on wasm32, sourced from the JS clock. +#[cfg(target_arch = "wasm32")] +pub(crate) fn current_unix_secs() -> u64 { + (js_sys::Date::now() / 1000.0) as u64 +} + +/// Decode a SCALE signed statement into the public v01 statement shape. +pub fn decode_signed_statement( + statement: &[u8], +) -> Result { + signed_statement_from_fields(decode_statement_fields(statement)?) +} + +/// Build a signed statement on the active SSO request channel. +pub fn build_signed_session_request_statement( + session: &SsoSessionInfo, + encrypted_data: Vec, + expiry: u64, +) -> Result, String> { + build_signed_statement( + session, + session.request_channel, + session.session_id_own, + encrypted_data, + expiry, + ) +} + +/// Build a signed statement for an arbitrary channel/topic pair. +pub fn build_signed_statement( + session: &SsoSessionInfo, + channel: [u8; 32], + topic1: [u8; 32], + data: Vec, + expiry: u64, +) -> Result, String> { + let fields = vec![ + StatementField::Expiry(expiry), + StatementField::Channel(channel), + StatementField::Topic1(topic1), + StatementField::Data(data), + ]; + sign_statement_fields(session.ss_secret, session.ss_public_key, fields) + .map(|fields| fields.encode()) +} + +/// Sort fields, insert an sr25519 proof, and return signed fields. +pub fn sign_statement_fields( + ss_secret: [u8; 64], + expected_public_key: [u8; 32], + mut fields: Vec, +) -> Result, String> { + if fields + .iter() + .any(|field| matches!(field, StatementField::Proof(_))) + { + return Err("statement is already signed".to_string()); + } + fields.sort_by_key(statement_field_sort_index); + + let secret = + SecretKey::from_bytes(&ss_secret).map_err(|err| format!("invalid ss_secret: {err}"))?; + let public = secret.to_public(); + if public.to_bytes() != expected_public_key { + return Err("ss_secret does not match session statement public key".to_string()); + } + + let signing_payload = statement_signing_payload(&fields)?; + let signature = secret + .sign_simple(SR25519_SIGNING_CONTEXT, &signing_payload, &public) + .to_bytes(); + + let mut signed = Vec::with_capacity(fields.len() + 1); + signed.push(StatementField::Proof(StatementProof::Sr25519 { + signature, + signer: expected_public_key, + })); + signed.extend(fields); + Ok(signed) +} + +/// Build the statement signing payload from sorted fields. +pub fn statement_signing_payload(fields: &[StatementField]) -> Result, String> { + let encoded = fields.to_vec().encode(); + let mut input = encoded.as_slice(); + let _: Compact = + Decode::decode(&mut input).map_err(|err| format!("invalid statement vector: {err}"))?; + let compact_len = encoded.len() - input.len(); + Ok(encoded[compact_len..].to_vec()) +} + +fn decode_statement_fields( + statement: &[u8], +) -> Result, StatementStoreParseError> { + let mut input = statement; + let fields: Vec = Decode::decode(&mut input) + .map_err(|err| StatementStoreParseError::InvalidStatementScale(err.to_string()))?; + if !input.is_empty() { + return Err(StatementStoreParseError::Malformed( + "statement has trailing bytes".to_string(), + )); + } + Ok(fields) +} + +fn statement_data_from_fields( + fields: Vec, +) -> Result, StatementStoreParseError> { + fields + .into_iter() + .find_map(|field| match field { + StatementField::Data(value) => Some(value), + _ => None, + }) + .ok_or_else(|| StatementStoreParseError::Malformed("statement has no data".to_string())) +} + +fn verify_statement_proof( + fields: &[StatementField], + expected_signer: Option<[u8; 32]>, +) -> Result<[u8; 32], StatementStoreParseError> { + let mut proof = None; + let mut unsigned_fields = Vec::with_capacity(fields.len().saturating_sub(1)); + for field in fields { + match field { + StatementField::Proof(StatementProof::Sr25519 { signature, signer }) => { + if proof.replace((*signature, *signer)).is_some() { + return Err(StatementStoreParseError::InvalidStatementProof( + "statement has duplicate proof".to_string(), + )); + } + } + StatementField::Proof(_) => { + return Err(StatementStoreParseError::InvalidStatementProof( + "statement proof is not sr25519".to_string(), + )); + } + field => unsigned_fields.push(field.clone()), + } + } + let (signature, signer) = proof.ok_or_else(|| { + StatementStoreParseError::InvalidStatementProof("statement has no proof".to_string()) + })?; + if let Some(expected) = expected_signer + && signer != expected + { + return Err(StatementStoreParseError::InvalidStatementProof( + "statement proof signer does not match expected peer".to_string(), + )); + } + + unsigned_fields.sort_by_key(statement_field_sort_index); + let payload = + statement_signing_payload(&unsigned_fields).map_err(StatementStoreParseError::Malformed)?; + let public = PublicKey::from_bytes(&signer).map_err(|err| { + StatementStoreParseError::InvalidStatementProof(format!("invalid sr25519 signer: {err}")) + })?; + let signature = Signature::from_bytes(&signature).map_err(|err| { + StatementStoreParseError::InvalidStatementProof(format!("invalid sr25519 signature: {err}")) + })?; + public + .verify_simple(SR25519_SIGNING_CONTEXT, &payload, &signature) + .map_err(|err| { + StatementStoreParseError::InvalidStatementProof(format!( + "sr25519 signature verification failed: {err}" + )) + })?; + Ok(signer) +} + +/// Convert a public v01 statement into SCALE statement fields. +pub fn statement_fields_from_v01(statement: v01::Statement) -> Result, String> { + let mut fields = Vec::new(); + if let Some(proof) = statement.proof { + fields.push(StatementField::Proof(statement_proof_from_v01(proof))); + } + if let Some(decryption_key) = statement.decryption_key { + fields.push(StatementField::DecryptionKey(decryption_key)); + } + if let Some(expiry) = statement.expiry { + fields.push(StatementField::Expiry(expiry)); + } + if let Some(channel) = statement.channel { + fields.push(StatementField::Channel(channel)); + } + push_statement_topics(&mut fields, statement.topics)?; + if let Some(data) = statement.data { + fields.push(StatementField::Data(data)); + } + Ok(fields) +} + +/// Convert a public v01 signed statement into SCALE bytes. +pub fn signed_statement_to_scale(statement: v01::SignedStatement) -> Result, String> { + Ok(signed_statement_fields(statement)?.encode()) +} + +fn signed_statement_fields(statement: v01::SignedStatement) -> Result, String> { + let mut fields = vec![StatementField::Proof(statement_proof_from_v01( + statement.proof, + ))]; + if let Some(decryption_key) = statement.decryption_key { + fields.push(StatementField::DecryptionKey(decryption_key)); + } + if let Some(expiry) = statement.expiry { + fields.push(StatementField::Expiry(expiry)); + } + if let Some(channel) = statement.channel { + fields.push(StatementField::Channel(channel)); + } + push_statement_topics(&mut fields, statement.topics)?; + if let Some(data) = statement.data { + fields.push(StatementField::Data(data)); + } + fields.sort_by_key(statement_field_sort_index); + Ok(fields) +} + +fn signed_statement_from_fields( + fields: Vec, +) -> Result { + let mut proof = None; + let mut decryption_key = None; + let mut expiry = None; + let mut channel = None; + let mut topics = Vec::new(); + let mut data = None; + + for field in fields { + match field { + StatementField::Proof(value) => { + if proof.replace(statement_proof_to_v01(value)).is_some() { + return Err(StatementStoreParseError::Malformed( + "statement has duplicate proof".to_string(), + )); + } + } + StatementField::DecryptionKey(value) => { + if decryption_key.replace(value).is_some() { + return Err(StatementStoreParseError::Malformed( + "statement has duplicate decryption key".to_string(), + )); + } + } + StatementField::Expiry(value) => { + if expiry.replace(value).is_some() { + return Err(StatementStoreParseError::Malformed( + "statement has duplicate expiry".to_string(), + )); + } + } + StatementField::Channel(value) => { + if channel.replace(value).is_some() { + return Err(StatementStoreParseError::Malformed( + "statement has duplicate channel".to_string(), + )); + } + } + StatementField::Topic1(value) + | StatementField::Topic2(value) + | StatementField::Topic3(value) + | StatementField::Topic4(value) => topics.push(value), + StatementField::Data(value) => { + if data.replace(value).is_some() { + return Err(StatementStoreParseError::Malformed( + "statement has duplicate data".to_string(), + )); + } + } + } + } + + let proof = proof + .ok_or_else(|| StatementStoreParseError::Malformed("statement has no proof".to_string()))?; + Ok(v01::SignedStatement { + proof, + decryption_key, + expiry, + channel, + topics, + data, + }) +} + +/// Convert an internal proof into the public v01 proof shape. +pub fn statement_proof_to_v01(proof: StatementProof) -> v01::StatementProof { + match proof { + StatementProof::Sr25519 { signature, signer } => { + v01::StatementProof::Sr25519 { signature, signer } + } + StatementProof::Ed25519 { signature, signer } => { + v01::StatementProof::Ed25519 { signature, signer } + } + StatementProof::Ecdsa { signature, signer } => { + v01::StatementProof::Ecdsa { signature, signer } + } + StatementProof::OnChain { + who, + block_hash, + event, + } => v01::StatementProof::OnChain { + who, + block_hash, + event, + }, + } +} + +fn statement_proof_from_v01(proof: v01::StatementProof) -> StatementProof { + match proof { + v01::StatementProof::Sr25519 { signature, signer } => { + StatementProof::Sr25519 { signature, signer } + } + v01::StatementProof::Ed25519 { signature, signer } => { + StatementProof::Ed25519 { signature, signer } + } + v01::StatementProof::Ecdsa { signature, signer } => { + StatementProof::Ecdsa { signature, signer } + } + v01::StatementProof::OnChain { + who, + block_hash, + event, + } => StatementProof::OnChain { + who, + block_hash, + event, + }, + } +} + +fn push_statement_topics( + fields: &mut Vec, + topics: Vec<[u8; 32]>, +) -> Result<(), String> { + if topics.len() > 4 { + return Err(format!( + "statement has {} topics, maximum is 4", + topics.len() + )); + } + for (index, topic) in topics.into_iter().enumerate() { + fields.push(match index { + 0 => StatementField::Topic1(topic), + 1 => StatementField::Topic2(topic), + 2 => StatementField::Topic3(topic), + 3 => StatementField::Topic4(topic), + _ => unreachable!("topic count checked above"), + }); + } + Ok(()) +} + +fn statement_field_sort_index(field: &StatementField) -> u8 { + // Keep in sync with upstream `sp_statement_store::Field` discriminants: + // https://github.com/paritytech/polkadot-sdk/blob/f2f3aa6a8fda8ea52282da9711b3c5da4ba82529/substrate/primitives/statement-store/src/lib.rs#L314-L337 + match field { + StatementField::Proof(_) => 0, + StatementField::DecryptionKey(_) => 1, + StatementField::Expiry(_) => 2, + StatementField::Channel(_) => 3, + StatementField::Topic1(_) => 4, + StatementField::Topic2(_) => 5, + StatementField::Topic3(_) => 6, + StatementField::Topic4(_) => 7, + StatementField::Data(_) => 8, + } +} + +/// Format a 32-byte statement-store topic as `0x`-prefixed hex. +pub fn hex_topic(topic: &[u8; 32]) -> String { + format!("0x{}", hex::encode(topic)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_logic::session::SsoSessionInfo; + use schnorrkel::{ExpansionMode, MiniSecretKey, PublicKey, Signature}; + + fn test_session() -> SsoSessionInfo { + let mini_secret = MiniSecretKey::from_bytes(&[7; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + SsoSessionInfo { + ss_secret: keypair.secret.to_bytes(), + ss_public_key: keypair.public.to_bytes(), + enc_secret: [1; 32], + peer_enc_pubkey: [2; 65], + identity_account_id: [3; 32], + session_id_own: [4; 32], + session_id_peer: [5; 32], + request_channel: [6; 32], + response_channel: [7; 32], + peer_request_channel: [8; 32], + } + } + + #[test] + fn decodes_statement_data_field() { + let statement = vec![ + StatementField::Proof(StatementProof::Sr25519 { + signature: [1; 64], + signer: [2; 32], + }), + StatementField::Expiry(42), + StatementField::Channel([3; 32]), + StatementField::Topic1([4; 32]), + StatementField::Data(vec![0xde, 0xad, 0xbe, 0xef]), + ] + .encode(); + + assert_eq!( + decode_statement_data(&statement).unwrap(), + vec![0xde, 0xad, 0xbe, 0xef] + ); + } + + #[test] + fn signed_statement_scale_round_trips_public_shape() { + let signed = v01::SignedStatement { + proof: v01::StatementProof::Sr25519 { + signature: [9; 64], + signer: [8; 32], + }, + decryption_key: Some([7; 32]), + expiry: Some(99), + channel: Some([6; 32]), + topics: vec![[1; 32], [2; 32]], + data: Some(vec![3, 4, 5]), + }; + + let encoded = signed_statement_to_scale(signed.clone()).unwrap(); + + assert_eq!(decode_signed_statement(&encoded).unwrap(), signed); + } + + #[test] + fn signing_payload_strips_scale_vec_compact_len() { + let fields = vec![ + StatementField::Expiry(42), + StatementField::Channel([3; 32]), + StatementField::Topic1([4; 32]), + StatementField::Data(vec![0xde, 0xad, 0xbe, 0xef]), + ]; + let encoded = fields.encode(); + + assert_eq!(encoded[0], 16); + assert_eq!(statement_signing_payload(&fields).unwrap(), encoded[1..]); + } + + #[test] + fn builds_signed_session_request_statement() { + let session = test_session(); + + let statement = + build_signed_session_request_statement(&session, vec![0xde, 0xad], 42).unwrap(); + let mut input = statement.as_slice(); + let fields = Vec::::decode(&mut input).unwrap(); + + assert!(input.is_empty()); + assert_eq!(fields.len(), 5); + let StatementField::Proof(StatementProof::Sr25519 { signature, signer }) = fields[0] else { + panic!("expected sr25519 proof"); + }; + assert_eq!(signer, session.ss_public_key); + assert_eq!(fields[1], StatementField::Expiry(42)); + assert_eq!(fields[2], StatementField::Channel(session.request_channel)); + assert_eq!(fields[3], StatementField::Topic1(session.session_id_own)); + assert_eq!(fields[4], StatementField::Data(vec![0xde, 0xad])); + + let payload = statement_signing_payload(&fields[1..]).unwrap(); + let public = PublicKey::from_bytes(&signer).unwrap(); + let signature = Signature::from_bytes(&signature).unwrap(); + public + .verify_simple(SR25519_SIGNING_CONTEXT, &payload, &signature) + .unwrap(); + } + + #[test] + fn verified_statement_data_accepts_valid_sr25519_proof() { + let session = test_session(); + let statement = + build_signed_session_request_statement(&session, vec![0xde, 0xad], 42).unwrap(); + + let verified = + decode_verified_statement_data(&statement, Some(session.ss_public_key)).unwrap(); + + assert_eq!( + verified, + VerifiedStatementData { + data: vec![0xde, 0xad], + signer: session.ss_public_key, + expiry: Some(42), + } + ); + } + + #[test] + fn verified_statement_data_rejects_tampered_signature() { + let session = test_session(); + let statement = + build_signed_session_request_statement(&session, vec![0xde, 0xad], 42).unwrap(); + let mut fields = Vec::::decode(&mut statement.as_slice()).unwrap(); + let StatementField::Proof(StatementProof::Sr25519 { signature, .. }) = &mut fields[0] + else { + panic!("expected sr25519 proof"); + }; + signature[0] ^= 0xff; + + let err = decode_verified_statement_data(&fields.encode(), Some(session.ss_public_key)) + .unwrap_err(); + + assert!( + matches!(err, StatementStoreParseError::InvalidStatementProof(reason) if reason.contains("signature verification failed")) + ); + } + + #[test] + fn verified_statement_data_rejects_wrong_expected_signer() { + let session = test_session(); + let statement = + build_signed_session_request_statement(&session, vec![0xde, 0xad], 42).unwrap(); + + assert_eq!( + decode_verified_statement_data(&statement, Some([0xaa; 32])).unwrap_err(), + StatementStoreParseError::InvalidStatementProof( + "statement proof signer does not match expected peer".to_string() + ) + ); + } + + #[test] + fn signing_rejects_mismatched_session_key_material() { + let mut session = test_session(); + session.ss_public_key = [0xff; 32]; + + assert_eq!( + build_signed_session_request_statement(&session, vec![0xde], 42).unwrap_err(), + "ss_secret does not match session statement public key" + ); + } + + #[test] + fn signing_rejects_already_signed_statements() { + let session = test_session(); + let fields = vec![StatementField::Proof(StatementProof::Sr25519 { + signature: [1; 64], + signer: session.ss_public_key, + })]; + + assert_eq!( + sign_statement_fields(session.ss_secret, session.ss_public_key, fields).unwrap_err(), + "statement is already signed" + ); + } + + #[test] + fn rejects_statement_without_data_field() { + let statement = vec![StatementField::Expiry(42)].encode(); + + assert_eq!( + decode_statement_data(&statement).unwrap_err(), + StatementStoreParseError::Malformed("statement has no data".to_string()) + ); + } +} diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs new file mode 100644 index 00000000..8b56ec56 --- /dev/null +++ b/rust/crates/truapi-server/src/lib.rs @@ -0,0 +1,9 @@ +//! TrUAPI server runtime support. +//! +//! This layer contains host-agnostic logic shared by the runtime and target +//! adapters. Wire dispatch and platform runtime wiring are added by later stack +//! layers. + +#![forbid(unsafe_code)] + +pub mod host_logic; From c8c4595541ce27ab5596d0803e961b7faaa0cee7 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 1 Jul 2026 22:36:10 +0200 Subject: [PATCH 15/19] fixup! feat(truapi-server): add host logic primitives --- .../src/host_logic/sso/messages.rs | 365 +++++++++++++++--- .../src/host_logic/sso/pairing.rs | 7 +- 2 files changed, 315 insertions(+), 57 deletions(-) diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index 31b69045..6d782242 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -1,17 +1,22 @@ -//! SCALE codecs for host-papp 0.7.9 SSO session-channel messages. +//! SCALE codecs for host-papp SSO session-channel messages. //! //! These are the encrypted payloads carried inside statement-store //! `SsoStatementData::Request` / `Response` frames. -//! The remote-message and signing codecs mirror host-papp: -//! -//! +//! The runtime builds them when forwarding TrUAPI account, signing, resource +//! allocation, and transaction requests to the paired wallet, then decodes the +//! wallet's responses while waiting on the SSO statement-store channels. +//! Field order and enum variant order are kept wire-compatible with host-papp: +//! +//! +//! +//! use parity_scale_codec::{Decode, Encode, OptionBool}; use truapi::latest::{ - AllocatableResource, HostAccountGetAliasResponse, ProductAccountId, ProductAccountTxPayload, + AccountId, AllocatableResource, HostAccountGetAliasResponse, HostSignPayloadRequest, + HostSignRawRequest, LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, RawPayload, }; -use truapi::v01::{HostSignPayloadRequest, HostSignRawRequest}; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::pairing::{ @@ -40,7 +45,10 @@ pub enum RemoteMessageData { V1(RemoteMessageV1), } -/// Host-papp v1 remote message variants. +/// v1 messages exchanged with the paired wallet over the encrypted SSO channel. +/// +/// The variant order is part of the SCALE wire protocol used inside +/// statement-store session statements. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum RemoteMessageV1 { Disconnected, @@ -52,6 +60,9 @@ pub enum RemoteMessageV1 { ResourceAllocationResponse(ResourceAllocationResponse), CreateTransactionRequest(CreateTransactionRequest), CreateTransactionResponse(CreateTransactionResponse), + CreateTransactionLegacyRequest(CreateTransactionLegacyRequest), + SignRawLegacyRequest(SignRawLegacyRequest), + SignRawLegacyResponse(SignRawLegacyResponse), } /// Signing request flavor sent to the wallet. @@ -61,7 +72,12 @@ pub enum SigningRequest { Raw(SigningRawRequest), } -/// Product-account payload signing request mirrored from host-papp. +/// Request sent when a product asks the paired wallet to sign a Substrate +/// payload with a product-derived account. +/// +/// Built from [`HostSignPayloadRequest`] and wrapped in +/// [`RemoteMessageV1::SignRequest`] before being encrypted into an SSO session +/// statement. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SigningPayloadRequest { pub product_account_id: ProductAccountId, @@ -82,47 +98,63 @@ pub struct SigningPayloadRequest { pub with_signed_transaction: OptionBool, } -impl From for SigningPayloadRequest { - fn from(value: HostSignPayloadRequest) -> Self { - let payload = value.payload; - Self { - product_account_id: value.account, - block_hash: payload.block_hash, - block_number: payload.block_number, - era: payload.era, - genesis_hash: payload.genesis_hash, - method: payload.method, - nonce: payload.nonce, - spec_version: payload.spec_version, - tip: payload.tip, - transaction_version: payload.transaction_version, - signed_extensions: payload.signed_extensions, - version: payload.version, - asset_id: payload.asset_id, - metadata_hash: payload.metadata_hash, - mode: payload.mode, - with_signed_transaction: OptionBool(payload.with_signed_transaction), - } +fn signing_payload_request_from(value: HostSignPayloadRequest) -> SigningPayloadRequest { + let payload = value.payload; + SigningPayloadRequest { + product_account_id: value.account, + block_hash: payload.block_hash, + block_number: payload.block_number, + era: payload.era, + genesis_hash: payload.genesis_hash, + method: payload.method, + nonce: payload.nonce, + spec_version: payload.spec_version, + tip: payload.tip, + transaction_version: payload.transaction_version, + signed_extensions: payload.signed_extensions, + version: payload.version, + asset_id: payload.asset_id, + metadata_hash: payload.metadata_hash, + mode: payload.mode, + with_signed_transaction: OptionBool(payload.with_signed_transaction), } } -/// Raw signing request mirrored from host-papp. +/// Request sent when a product asks the paired wallet to sign raw bytes or a +/// string message with a product-derived account. +/// +/// Built from [`HostSignRawRequest`] and wrapped in +/// [`RemoteMessageV1::SignRequest`] before being encrypted into an SSO session +/// statement. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SigningRawRequest { pub product_account_id: ProductAccountId, pub data: SigningRawPayload, } -impl From for SigningRawRequest { - fn from(value: HostSignRawRequest) -> Self { - Self { - product_account_id: value.account, - data: value.payload.into(), - } +fn signing_raw_request_from(value: HostSignRawRequest) -> SigningRawRequest { + SigningRawRequest { + product_account_id: value.account, + data: value.payload.into(), } } -/// Raw signing payload shape mirrored from host-papp. +/// Request sent when a product asks the paired wallet to sign raw data with a +/// user-imported legacy account. +/// +/// Unlike product-account signing, the signer is the raw account id selected +/// from the user's legacy accounts. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SignRawLegacyRequest { + pub account: AccountId, + pub data: SigningRawPayload, +} + +/// Raw data accepted by SSO signing requests. +/// +/// Used by both product-account raw signing and legacy-account raw signing to +/// distinguish binary payloads from string messages on the session-channel +/// wire. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SigningRawPayload { Bytes(Vec), @@ -138,35 +170,56 @@ impl From for SigningRawPayload { } } -/// Wallet response to a signing request. +/// Response returned by the wallet for a product-account signing request. +/// +/// Decoded from [`RemoteMessageV1::SignResponse`] while the runtime is waiting +/// for a matching SSO remote message id. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SigningResponse { pub responding_to: String, pub payload: Result, } -/// Successful signing response payload. +/// Successful product-account signing result returned by the wallet. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SigningPayloadResponseData { pub signature: Vec, pub signed_transaction: Option>, } -/// Wallet alias request for a product account. +/// Response returned by the wallet for a legacy-account raw signing request. +/// +/// Decoded from [`RemoteMessageV1::SignRawLegacyResponse`] and mapped back to +/// the public raw-signing response shape. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SignRawLegacyResponse { + pub responding_to: String, + pub signature: Result, String>, +} + +/// Request sent when a product asks the wallet for a ring-VRF alias. +/// +/// Used by `Account::get_account_alias`; the product account identifies the +/// alias target, while `product_id` identifies the caller that the wallet is +/// authorizing over the SSO channel. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RingVrfAliasRequest { pub product_account_id: ProductAccountId, pub product_id: String, } -/// Wallet alias response. +/// Response returned by the wallet for a ring-VRF alias request. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RingVrfAliasResponse { pub responding_to: String, pub payload: Result, } -/// Wallet resource-allocation request. +/// Request sent when a product asks the wallet to allocate SSO-backed +/// resources. +/// +/// Used by `ResourceAllocation::request` for capabilities such as statement +/// store allowance and auto-signing material. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct ResourceAllocationRequest { pub calling_product_id: String, @@ -203,7 +256,7 @@ pub enum OnExistingAllowancePolicy { Increase, } -/// Wallet resource-allocation response. +/// Response returned by the wallet for a resource-allocation request. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct ResourceAllocationResponse { pub responding_to: String, @@ -234,7 +287,8 @@ pub enum SsoAllocatedResource { }, } -/// Wallet transaction-creation request. +/// Request sent when a product asks the wallet to create a signed transaction +/// for a product-derived account. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct CreateTransactionRequest { pub payload: CreateTransactionPayload, @@ -246,7 +300,21 @@ pub enum CreateTransactionPayload { V1(ProductAccountTxPayload), } -/// Wallet transaction-creation response. +/// Request sent when a product asks the wallet to create a signed transaction +/// for a user-imported legacy account. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct CreateTransactionLegacyRequest { + pub payload: CreateTransactionLegacyPayload, +} + +/// Versioned legacy transaction-creation payload. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum CreateTransactionLegacyPayload { + V1(LegacyAccountTxPayload), +} + +/// Response returned by the wallet for either product-account or legacy-account +/// transaction creation. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct CreateTransactionResponse { pub responding_to: String, @@ -265,6 +333,7 @@ pub enum SsoSessionStatement { #[derive(Debug, Clone, PartialEq, Eq)] pub enum SsoRemoteResponse { Sign(SigningResponse), + SignRawLegacy(SignRawLegacyResponse), RingVrfAlias(RingVrfAliasResponse), ResourceAllocation(ResourceAllocationResponse), CreateTransaction(CreateTransactionResponse), @@ -362,6 +431,11 @@ fn remote_response_for_message( { Some(SsoRemoteResponse::RingVrfAlias(response)) } + RemoteMessageV1::SignRawLegacyResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::SignRawLegacy(response)) + } RemoteMessageV1::ResourceAllocationResponse(response) if response.responding_to == expected_remote_message_id => { @@ -378,10 +452,9 @@ fn remote_response_for_message( fn sso_response_code_name(code: u8) -> &'static str { match code { - 1 => "decodingFailed", - 2 => "decryptionFailed", - 3 => "unknown", - _ => "unrecognized response code", + 1 => "decryptionFailed", + 2 => "decodingFailed", + _ => "unknown", } } @@ -390,7 +463,7 @@ pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) RemoteMessage { message_id, data: RemoteMessageData::V1(RemoteMessageV1::SignRequest(Box::new( - SigningRequest::Payload(Box::new(request.into())), + SigningRequest::Payload(Box::new(signing_payload_request_from(request))), ))), } } @@ -400,11 +473,28 @@ pub fn sign_raw_message(message_id: String, request: HostSignRawRequest) -> Remo RemoteMessage { message_id, data: RemoteMessageData::V1(RemoteMessageV1::SignRequest(Box::new(SigningRequest::Raw( - request.into(), + signing_raw_request_from(request), )))), } } +/// Build a wallet legacy raw-signing request message. +pub fn sign_raw_legacy_message( + message_id: String, + account: AccountId, + payload: RawPayload, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(RemoteMessageV1::SignRawLegacyRequest( + SignRawLegacyRequest { + account, + data: payload.into(), + }, + )), + } +} + /// Build a wallet account-alias request message. pub fn alias_request_message( message_id: String, @@ -454,6 +544,21 @@ pub fn create_transaction_message( } } +/// Build a wallet legacy-account transaction-creation request message. +pub fn create_transaction_legacy_message( + message_id: String, + payload: LegacyAccountTxPayload, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(RemoteMessageV1::CreateTransactionLegacyRequest( + CreateTransactionLegacyRequest { + payload: CreateTransactionLegacyPayload::V1(payload), + }, + )), + } +} + /// Build a signed outbound SSO request statement with a random nonce. pub fn build_outgoing_request_statement( session: &SsoSessionInfo, @@ -526,6 +631,7 @@ mod tests { use p256::elliptic_curve::sec1::ToEncodedPoint; use schnorrkel::{ExpansionMode, MiniSecretKey}; use truapi::latest::HostSignPayloadData; + use truapi::v01; fn account() -> ProductAccountId { ProductAccountId { @@ -595,6 +701,157 @@ mod tests { assert_eq!(encoded[5], 1); } + #[test] + fn late_remote_message_variants_match_host_papp_order() { + let legacy_tx = create_transaction_legacy_message( + String::new(), + v01::LegacyAccountTxPayload { + signer: [1; 32], + genesis_hash: [2; 32], + call_data: Vec::new(), + extensions: Vec::new(), + tx_ext_version: 0, + }, + ) + .encode(); + let legacy_raw = + sign_raw_legacy_message(String::new(), [1; 32], RawPayload::Bytes { bytes: vec![] }) + .encode(); + + assert_eq!(legacy_tx[..3], [0, 0, 9]); + assert_eq!(legacy_raw[..3], [0, 0, 10]); + } + + fn sequential_bytes(start: u8) -> [u8; N] { + std::array::from_fn(|index| start.wrapping_add(index as u8)) + } + + fn assert_host_papp_0_8_8_fixture(message: RemoteMessage, expected: &str) { + assert_eq!( + hex::encode(message.encode()), + expected.trim_start_matches("0x") + ); + } + + #[test] + fn resource_allocation_message_matches_host_papp_0_8_8_fixture() { + let message = resource_allocation_message( + "m-resource".to_string(), + "truapi-playground.dot".to_string(), + vec![ + AllocatableResource::StatementStoreAllowance, + AllocatableResource::BulletinAllowance, + AllocatableResource::SmartContractAllowance(9), + AllocatableResource::AutoSigning, + ], + OnExistingAllowancePolicy::Increase, + ); + + assert_host_papp_0_8_8_fixture( + message, + "0x286d2d7265736f757263650005547472756170692d706c617967726f756e642e646f7410000102090000000301", + ); + } + + #[test] + fn create_transaction_message_matches_host_papp_0_8_8_fixture() { + let message = create_transaction_message( + "m-product-tx".to_string(), + v01::ProductAccountTxPayload { + signer: v01::ProductAccountId { + dot_ns_identifier: "truapi-playground.dot".to_string(), + derivation_index: 0, + }, + genesis_hash: sequential_bytes(32), + call_data: vec![0, 0], + extensions: vec![v01::TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![1], + additional_signed: vec![2, 3], + }], + tx_ext_version: 0, + }, + ); + + assert_host_papp_0_8_8_fixture( + message, + "0x306d2d70726f647563742d7478000700547472756170692d706c617967726f756e642e646f7400000000202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f0800000428436865636b4e6f6e6365040108020300", + ); + } + + #[test] + fn playground_create_transaction_message_matches_host_papp_0_8_8_fixture() { + let message = create_transaction_message( + "create-transaction-1".to_string(), + v01::ProductAccountTxPayload { + signer: v01::ProductAccountId { + dot_ns_identifier: "truapi-playground.dot".to_string(), + derivation_index: 0, + }, + genesis_hash: [ + 0xbf, 0x04, 0x88, 0xdb, 0xe9, 0xda, 0xa1, 0xde, 0x1c, 0x08, 0xc5, 0xf7, 0x43, + 0xe2, 0x6f, 0xdc, 0x2a, 0x4e, 0xcd, 0x74, 0xcf, 0x87, 0xdd, 0x1b, 0x4b, 0x1e, + 0xeb, 0x99, 0xae, 0x4e, 0xf1, 0x9f, + ], + call_data: vec![0, 0], + extensions: vec![], + tx_ext_version: 0, + }, + ); + + assert_host_papp_0_8_8_fixture( + message, + "0x506372656174652d7472616e73616374696f6e2d31000700547472756170692d706c617967726f756e642e646f7400000000bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f0800000000", + ); + } + + #[test] + fn create_transaction_legacy_message_matches_host_papp_0_8_8_fixture() { + let message = create_transaction_legacy_message( + "m-legacy-tx".to_string(), + v01::LegacyAccountTxPayload { + signer: sequential_bytes(0), + genesis_hash: sequential_bytes(32), + call_data: vec![0, 0], + extensions: vec![v01::TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![1], + additional_signed: vec![2, 3], + }], + tx_ext_version: 0, + }, + ); + + assert_host_papp_0_8_8_fixture( + message, + "0x2c6d2d6c65676163792d7478000900000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f0800000428436865636b4e6f6e6365040108020300", + ); + } + + #[test] + fn sign_raw_legacy_messages_match_host_papp_0_8_8_fixtures() { + assert_host_papp_0_8_8_fixture( + sign_raw_legacy_message( + "m-legacy-raw".to_string(), + sequential_bytes(0), + RawPayload::Bytes { + bytes: b"Hi".to_vec(), + }, + ), + "0x306d2d6c65676163792d726177000a000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f00084869", + ); + assert_host_papp_0_8_8_fixture( + sign_raw_legacy_message( + "m-legacy-raw-payload".to_string(), + sequential_bytes(0), + RawPayload::Payload { + payload: "Hi".to_string(), + }, + ), + "0x506d2d6c65676163792d7261772d7061796c6f6164000a000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f01443c42797465733e48693c2f42797465733e", + ); + } + #[test] fn option_bool_matches_host_papp_option_bool_encoding() { let mut request = HostSignPayloadRequest { @@ -617,11 +874,11 @@ mod tests { with_signed_transaction: Some(true), }, }; - let true_encoded = SigningPayloadRequest::from(request.clone()).encode(); + let true_encoded = signing_payload_request_from(request.clone()).encode(); request.payload.with_signed_transaction = Some(false); - let false_encoded = SigningPayloadRequest::from(request.clone()).encode(); + let false_encoded = signing_payload_request_from(request.clone()).encode(); request.payload.with_signed_transaction = None; - let none_encoded = SigningPayloadRequest::from(request).encode(); + let none_encoded = signing_payload_request_from(request).encode(); assert_eq!(true_encoded.last(), Some(&1)); assert_eq!(false_encoded.last(), Some(&2)); diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs index 3efecba5..f6e16065 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -2,8 +2,9 @@ //! //! This module owns the byte shape of the QR/deeplink payload described in //! `docs/design/host-contract-and-core-impl/H - sso-pairing-protocol.md`. -//! The SCALE handshake codecs mirror host-papp's v2 handshake codec: -//! +//! The SCALE handshake codecs are kept wire-compatible with host-papp's v2 +//! handshake codec: +//! use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Nonce}; @@ -134,7 +135,7 @@ pub struct HandshakeSuccessV2 { /// Encrypted statement-channel envelope shared with the wallet. /// /// Mirrors `@novasamatech/statement-store` session statement data: -/// +/// #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SsoStatementData { Request { From 88cf1a903960718c9795473b18363d384cf6c2e6 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 2 Jul 2026 10:53:15 +0200 Subject: [PATCH 16/19] fixup! feat(truapi-server): add host logic primitives --- .../truapi-server/src/host_logic/sso/pairing.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs index f6e16065..73e7d402 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -509,20 +509,21 @@ mod tests { ]; fn runtime_config() -> RuntimeConfig { - RuntimeConfig { - product_id: "myapp.dot".to_string(), - host_info: HostInfo { + RuntimeConfig::new( + "myapp.dot".to_string(), + HostInfo { name: "Polkadot Web".to_string(), icon: Some("https://example.invalid/dotli.png".to_string()), version: Some("1.2.3".to_string()), }, - platform_info: PlatformInfo { + PlatformInfo { kind: Some("Firefox".to_string()), version: Some("192.32".to_string()), }, - people_chain_genesis_hash: [0; 32], - pairing_deeplink_scheme: "polkadotapp".to_string(), - } + [0; 32], + "polkadotapp".to_string(), + ) + .expect("test runtime config is valid") } #[test] From 2d88bdf5edd29a3240a33fe4841c1f713855213d Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 17:13:20 +0200 Subject: [PATCH 17/19] fixup! feat(truapi-server): add host logic primitives --- Cargo.lock | 14 ++ rust/crates/truapi-server/Cargo.toml | 1 + .../truapi-server/src/host_logic/entropy.rs | 38 +++- .../src/host_logic/product_account.rs | 116 ++++++++--- .../truapi-server/src/host_logic/session.rs | 72 ++++--- .../src/host_logic/sso/messages.rs | 182 ++++++++++-------- .../src/host_logic/sso/pairing.rs | 122 ++++-------- .../src/host_logic/sso/pairing/v2.rs | 57 ++++++ .../host_logic/statement_store/statement.rs | 100 +++++----- 9 files changed, 423 insertions(+), 279 deletions(-) create mode 100644 rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs diff --git a/Cargo.lock b/Cargo.lock index 23c9c80a..4b514a60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2901,6 +2901,19 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "substrate-bip39" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93affb0135879b1b67cbcf6370a256e1772f9eaaece3899ec20966d67ad0492" +dependencies = [ + "hmac 0.12.1", + "pbkdf2", + "schnorrkel", + "sha2 0.10.9", + "zeroize", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3280,6 +3293,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sp-crypto-hashing", + "substrate-bip39", "subxt-rpcs", "thiserror 1.0.69", "tracing", diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 6cecb5f6..a4a86a0c 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -30,6 +30,7 @@ blake2-rfc = { version = "0.2", default-features = false } sp-crypto-hashing = { version = "0.1", default-features = false } bs58 = { version = "0.5", default-features = false, features = ["alloc"] } schnorrkel = { version = "0.11.5", default-features = false, features = ["alloc", "getrandom"] } +substrate-bip39 = { version = "0.6", default-features = false } getrandom = { version = "0.2", features = ["js"] } p256 = { version = "0.13", default-features = false, features = ["ecdh"] } hkdf = "0.12" diff --git a/rust/crates/truapi-server/src/host_logic/entropy.rs b/rust/crates/truapi-server/src/host_logic/entropy.rs index 68e9e1fb..1e9b4193 100644 --- a/rust/crates/truapi-server/src/host_logic/entropy.rs +++ b/rust/crates/truapi-server/src/host_logic/entropy.rs @@ -26,7 +26,8 @@ pub fn derive_product_entropy( derive_product_entropy_from_source(&root_entropy_source, product_id, key) } -/// Derive product-scoped entropy from an already normalized root entropy source. +/// Derive product-scoped entropy when the session already stores the +/// pre-hashed root entropy source. pub fn derive_product_entropy_from_source( root_entropy_source: &[u8; 32], product_id: &str, @@ -36,7 +37,7 @@ pub fn derive_product_entropy_from_source( return Err(ProductEntropyError::InvalidKeyLength(key.len())); } - let product_id_hash = blake2b256(product_id.as_bytes()); + let product_id_hash = blake2b256_keyed(product_id.as_bytes(), &[]); let per_product_entropy = blake2b256_keyed(root_entropy_source, &product_id_hash); Ok(blake2b256_keyed(&per_product_entropy, key)) } @@ -48,10 +49,6 @@ fn blake2b256_keyed(message: &[u8], key: &[u8]) -> [u8; 32] { .expect("BLAKE2b-256 returns 32 bytes") } -fn blake2b256(message: &[u8]) -> [u8; 32] { - blake2b256_keyed(message, &[]) -} - #[cfg(test)] mod tests { use super::*; @@ -99,6 +96,35 @@ mod tests { assert_eq!(hex::encode(entropy), case.expected_hex, "{}", case.name); } + // Byte-for-byte vectors from polkadot-app-ios-v2 + // (ProductRootEntropyDeriverTests): raw BIP-39 entropy of 16 * 0xAB. + let ios_entropy = [0xABu8; 16]; + let ios_cases = [ + ( + "test.product.dot", + b"my-key".as_slice(), + "479d5b9ecce19615397c9f160ee95e2f00c579837a5afb111132dd0da5fd472a", + ), + ( + "test.product.dot", + b"other-key".as_slice(), + "0d576d5d77cb179bf94b85cb1d644b7879315e74d9e69791fb9cbe94df3c7c39", + ), + ( + "other.product.dot", + b"my-key".as_slice(), + "e2f25271c106593c2977d5965f52fa1d2227da0fc110d682c8cb8f30b2ba21c8", + ), + ]; + for (product_id, key, expected_hex) in ios_cases { + let entropy = derive_product_entropy(&ios_entropy, product_id, key).unwrap(); + assert_eq!( + hex::encode(entropy), + expected_hex, + "ios vector {product_id}" + ); + } + let error_cases = vec![ (Vec::new(), ProductEntropyError::InvalidKeyLength(0)), (vec![0u8; 33], ProductEntropyError::InvalidKeyLength(33)), diff --git a/rust/crates/truapi-server/src/host_logic/product_account.rs b/rust/crates/truapi-server/src/host_logic/product_account.rs index c4b16ec0..8298907c 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -5,35 +5,59 @@ use blake2_rfc::blake2b::blake2b; use parity_scale_codec::Encode; -use schnorrkel::PublicKey; use schnorrkel::derive::{ChainCode, Derivation}; +use schnorrkel::{ExpansionMode, Keypair, PublicKey}; use thiserror::Error; -use unicode_normalization::UnicodeNormalization; const JUNCTION_ID_LEN: usize = 32; const PRODUCT_JUNCTION: &str = "product"; const SS58_PREFIX: &[u8] = b"SS58PRE"; const SUBSTRATE_GENERIC_SS58_PREFIX: u8 = 42; +/// Substrate sr25519 signing-context string, shared by every sr25519 signature +/// the core produces (statement store, product raw signing). +pub(crate) const SR25519_SIGNING_CONTEXT: &[u8] = b"substrate"; + #[derive(Debug, Error, PartialEq, Eq)] pub enum ProductAccountError { #[error("invalid sr25519 root public key")] InvalidRootPublicKey, #[error("numeric derivation junction is outside u64 range")] NumericJunctionOutOfRange, + #[error("invalid BIP-39 entropy: {0}")] + InvalidEntropy(String), } -/// Whether `identifier` is a product scope the core is allowed to derive for. -pub fn is_product_identifier(identifier: &str) -> bool { - let normalized = normalize_product_identifier(identifier); - normalized.ends_with(".dot") - || normalized == "localhost" - || normalized.starts_with("localhost:") +/// Derive the root sr25519 keypair from raw BIP-39 entropy. +/// +/// Matches the Substrate mini-secret scheme (`sp_core::sr25519::Pair::from_entropy`) +/// used by polkadot-app-ios-v2: PBKDF2 over the entropy to a 32-byte mini +/// secret, then Ed25519-mode expansion. The public key of this keypair is the +/// `rootAccountId` shared with paired hosts. +pub fn derive_root_keypair_from_entropy(entropy: &[u8]) -> Result { + let mini_secret = substrate_bip39::mini_secret_from_entropy(entropy, "") + .map_err(|err| ProductAccountError::InvalidEntropy(format!("{err:?}")))?; + Ok(mini_secret.expand_to_keypair(ExpansionMode::Ed25519)) } -/// Normalize product identifiers before derivation and policy checks. -pub fn normalize_product_identifier(identifier: &str) -> String { - identifier.nfc().collect::().to_lowercase() +/// Derive a product-account keypair from the root keypair. +/// +/// Applies the same soft HDKD junctions `["product", product_id, +/// derivation_index]` as [`derive_product_public_key`] on the secret side, so +/// the resulting public key equals the seedless public derivation by +/// construction. +pub fn derive_product_keypair( + root: &Keypair, + product_id: &str, + derivation_index: u32, +) -> Result { + let mut keypair = root.clone(); + let derivation_index = derivation_index.to_string(); + for junction in [PRODUCT_JUNCTION, product_id, derivation_index.as_str()] { + let chain_code = ChainCode(create_chain_code(junction)?); + keypair = keypair.derived_key_simple(chain_code, []).0; + } + Ok(keypair) } /// Derive a product account public key from the paired root public key. @@ -45,12 +69,9 @@ pub fn derive_product_public_key( let mut public_key = PublicKey::from_bytes(&root_public_key) .map_err(|_| ProductAccountError::InvalidRootPublicKey)?; - for junction in [ - PRODUCT_JUNCTION.to_string(), - product_id.to_string(), - derivation_index.to_string(), - ] { - let chain_code = ChainCode(create_chain_code(&junction)?); + let derivation_index = derivation_index.to_string(); + for junction in [PRODUCT_JUNCTION, product_id, derivation_index.as_str()] { + let chain_code = ChainCode(create_chain_code(junction)?); let (derived, _) = public_key.derived_key_simple(chain_code, []); public_key = derived; } @@ -73,8 +94,9 @@ pub fn product_public_key_to_address(public_key: [u8; 32]) -> String { bs58::encode(payload).into_string() } +/// Create a Substrate soft-derivation chain code for one junction. fn create_chain_code(code: &str) -> Result<[u8; 32], ProductAccountError> { - let encoded = if is_numeric_junction(code) { + let encoded = if !code.is_empty() && code.bytes().all(|byte| byte.is_ascii_digit()) { code.parse::() .map_err(|_| ProductAccountError::NumericJunctionOutOfRange)? .encode() @@ -92,10 +114,6 @@ fn create_chain_code(code: &str) -> Result<[u8; 32], ProductAccountError> { Ok(chain_code) } -fn is_numeric_junction(code: &str) -> bool { - !code.is_empty() && code.bytes().all(|byte| byte.is_ascii_digit()) -} - #[cfg(test)] mod tests { use super::*; @@ -148,11 +166,55 @@ mod tests { } #[test] - fn accepts_dot_and_localhost_product_identifiers() { - assert!(is_product_identifier("Example.DOT")); - assert!(is_product_identifier("localhost")); - assert!(is_product_identifier("localhost:3000")); - assert!(!is_product_identifier("example.com")); + fn product_secret_derivation_matches_public_derivation() { + // The signing-host secret path and the seedless public path must agree + // on the product public key for any root, index, and product id. + let entropy = [0xABu8; 16]; + let root = derive_root_keypair_from_entropy(&entropy).unwrap(); + let root_public = root.public.to_bytes(); + for (product_id, index) in [("myapp.dot", 0u32), ("myapp.dot", 1), ("localhost:3000", 7)] { + let keypair = derive_product_keypair(&root, product_id, index).unwrap(); + let public = derive_product_public_key(root_public, product_id, index).unwrap(); + assert_eq!( + keypair.public.to_bytes(), + public, + "{product_id}#{index} secret vs public derivation", + ); + } + } + + #[test] + fn root_keypair_from_entropy_regression_pin() { + // Regression pin for the entropy -> mini-secret -> sr25519 root path + // (substrate-bip39 + schnorrkel Ed25519 expansion). This guards + // against an accidental change to that path (dep bump, expansion mode) + // that the pub-vs-secret self-consistency test cannot catch, since it + // derives both sides from the same root. + // + // NOTE: this is a self-computed regression value, NOT yet cross-checked + // against a polkadot-app-ios-v2 `deriveAccount` vector. Replace with an + // iOS-sourced value once available to make it a true interop anchor. + let root = derive_root_keypair_from_entropy(&[0xAB; 16]).unwrap(); + assert_eq!( + hex::encode(root.public.to_bytes()), + "0062ba8ae929ea64bc2ad6f21359e96a29e236a41d376d1c5ba76491da94fc72", + ); + } + + #[test] + fn product_secret_signs_verifiably() { + let root = derive_root_keypair_from_entropy(&[0xABu8; 16]).unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let message = b"hello"; + let signature = keypair + .secret + .sign_simple(b"substrate", message, &keypair.public); + assert!( + keypair + .public + .verify_simple(b"substrate", message, &signature) + .is_ok() + ); } #[test] diff --git a/rust/crates/truapi-server/src/host_logic/session.rs b/rust/crates/truapi-server/src/host_logic/session.rs index e2b1eeb3..261c110a 100644 --- a/rust/crates/truapi-server/src/host_logic/session.rs +++ b/rust/crates/truapi-server/src/host_logic/session.rs @@ -1,7 +1,7 @@ -//! Core-owned active-session state. Platform entrypoints notify the core when -//! pairing or unpairing changes the session, and account-management methods -//! read this state instead of round-tripping a host callback on every product -//! call. +//! Pairing-host active-session state. The runtime updates this when pairing or +//! unpairing with a signing host changes the inter-host session, and +//! account-management methods read it instead of round-tripping host callbacks +//! on every product call. use futures::channel::mpsc; use futures::stream::{self, BoxStream, StreamExt}; @@ -11,14 +11,15 @@ use std::sync::{Arc, Mutex}; use truapi::v01::HostAccountConnectionStatusSubscribeItem; use truapi::versioned::account::HostAccountConnectionStatusSubscribeItem as VersionedItem; -/// Session info pushed by the host. The 32-byte sr25519 public key plus -/// optional usernames sourced from the People-Chain identity record. +/// Session info for a pairing host's active signing-host session. The 32-byte +/// sr25519 public key plus optional usernames are sourced from the signing host +/// and People-chain identity record. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SessionInfo { - /// 32-byte sr25519 root public key of the paired session. + /// 32-byte sr25519 root public key owned by the signing host. pub public_key: [u8; 32], - /// Core-owned SSO channel state. Core-run pairing fills this; unavailable - /// sessions restored from older test fixtures may leave it empty. + /// SSO channel state negotiated by this pairing host with the signing host. + /// Sessions restored from older test fixtures may leave it empty. pub sso: Option, /// Wallet-provided source for deterministic product entropy. pub root_entropy_source: Option<[u8; 32]>, @@ -30,54 +31,62 @@ pub struct SessionInfo { pub full_username: Option, } -/// Core-owned SSO session material negotiated with the wallet during pairing. +/// SSO session material negotiated by the pairing host with the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SsoSessionInfo { - /// Core's own 64-byte expanded sr25519 statement-store secret. + /// Pairing host's own 64-byte expanded sr25519 statement-store secret. pub ss_secret: [u8; 64], - /// Core's own session sr25519 statement-store public key. + /// Pairing host's own session sr25519 statement-store public key. pub ss_public_key: [u8; 32], - /// Core's P-256 ECDH private key. + /// Pairing host's P-256 ECDH private key. pub enc_secret: [u8; 32], - /// Wallet persistent P-256 public key. + /// Signing host's persistent P-256 public key. pub peer_enc_pubkey: [u8; 65], - /// Wallet identity sr25519 account id. + /// Signing host's identity sr25519 account id. pub identity_account_id: [u8; 32], - /// Core -> wallet topic id. + /// Pairing host -> signing host topic id. pub session_id_own: [u8; 32], - /// Wallet -> core topic id. + /// Signing host -> pairing host topic id. pub session_id_peer: [u8; 32], - /// Statement channel for core requests. + /// Statement channel for pairing-host requests. pub request_channel: [u8; 32], - /// Statement channel for wallet responses to core requests. + /// Statement channel for signing-host responses to pairing-host requests. pub response_channel: [u8; 32], - /// Statement channel for wallet-initiated requests. + /// Statement channel for signing-host initiated requests. pub peer_request_channel: [u8; 32], } -const PERSISTED_SESSION_VERSION: u8 = 3; +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +enum PersistedSessionBlob { + #[codec(index = 3)] + V3(SessionInfo), +} /// Encode the active-session fields the core currently understands into an /// opaque host-global session blob. Later SSO channel state should bump -/// `PERSISTED_SESSION_VERSION` instead of extending this layout silently. +/// the enum variant instead of extending this layout silently. pub fn encode_persisted_session(info: &SessionInfo) -> Vec { - (PERSISTED_SESSION_VERSION, info).encode() + PersistedSessionBlob::V3(info.clone()).encode() } /// Decode a core-owned persisted session blob. pub fn decode_persisted_session(blob: &[u8]) -> Result { - let mut input = blob; - let version = u8::decode(&mut input).map_err(|err| format!("invalid session blob: {err}"))?; - let info = match version { - PERSISTED_SESSION_VERSION => { - SessionInfo::decode(&mut input).map_err(|err| format!("invalid session blob: {err}"))? - } - _ => return Err(format!("unsupported session blob version {version}")), + let Some(version) = blob.first() else { + return Err("invalid session blob: missing version".to_string()); }; + if *version != 3 { + return Err(format!("unsupported session blob version {version}")); + } + + let mut input = blob; + let decoded = PersistedSessionBlob::decode(&mut input) + .map_err(|err| format!("invalid session blob: {err}"))?; if !input.is_empty() { return Err("invalid session blob: trailing bytes".to_string()); } - Ok(info) + match decoded { + PersistedSessionBlob::V3(info) => Ok(info), + } } /// Holds the currently-active session and broadcasts connection-status @@ -152,6 +161,7 @@ impl SessionState { } } +/// Broadcast one connection-status transition and prune dropped subscribers. fn broadcast( subscribers: &mut Vec>, status: HostAccountConnectionStatusSubscribeItem, diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index 6d782242..abbd7e5d 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -3,8 +3,13 @@ //! These are the encrypted payloads carried inside statement-store //! `SsoStatementData::Request` / `Response` frames. //! The runtime builds them when forwarding TrUAPI account, signing, resource -//! allocation, and transaction requests to the paired wallet, then decodes the -//! wallet's responses while waiting on the SSO statement-store channels. +//! allocation, and transaction requests to the paired signing host, then +//! decodes the signing host's responses while waiting on the SSO +//! statement-store channels. +//! The envelope and baseline message catalog are specified in host-spec: +//! +//! Deployed extension variants are tracked as a host-spec divergence: +//! //! Field order and enum variant order are kept wire-compatible with host-papp: //! //! @@ -28,12 +33,36 @@ use crate::host_logic::statement_store::{ statement_expiry_elapsed, }; -const SSO_RESPONSE_CODE_SUCCESS: u8 = 0; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, derive_more::Display)] +enum SsoResponseCode { + #[codec(index = 0)] + #[display("success")] + Success, + #[codec(index = 1)] + #[display("decryptionFailed")] + DecryptionFailed, + #[codec(index = 2)] + #[display("decodingFailed")] + DecodingFailed, +} + +impl TryFrom for SsoResponseCode { + type Error = (); + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::Success), + 1 => Ok(Self::DecryptionFailed), + 2 => Ok(Self::DecodingFailed), + _ => Err(()), + } + } +} -/// Top-level wallet remote message sent over the encrypted SSO channel. +/// Top-level remote message sent over the encrypted SSO channel. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RemoteMessage { - /// Correlation id used to match wallet responses to host requests. + /// Correlation id used to match signing-host responses to pairing-host requests. pub message_id: String, /// Versioned remote message body. pub data: RemoteMessageData, @@ -45,7 +74,7 @@ pub enum RemoteMessageData { V1(RemoteMessageV1), } -/// v1 messages exchanged with the paired wallet over the encrypted SSO channel. +/// v1 messages exchanged with the paired signing host over the encrypted SSO channel. /// /// The variant order is part of the SCALE wire protocol used inside /// statement-store session statements. @@ -65,19 +94,19 @@ pub enum RemoteMessageV1 { SignRawLegacyResponse(SignRawLegacyResponse), } -/// Signing request flavor sent to the wallet. +/// Signing request flavor sent to the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SigningRequest { Payload(Box), Raw(SigningRawRequest), } -/// Request sent when a product asks the paired wallet to sign a Substrate +/// Request sent when a product asks the paired signing host to sign a Substrate /// payload with a product-derived account. /// -/// Built from [`HostSignPayloadRequest`] and wrapped in -/// [`RemoteMessageV1::SignRequest`] before being encrypted into an SSO session -/// statement. +/// Built from [`HostSignPayloadRequest`] but kept as a dedicated wire type +/// because the host-papp SSO dialect flattens the public request payload and +/// encodes `with_signed_transaction` as `OptionBool`. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SigningPayloadRequest { pub product_account_id: ProductAccountId, @@ -98,29 +127,31 @@ pub struct SigningPayloadRequest { pub with_signed_transaction: OptionBool, } -fn signing_payload_request_from(value: HostSignPayloadRequest) -> SigningPayloadRequest { - let payload = value.payload; - SigningPayloadRequest { - product_account_id: value.account, - block_hash: payload.block_hash, - block_number: payload.block_number, - era: payload.era, - genesis_hash: payload.genesis_hash, - method: payload.method, - nonce: payload.nonce, - spec_version: payload.spec_version, - tip: payload.tip, - transaction_version: payload.transaction_version, - signed_extensions: payload.signed_extensions, - version: payload.version, - asset_id: payload.asset_id, - metadata_hash: payload.metadata_hash, - mode: payload.mode, - with_signed_transaction: OptionBool(payload.with_signed_transaction), +impl From for SigningPayloadRequest { + fn from(value: truapi::v01::HostSignPayloadRequest) -> Self { + let payload = value.payload; + Self { + product_account_id: value.account, + block_hash: payload.block_hash, + block_number: payload.block_number, + era: payload.era, + genesis_hash: payload.genesis_hash, + method: payload.method, + nonce: payload.nonce, + spec_version: payload.spec_version, + tip: payload.tip, + transaction_version: payload.transaction_version, + signed_extensions: payload.signed_extensions, + version: payload.version, + asset_id: payload.asset_id, + metadata_hash: payload.metadata_hash, + mode: payload.mode, + with_signed_transaction: OptionBool(payload.with_signed_transaction), + } } } -/// Request sent when a product asks the paired wallet to sign raw bytes or a +/// Request sent when a product asks the paired signing host to sign raw bytes or a /// string message with a product-derived account. /// /// Built from [`HostSignRawRequest`] and wrapped in @@ -132,14 +163,16 @@ pub struct SigningRawRequest { pub data: SigningRawPayload, } -fn signing_raw_request_from(value: HostSignRawRequest) -> SigningRawRequest { - SigningRawRequest { - product_account_id: value.account, - data: value.payload.into(), +impl From for SigningRawRequest { + fn from(value: truapi::v01::HostSignRawRequest) -> Self { + Self { + product_account_id: value.account, + data: value.payload.into(), + } } } -/// Request sent when a product asks the paired wallet to sign raw data with a +/// Request sent when a product asks the paired signing host to sign raw data with a /// user-imported legacy account. /// /// Unlike product-account signing, the signer is the raw account id selected @@ -170,7 +203,7 @@ impl From for SigningRawPayload { } } -/// Response returned by the wallet for a product-account signing request. +/// Response returned by the signing host for a product-account signing request. /// /// Decoded from [`RemoteMessageV1::SignResponse`] while the runtime is waiting /// for a matching SSO remote message id. @@ -180,14 +213,14 @@ pub struct SigningResponse { pub payload: Result, } -/// Successful product-account signing result returned by the wallet. +/// Successful product-account signing result returned by the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SigningPayloadResponseData { pub signature: Vec, pub signed_transaction: Option>, } -/// Response returned by the wallet for a legacy-account raw signing request. +/// Response returned by the signing host for a legacy-account raw signing request. /// /// Decoded from [`RemoteMessageV1::SignRawLegacyResponse`] and mapped back to /// the public raw-signing response shape. @@ -197,10 +230,10 @@ pub struct SignRawLegacyResponse { pub signature: Result, String>, } -/// Request sent when a product asks the wallet for a ring-VRF alias. +/// Request sent when a product asks the signing host for a ring-VRF alias. /// /// Used by `Account::get_account_alias`; the product account identifies the -/// alias target, while `product_id` identifies the caller that the wallet is +/// alias target, while `product_id` identifies the caller that the signing host is /// authorizing over the SSO channel. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RingVrfAliasRequest { @@ -208,14 +241,14 @@ pub struct RingVrfAliasRequest { pub product_id: String, } -/// Response returned by the wallet for a ring-VRF alias request. +/// Response returned by the signing host for a ring-VRF alias request. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RingVrfAliasResponse { pub responding_to: String, pub payload: Result, } -/// Request sent when a product asks the wallet to allocate SSO-backed +/// Request sent when a product asks the signing host to allocate SSO-backed /// resources. /// /// Used by `ResourceAllocation::request` for capabilities such as statement @@ -227,7 +260,7 @@ pub struct ResourceAllocationRequest { pub on_existing: OnExistingAllowancePolicy, } -/// Resources the wallet may allocate for the calling product. +/// Resources the signing host may allocate for the calling product. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SsoAllocatableResource { StatementStoreAllowance, @@ -249,21 +282,21 @@ impl From for SsoAllocatableResource { } } -/// Wallet policy for already-existing resource allowance. +/// Signing-host policy for already-existing resource allowance. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] pub enum OnExistingAllowancePolicy { Ignore, Increase, } -/// Response returned by the wallet for a resource-allocation request. +/// Response returned by the signing host for a resource-allocation request. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct ResourceAllocationResponse { pub responding_to: String, pub payload: Result, String>, } -/// Per-resource allocation result from the wallet. +/// Per-resource allocation result from the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SsoAllocationOutcome { Allocated(SsoAllocatedResource), @@ -271,7 +304,7 @@ pub enum SsoAllocationOutcome { NotAvailable, } -/// Resource material allocated by the wallet. +/// Resource material allocated by the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SsoAllocatedResource { StatementStoreAllowance { @@ -287,7 +320,7 @@ pub enum SsoAllocatedResource { }, } -/// Request sent when a product asks the wallet to create a signed transaction +/// Request sent when a product asks the signing host to create a signed transaction /// for a product-derived account. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct CreateTransactionRequest { @@ -300,7 +333,7 @@ pub enum CreateTransactionPayload { V1(ProductAccountTxPayload), } -/// Request sent when a product asks the wallet to create a signed transaction +/// Request sent when a product asks the signing host to create a signed transaction /// for a user-imported legacy account. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct CreateTransactionLegacyRequest { @@ -313,7 +346,7 @@ pub enum CreateTransactionLegacyPayload { V1(LegacyAccountTxPayload), } -/// Response returned by the wallet for either product-account or legacy-account +/// Response returned by the signing host for either product-account or legacy-account /// transaction creation. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct CreateTransactionResponse { @@ -329,7 +362,7 @@ pub enum SsoSessionStatement { Disconnected, } -/// Wallet response variants that can satisfy a pending remote request. +/// Signing-host response variants that can satisfy a pending remote request. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SsoRemoteResponse { Sign(SigningResponse), @@ -405,13 +438,10 @@ fn classify_response_ack( request_id: String, response_code: u8, ) -> Result { - if response_code == SSO_RESPONSE_CODE_SUCCESS { - Ok(SsoSessionStatement::RequestAccepted) - } else { - Err(format!( - "SSO request {request_id} was rejected: {}", - sso_response_code_name(response_code) - )) + match SsoResponseCode::try_from(response_code) { + Ok(SsoResponseCode::Success) => Ok(SsoSessionStatement::RequestAccepted), + Ok(code) => Err(format!("SSO request {request_id} was rejected: {code}")), + Err(()) => Err(format!("SSO request {request_id} was rejected: unknown")), } } @@ -450,35 +480,27 @@ fn remote_response_for_message( } } -fn sso_response_code_name(code: u8) -> &'static str { - match code { - 1 => "decryptionFailed", - 2 => "decodingFailed", - _ => "unknown", - } -} - -/// Build a wallet payload-signing request message. +/// Build a signing-host payload-signing request message. pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) -> RemoteMessage { RemoteMessage { message_id, data: RemoteMessageData::V1(RemoteMessageV1::SignRequest(Box::new( - SigningRequest::Payload(Box::new(signing_payload_request_from(request))), + SigningRequest::Payload(Box::new(request.into())), ))), } } -/// Build a wallet raw-signing request message. +/// Build a signing-host raw-signing request message. pub fn sign_raw_message(message_id: String, request: HostSignRawRequest) -> RemoteMessage { RemoteMessage { message_id, data: RemoteMessageData::V1(RemoteMessageV1::SignRequest(Box::new(SigningRequest::Raw( - signing_raw_request_from(request), + request.into(), )))), } } -/// Build a wallet legacy raw-signing request message. +/// Build a signing-host legacy raw-signing request message. pub fn sign_raw_legacy_message( message_id: String, account: AccountId, @@ -495,7 +517,7 @@ pub fn sign_raw_legacy_message( } } -/// Build a wallet account-alias request message. +/// Build a signing-host account-alias request message. pub fn alias_request_message( message_id: String, product_account_id: ProductAccountId, @@ -510,7 +532,7 @@ pub fn alias_request_message( } } -/// Build a wallet resource-allocation request message. +/// Build a signing-host resource-allocation request message. pub fn resource_allocation_message( message_id: String, calling_product_id: String, @@ -529,7 +551,7 @@ pub fn resource_allocation_message( } } -/// Build a wallet transaction-creation request message. +/// Build a signing-host transaction-creation request message. pub fn create_transaction_message( message_id: String, payload: ProductAccountTxPayload, @@ -544,7 +566,7 @@ pub fn create_transaction_message( } } -/// Build a wallet legacy-account transaction-creation request message. +/// Build a signing-host legacy-account transaction-creation request message. pub fn create_transaction_legacy_message( message_id: String, payload: LegacyAccountTxPayload, @@ -874,11 +896,11 @@ mod tests { with_signed_transaction: Some(true), }, }; - let true_encoded = signing_payload_request_from(request.clone()).encode(); + let true_encoded = SigningPayloadRequest::from(request.clone()).encode(); request.payload.with_signed_transaction = Some(false); - let false_encoded = signing_payload_request_from(request.clone()).encode(); + let false_encoded = SigningPayloadRequest::from(request.clone()).encode(); request.payload.with_signed_transaction = None; - let none_encoded = signing_payload_request_from(request).encode(); + let none_encoded = SigningPayloadRequest::from(request).encode(); assert_eq!(true_encoded.last(), Some(&1)); assert_eq!(false_encoded.last(), Some(&2)); @@ -988,7 +1010,7 @@ mod tests { session, &SsoStatementData::Response { request_id: "statement-1".to_string(), - response_code: SSO_RESPONSE_CODE_SUCCESS, + response_code: 0, }, [9; AES_GCM_NONCE_LEN], ) diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs index 73e7d402..93231638 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -17,7 +17,7 @@ use parity_scale_codec::{Decode, Encode}; use schnorrkel::{ExpansionMode, MiniSecretKey}; use sha2::Sha256; use thiserror::Error; -use truapi_platform::RuntimeConfig; +use truapi_platform::PairingHostConfig; #[cfg(test)] use truapi_platform::{HostInfo, PlatformInfo}; @@ -65,36 +65,7 @@ pub enum PairingBootstrapError { #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum VersionedHandshakeProposal { #[codec(index = 1)] - V2(HandshakeProposalV2), -} - -/// Host-papp v2 handshake proposal sent by the host. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HandshakeProposalV2 { - pub device: HandshakeDevice, - pub metadata: Vec, -} - -/// Device keys advertised in the v2 handshake proposal. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HandshakeDevice { - pub statement_account_id: [u8; 32], - pub encryption_public_key: [u8; 65], -} - -/// Metadata key/value entry attached to a v2 handshake proposal. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HandshakeMetadataEntry(pub HandshakeMetadataKey, pub String); - -/// Metadata keys understood by the mobile SSO pairing flow. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HandshakeMetadataKey { - Custom(String), - HostName, - HostVersion, - HostIcon, - PlatformType, - PlatformVersion, + V2(v2::Proposal), } /// Versioned encrypted response posted by the wallet to the pairing topic. @@ -107,30 +78,8 @@ pub enum VersionedHandshakeResponse { }, } -/// Plaintext v2 wallet response after decrypting the pairing statement. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum EncryptedHandshakeResponseV2 { - Pending(HandshakeStatusV2), - Success(Box), - Failed(String), -} - -/// Intermediate v2 handshake status emitted before success/failure. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum HandshakeStatusV2 { - AllowanceAllocation, -} - -/// Successful v2 handshake payload used to establish the SSO session. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct HandshakeSuccessV2 { - pub identity_account_id: [u8; 32], - pub root_account_id: [u8; 32], - pub identity_chat_private_key: [u8; 32], - pub sso_enc_pub_key: [u8; 65], - pub device_enc_pub_key: [u8; 65], - pub root_entropy_source: [u8; 32], -} +/// Host-papp v2 handshake wire types. +pub mod v2; /// Encrypted statement-channel envelope shared with the wallet. /// @@ -164,14 +113,14 @@ pub fn decrypt_v2_handshake_response( core_encryption_secret_key: [u8; 32], wallet_ephemeral_public_key: [u8; 65], encrypted_message: &[u8], -) -> Result { +) -> Result { let plaintext = decrypt_p256_hkdf_aes_gcm( core_encryption_secret_key, wallet_ephemeral_public_key, encrypted_message, )?; let mut input = plaintext.as_slice(); - let value = EncryptedHandshakeResponseV2::decode(&mut input) + let value = v2::EncryptedResponse::decode(&mut input) .map_err(|err| format!("invalid SSO V2 handshake response: {err}"))?; if !input.is_empty() { return Err("invalid SSO V2 handshake response: trailing bytes".to_string()); @@ -256,6 +205,7 @@ pub fn decrypt_session_statement_data( Ok(data) } +/// Decrypt an SSO handshake answer using P-256 ECDH, HKDF-SHA256, and AES-GCM. fn decrypt_p256_hkdf_aes_gcm( own_secret_key: [u8; 32], peer_public_key: [u8; 65], @@ -281,6 +231,7 @@ fn decrypt_session_message( ) } +/// Decrypt a nonce-prefixed AES-GCM payload with the already-derived channel key. fn decrypt_aes_gcm_with_key( aes_key: [u8; 32], encrypted_message: &[u8], @@ -297,11 +248,13 @@ fn decrypt_aes_gcm_with_key( .map_err(|err| format!("failed to decrypt SSO {label}: {err}")) } +/// Derive the AES-GCM session key for encrypted statement-channel messages. fn session_aes_key(session: &SsoSessionInfo) -> Result<[u8; 32], String> { let shared_secret = shared_secret(session.enc_secret, session.peer_enc_pubkey)?; aes_key_from_shared_secret(&shared_secret) } +/// Expand a P-256 ECDH shared secret into a 32-byte AES-GCM key. fn aes_key_from_shared_secret( shared_secret: &p256::ecdh::SharedSecret, ) -> Result<[u8; 32], String> { @@ -312,6 +265,7 @@ fn aes_key_from_shared_secret( Ok(aes_key) } +/// Compute the P-256 ECDH shared secret from our private key and the peer public key. fn shared_secret( own_secret_key: [u8; 32], peer_public_key: [u8; 65], @@ -349,7 +303,7 @@ fn keyed_hash(key: [u8; 32], message: &[u8]) -> [u8; 32] { /// Create one-shot pairing bootstrap material from runtime config. pub fn create_pairing_bootstrap( - config: &RuntimeConfig, + config: &PairingHostConfig, ) -> Result { create_pairing_bootstrap_from_identity(config, generate_pairing_device_identity()?) } @@ -369,7 +323,7 @@ pub fn generate_pairing_device_identity() -> Result Result { let deeplink = build_pairing_deeplink( @@ -398,10 +352,10 @@ pub fn build_pairing_deeplink( scheme: &str, statement_store_public_key: [u8; 32], encryption_public_key: [u8; 65], - config: &RuntimeConfig, + config: &PairingHostConfig, ) -> String { - let handshake = VersionedHandshakeProposal::V2(HandshakeProposalV2 { - device: HandshakeDevice { + let handshake = VersionedHandshakeProposal::V2(v2::Proposal { + device: v2::Device { statement_account_id: statement_store_public_key, encryption_public_key, }, @@ -413,32 +367,29 @@ pub fn build_pairing_deeplink( ) } -fn handshake_metadata(config: &RuntimeConfig) -> Vec { - let mut entries = vec![HandshakeMetadataEntry( - HandshakeMetadataKey::HostName, - config.host_info.name.clone(), +fn handshake_metadata(config: &PairingHostConfig) -> Vec { + let mut entries = vec![v2::MetadataEntry( + v2::MetadataKey::HostName, + config.host.host_info.name.clone(), )]; - if let Some(value) = &config.host_info.version { - entries.push(HandshakeMetadataEntry( - HandshakeMetadataKey::HostVersion, + if let Some(value) = &config.host.host_info.version { + entries.push(v2::MetadataEntry( + v2::MetadataKey::HostVersion, value.clone(), )); } - if let Some(value) = &config.host_info.icon { - entries.push(HandshakeMetadataEntry( - HandshakeMetadataKey::HostIcon, - value.clone(), - )); + if let Some(value) = &config.host.host_info.icon { + entries.push(v2::MetadataEntry(v2::MetadataKey::HostIcon, value.clone())); } - if let Some(value) = &config.platform_info.kind { - entries.push(HandshakeMetadataEntry( - HandshakeMetadataKey::PlatformType, + if let Some(value) = &config.host.platform_info.kind { + entries.push(v2::MetadataEntry( + v2::MetadataKey::PlatformType, value.clone(), )); } - if let Some(value) = &config.platform_info.version { - entries.push(HandshakeMetadataEntry( - HandshakeMetadataKey::PlatformVersion, + if let Some(value) = &config.host.platform_info.version { + entries.push(v2::MetadataEntry( + v2::MetadataKey::PlatformVersion, value.clone(), )); } @@ -508,9 +459,8 @@ mod tests { 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, ]; - fn runtime_config() -> RuntimeConfig { - RuntimeConfig::new( - "myapp.dot".to_string(), + fn runtime_config() -> PairingHostConfig { + PairingHostConfig::new( HostInfo { name: "Polkadot Web".to_string(), icon: Some("https://example.invalid/dotli.png".to_string()), @@ -537,8 +487,8 @@ mod tests { let VersionedHandshakeProposal::V2(proposal) = decoded; assert_eq!(proposal.device.statement_account_id, SS_PUBLIC); assert_eq!(proposal.device.encryption_public_key, ENC_PUBLIC); - assert!(proposal.metadata.contains(&HandshakeMetadataEntry( - HandshakeMetadataKey::HostName, + assert!(proposal.metadata.contains(&v2::MetadataEntry( + v2::MetadataKey::HostName, "Polkadot Web".to_string() ))); } @@ -621,7 +571,7 @@ mod tests { let mut aes_key = [0u8; 32]; hkdf.expand(&[], &mut aes_key).unwrap(); - let sensitive = EncryptedHandshakeResponseV2::Success(Box::new(HandshakeSuccessV2 { + let sensitive = v2::EncryptedResponse::Success(Box::new(v2::Success { identity_account_id: [8; 32], root_account_id: [7; 32], identity_chat_private_key: [6; 32], diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs new file mode 100644 index 00000000..43f3f277 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs @@ -0,0 +1,57 @@ +//! Host-papp v2 handshake wire types. + +use parity_scale_codec::{Decode, Encode}; + +/// Handshake proposal sent by the host. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct Proposal { + pub device: Device, + pub metadata: Vec, +} + +/// Device keys advertised in the handshake proposal. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct Device { + pub statement_account_id: [u8; 32], + pub encryption_public_key: [u8; 65], +} + +/// Metadata key/value entry attached to a handshake proposal. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct MetadataEntry(pub MetadataKey, pub String); + +/// Metadata keys understood by the mobile SSO pairing flow. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum MetadataKey { + Custom(String), + HostName, + HostVersion, + HostIcon, + PlatformType, + PlatformVersion, +} + +/// Plaintext wallet response after decrypting the pairing statement. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum EncryptedResponse { + Pending(Status), + Success(Box), + Failed(String), +} + +/// Intermediate handshake status emitted before success/failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum Status { + AllowanceAllocation, +} + +/// Successful handshake payload used to establish the SSO session. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct Success { + pub identity_account_id: [u8; 32], + pub root_account_id: [u8; 32], + pub identity_chat_private_key: [u8; 32], + pub sso_enc_pub_key: [u8; 65], + pub device_enc_pub_key: [u8; 65], + pub root_entropy_source: [u8; 32], +} diff --git a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs index b4f29302..faef35c1 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs @@ -3,10 +3,9 @@ use schnorrkel::{PublicKey, SecretKey, Signature}; use truapi::v01; use super::StatementStoreParseError; +use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; use crate::host_logic::session::SsoSessionInfo; -const SR25519_SIGNING_CONTEXT: &[u8] = b"substrate"; - /// Verified statement payload plus the sr25519 signer recovered from proof. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VerifiedStatementData { @@ -281,7 +280,7 @@ fn verify_statement_proof( pub fn statement_fields_from_v01(statement: v01::Statement) -> Result, String> { let mut fields = Vec::new(); if let Some(proof) = statement.proof { - fields.push(StatementField::Proof(statement_proof_from_v01(proof))); + fields.push(StatementField::Proof(proof.into())); } if let Some(decryption_key) = statement.decryption_key { fields.push(StatementField::DecryptionKey(decryption_key)); @@ -301,13 +300,7 @@ pub fn statement_fields_from_v01(statement: v01::Statement) -> Result Result, String> { - Ok(signed_statement_fields(statement)?.encode()) -} - -fn signed_statement_fields(statement: v01::SignedStatement) -> Result, String> { - let mut fields = vec![StatementField::Proof(statement_proof_from_v01( - statement.proof, - ))]; + let mut fields = vec![StatementField::Proof(statement.proof.into())]; if let Some(decryption_key) = statement.decryption_key { fields.push(StatementField::DecryptionKey(decryption_key)); } @@ -322,9 +315,10 @@ fn signed_statement_fields(statement: v01::SignedStatement) -> Result, ) -> Result { @@ -338,7 +332,7 @@ fn signed_statement_from_fields( for field in fields { match field { StatementField::Proof(value) => { - if proof.replace(statement_proof_to_v01(value)).is_some() { + if proof.replace(value.into()).is_some() { return Err(StatementStoreParseError::Malformed( "statement has duplicate proof".to_string(), )); @@ -393,48 +387,56 @@ fn signed_statement_from_fields( /// Convert an internal proof into the public v01 proof shape. pub fn statement_proof_to_v01(proof: StatementProof) -> v01::StatementProof { - match proof { - StatementProof::Sr25519 { signature, signer } => { - v01::StatementProof::Sr25519 { signature, signer } - } - StatementProof::Ed25519 { signature, signer } => { - v01::StatementProof::Ed25519 { signature, signer } - } - StatementProof::Ecdsa { signature, signer } => { - v01::StatementProof::Ecdsa { signature, signer } + proof.into() +} + +impl From for v01::StatementProof { + fn from(proof: StatementProof) -> Self { + match proof { + StatementProof::Sr25519 { signature, signer } => { + v01::StatementProof::Sr25519 { signature, signer } + } + StatementProof::Ed25519 { signature, signer } => { + v01::StatementProof::Ed25519 { signature, signer } + } + StatementProof::Ecdsa { signature, signer } => { + v01::StatementProof::Ecdsa { signature, signer } + } + StatementProof::OnChain { + who, + block_hash, + event, + } => v01::StatementProof::OnChain { + who, + block_hash, + event, + }, } - StatementProof::OnChain { - who, - block_hash, - event, - } => v01::StatementProof::OnChain { - who, - block_hash, - event, - }, } } -fn statement_proof_from_v01(proof: v01::StatementProof) -> StatementProof { - match proof { - v01::StatementProof::Sr25519 { signature, signer } => { - StatementProof::Sr25519 { signature, signer } - } - v01::StatementProof::Ed25519 { signature, signer } => { - StatementProof::Ed25519 { signature, signer } - } - v01::StatementProof::Ecdsa { signature, signer } => { - StatementProof::Ecdsa { signature, signer } +impl From for StatementProof { + fn from(proof: v01::StatementProof) -> Self { + match proof { + v01::StatementProof::Sr25519 { signature, signer } => { + StatementProof::Sr25519 { signature, signer } + } + v01::StatementProof::Ed25519 { signature, signer } => { + StatementProof::Ed25519 { signature, signer } + } + v01::StatementProof::Ecdsa { signature, signer } => { + StatementProof::Ecdsa { signature, signer } + } + v01::StatementProof::OnChain { + who, + block_hash, + event, + } => StatementProof::OnChain { + who, + block_hash, + event, + }, } - v01::StatementProof::OnChain { - who, - block_hash, - event, - } => StatementProof::OnChain { - who, - block_hash, - event, - }, } } From fa642c132b2e95128f454b29511ebf27038bd66c Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 17:26:11 +0200 Subject: [PATCH 18/19] fixup! feat(truapi-server): add host logic primitives --- .../src/host_logic/sso/messages.rs | 72 ++++++++----------- .../src/host_logic/sso/messages/v1.rs | 28 ++++++++ 2 files changed, 56 insertions(+), 44 deletions(-) create mode 100644 rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index abbd7e5d..b4ece9a0 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -33,6 +33,8 @@ use crate::host_logic::statement_store::{ statement_expiry_elapsed, }; +pub mod v1; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, derive_more::Display)] enum SsoResponseCode { #[codec(index = 0)] @@ -71,27 +73,7 @@ pub struct RemoteMessage { /// Versioned remote message body. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum RemoteMessageData { - V1(RemoteMessageV1), -} - -/// v1 messages exchanged with the paired signing host over the encrypted SSO channel. -/// -/// The variant order is part of the SCALE wire protocol used inside -/// statement-store session statements. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum RemoteMessageV1 { - Disconnected, - SignRequest(Box), - SignResponse(SigningResponse), - RingVrfAliasRequest(RingVrfAliasRequest), - RingVrfAliasResponse(RingVrfAliasResponse), - ResourceAllocationRequest(ResourceAllocationRequest), - ResourceAllocationResponse(ResourceAllocationResponse), - CreateTransactionRequest(CreateTransactionRequest), - CreateTransactionResponse(CreateTransactionResponse), - CreateTransactionLegacyRequest(CreateTransactionLegacyRequest), - SignRawLegacyRequest(SignRawLegacyRequest), - SignRawLegacyResponse(SignRawLegacyResponse), + V1(v1::RemoteMessage), } /// Signing request flavor sent to the signing host. @@ -155,7 +137,7 @@ impl From for SigningPayloadRequest { /// string message with a product-derived account. /// /// Built from [`HostSignRawRequest`] and wrapped in -/// [`RemoteMessageV1::SignRequest`] before being encrypted into an SSO session +/// [`v1::RemoteMessage::SignRequest`] before being encrypted into an SSO session /// statement. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SigningRawRequest { @@ -205,7 +187,7 @@ impl From for SigningRawPayload { /// Response returned by the signing host for a product-account signing request. /// -/// Decoded from [`RemoteMessageV1::SignResponse`] while the runtime is waiting +/// Decoded from [`v1::RemoteMessage::SignResponse`] while the runtime is waiting /// for a matching SSO remote message id. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SigningResponse { @@ -222,7 +204,7 @@ pub struct SigningPayloadResponseData { /// Response returned by the signing host for a legacy-account raw signing request. /// -/// Decoded from [`RemoteMessageV1::SignRawLegacyResponse`] and mapped back to +/// Decoded from [`v1::RemoteMessage::SignRawLegacyResponse`] and mapped back to /// the public raw-signing response shape. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SignRawLegacyResponse { @@ -419,7 +401,7 @@ pub fn decode_sso_session_statement( .map_err(|err| format!("invalid SSO remote message: {err}"))?; if matches!( &message.data, - RemoteMessageData::V1(RemoteMessageV1::Disconnected) + RemoteMessageData::V1(v1::RemoteMessage::Disconnected) ) { return Ok(Some(SsoSessionStatement::Disconnected)); } @@ -451,27 +433,27 @@ fn remote_response_for_message( ) -> Option { let RemoteMessageData::V1(data) = message.data; match data { - RemoteMessageV1::SignResponse(response) + v1::RemoteMessage::SignResponse(response) if response.responding_to == expected_remote_message_id => { Some(SsoRemoteResponse::Sign(response)) } - RemoteMessageV1::RingVrfAliasResponse(response) + v1::RemoteMessage::RingVrfAliasResponse(response) if response.responding_to == expected_remote_message_id => { Some(SsoRemoteResponse::RingVrfAlias(response)) } - RemoteMessageV1::SignRawLegacyResponse(response) + v1::RemoteMessage::SignRawLegacyResponse(response) if response.responding_to == expected_remote_message_id => { Some(SsoRemoteResponse::SignRawLegacy(response)) } - RemoteMessageV1::ResourceAllocationResponse(response) + v1::RemoteMessage::ResourceAllocationResponse(response) if response.responding_to == expected_remote_message_id => { Some(SsoRemoteResponse::ResourceAllocation(response)) } - RemoteMessageV1::CreateTransactionResponse(response) + v1::RemoteMessage::CreateTransactionResponse(response) if response.responding_to == expected_remote_message_id => { Some(SsoRemoteResponse::CreateTransaction(response)) @@ -484,7 +466,7 @@ fn remote_response_for_message( pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) -> RemoteMessage { RemoteMessage { message_id, - data: RemoteMessageData::V1(RemoteMessageV1::SignRequest(Box::new( + data: RemoteMessageData::V1(v1::RemoteMessage::SignRequest(Box::new( SigningRequest::Payload(Box::new(request.into())), ))), } @@ -494,9 +476,9 @@ pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) pub fn sign_raw_message(message_id: String, request: HostSignRawRequest) -> RemoteMessage { RemoteMessage { message_id, - data: RemoteMessageData::V1(RemoteMessageV1::SignRequest(Box::new(SigningRequest::Raw( - request.into(), - )))), + data: RemoteMessageData::V1(v1::RemoteMessage::SignRequest(Box::new( + SigningRequest::Raw(request.into()), + ))), } } @@ -508,7 +490,7 @@ pub fn sign_raw_legacy_message( ) -> RemoteMessage { RemoteMessage { message_id, - data: RemoteMessageData::V1(RemoteMessageV1::SignRawLegacyRequest( + data: RemoteMessageData::V1(v1::RemoteMessage::SignRawLegacyRequest( SignRawLegacyRequest { account, data: payload.into(), @@ -525,10 +507,12 @@ pub fn alias_request_message( ) -> RemoteMessage { RemoteMessage { message_id, - data: RemoteMessageData::V1(RemoteMessageV1::RingVrfAliasRequest(RingVrfAliasRequest { - product_account_id, - product_id, - })), + data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfAliasRequest( + RingVrfAliasRequest { + product_account_id, + product_id, + }, + )), } } @@ -541,7 +525,7 @@ pub fn resource_allocation_message( ) -> RemoteMessage { RemoteMessage { message_id, - data: RemoteMessageData::V1(RemoteMessageV1::ResourceAllocationRequest( + data: RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationRequest( ResourceAllocationRequest { calling_product_id, resources: resources.into_iter().map(Into::into).collect(), @@ -558,7 +542,7 @@ pub fn create_transaction_message( ) -> RemoteMessage { RemoteMessage { message_id, - data: RemoteMessageData::V1(RemoteMessageV1::CreateTransactionRequest( + data: RemoteMessageData::V1(v1::RemoteMessage::CreateTransactionRequest( CreateTransactionRequest { payload: CreateTransactionPayload::V1(payload), }, @@ -573,7 +557,7 @@ pub fn create_transaction_legacy_message( ) -> RemoteMessage { RemoteMessage { message_id, - data: RemoteMessageData::V1(RemoteMessageV1::CreateTransactionLegacyRequest( + data: RemoteMessageData::V1(v1::RemoteMessage::CreateTransactionLegacyRequest( CreateTransactionLegacyRequest { payload: CreateTransactionLegacyPayload::V1(payload), }, @@ -698,7 +682,7 @@ mod tests { fn disconnected_message_matches_host_papp_variant_order() { let message = RemoteMessage { message_id: String::new(), - data: RemoteMessageData::V1(RemoteMessageV1::Disconnected), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), }; assert_eq!(message.encode(), vec![0, 0, 0]); @@ -920,7 +904,7 @@ mod tests { ], OnExistingAllowancePolicy::Increase, ); - let RemoteMessageData::V1(RemoteMessageV1::ResourceAllocationRequest(request)) = + let RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationRequest(request)) = message.data else { panic!("expected resource allocation request"); diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs new file mode 100644 index 00000000..9e2b9cbd --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs @@ -0,0 +1,28 @@ +use parity_scale_codec::{Decode, Encode}; + +use super::{ + CreateTransactionLegacyRequest, CreateTransactionRequest, CreateTransactionResponse, + ResourceAllocationRequest, ResourceAllocationResponse, RingVrfAliasRequest, + RingVrfAliasResponse, SignRawLegacyRequest, SignRawLegacyResponse, SigningRequest, + SigningResponse, +}; + +/// v1 messages exchanged with the paired signing host over the encrypted SSO channel. +/// +/// The variant order is part of the SCALE wire protocol used inside +/// statement-store session statements. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum RemoteMessage { + Disconnected, + SignRequest(Box), + SignResponse(SigningResponse), + RingVrfAliasRequest(RingVrfAliasRequest), + RingVrfAliasResponse(RingVrfAliasResponse), + ResourceAllocationRequest(ResourceAllocationRequest), + ResourceAllocationResponse(ResourceAllocationResponse), + CreateTransactionRequest(CreateTransactionRequest), + CreateTransactionResponse(CreateTransactionResponse), + CreateTransactionLegacyRequest(CreateTransactionLegacyRequest), + SignRawLegacyRequest(SignRawLegacyRequest), + SignRawLegacyResponse(SignRawLegacyResponse), +} From 54f0b20bd6135614baac537a993d4a4b08489d7c Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 3 Jul 2026 18:23:22 +0200 Subject: [PATCH 19/19] docs(host-logic): link host-spec sections --- .../truapi-server/src/host_logic/entropy.rs | 2 ++ .../truapi-server/src/host_logic/identity.rs | 2 ++ .../src/host_logic/product_account.rs | 6 ++++++ .../truapi-server/src/host_logic/session.rs | 7 +++++++ .../truapi-server/src/host_logic/sso/messages.rs | 5 ++++- .../src/host_logic/sso/messages/v1.rs | 7 +++++++ .../truapi-server/src/host_logic/sso/pairing.rs | 15 +++++++++++++++ .../src/host_logic/sso/pairing/v2.rs | 4 ++++ .../src/host_logic/statement_store.rs | 2 ++ .../src/host_logic/statement_store/rpc.rs | 3 +++ .../src/host_logic/statement_store/statement.rs | 9 +++++++++ 11 files changed, 61 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi-server/src/host_logic/entropy.rs b/rust/crates/truapi-server/src/host_logic/entropy.rs index 1e9b4193..cbdd3fe2 100644 --- a/rust/crates/truapi-server/src/host_logic/entropy.rs +++ b/rust/crates/truapi-server/src/host_logic/entropy.rs @@ -2,6 +2,8 @@ //! //! Matches dotli's product entropy contract: three keyed BLAKE2b-256 layers //! over the session secret, product id, and caller key. +//! Host-spec C.8 defines the RFC-0007 product entropy algorithm: +//! use blake2_rfc::blake2b::blake2b; use thiserror::Error; diff --git a/rust/crates/truapi-server/src/host_logic/identity.rs b/rust/crates/truapi-server/src/host_logic/identity.rs index 0fbac76f..4f798825 100644 --- a/rust/crates/truapi-server/src/host_logic/identity.rs +++ b/rust/crates/truapi-server/src/host_logic/identity.rs @@ -5,6 +5,8 @@ //! it builds that storage key and decodes the leading username fields from the //! SCALE value. The record begins with a fixed identifier public key; credibility //! and statement-store slots are intentionally ignored. +//! Host-spec G defines the cross-host identity model and lookup behavior: +//! use parity_scale_codec::Decode; use sp_crypto_hashing::{blake2_128, twox_128}; diff --git a/rust/crates/truapi-server/src/host_logic/product_account.rs b/rust/crates/truapi-server/src/host_logic/product_account.rs index 8298907c..04298daa 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -2,6 +2,9 @@ //! //! Mirrors dotli's `packages/auth/src/account.ts`: derive an sr25519 public //! key through soft HDKD junctions `["product", product_id, derivation_index]`. +//! Host-spec C.5-C.7 define the product-account derivation, SS58 address, and +//! `ProductAccountId` shape: +//! use blake2_rfc::blake2b::blake2b; use parity_scale_codec::Encode; @@ -30,6 +33,9 @@ pub enum ProductAccountError { /// Derive the root sr25519 keypair from raw BIP-39 entropy. /// +/// Host-spec C.1 defines the BIP-39 entropy to sr25519 mini-secret path: +/// +/// /// Matches the Substrate mini-secret scheme (`sp_core::sr25519::Pair::from_entropy`) /// used by polkadot-app-ios-v2: PBKDF2 over the entropy to a 32-byte mini /// secret, then Ed25519-mode expansion. The public key of this keypair is the diff --git a/rust/crates/truapi-server/src/host_logic/session.rs b/rust/crates/truapi-server/src/host_logic/session.rs index 261c110a..3eb51ff8 100644 --- a/rust/crates/truapi-server/src/host_logic/session.rs +++ b/rust/crates/truapi-server/src/host_logic/session.rs @@ -2,6 +2,13 @@ //! unpairing with a signing host changes the inter-host session, and //! account-management methods read it instead of round-tripping host callbacks //! on every product call. +//! +//! Host-spec B.1.5 and B.3.1 define the remote account keys and session topics: +//! +//! +//! The persisted blob is core-owned and host-local; storage.md captures current +//! cross-host persistence status quo: +//! use futures::channel::mpsc; use futures::stream::{self, BoxStream, StreamExt}; diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index b4ece9a0..f801abae 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -6,7 +6,10 @@ //! allocation, and transaction requests to the paired signing host, then //! decodes the signing host's responses while waiting on the SSO //! statement-store channels. -//! The envelope and baseline message catalog are specified in host-spec: +//! The encrypted statement envelope and message identifiers are specified in +//! host-spec: +//! +//! The baseline remote message catalog is specified in host-spec: //! //! Deployed extension variants are tracked as a host-spec divergence: //! diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs index 9e2b9cbd..cbd0204a 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs @@ -1,3 +1,10 @@ +//! V1 application messages exchanged on the encrypted SSO channel. +//! +//! Baseline variants are specified in host-spec B.5: +//! +//! Additional deployed variants are tracked as divergence D-B.5.6: +//! + use parity_scale_codec::{Decode, Encode}; use super::{ diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs index 93231638..eff47058 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -2,6 +2,12 @@ //! //! This module owns the byte shape of the QR/deeplink payload described in //! `docs/design/host-contract-and-core-impl/H - sso-pairing-protocol.md`. +//! The inter-host pairing flow is specified in host-spec B.1: +//! +//! The deeplink route and hex construction rules are specified in host-spec L: +//! +//! Session crypto and statement-store framing are specified in host-spec B.2-B.4: +//! //! The SCALE handshake codecs are kept wire-compatible with host-papp's v2 //! handshake codec: //! @@ -62,6 +68,9 @@ pub enum PairingBootstrapError { } /// Versioned SCALE payload embedded in the pairing deeplink. +/// +/// Host-spec B.1.1 defines the deeplink as lowercase hex of this SCALE payload: +/// #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum VersionedHandshakeProposal { #[codec(index = 1)] @@ -69,6 +78,9 @@ pub enum VersionedHandshakeProposal { } /// Versioned encrypted response posted by the wallet to the pairing topic. +/// +/// Host-spec B.1.4 defines the encrypted answer statement and matching topic: +/// #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum VersionedHandshakeResponse { #[codec(index = 1)] @@ -83,6 +95,9 @@ pub mod v2; /// Encrypted statement-channel envelope shared with the wallet. /// +/// Host-spec B.4.1 defines this request/response wrapper: +/// +/// /// Mirrors `@novasamatech/statement-store` session statement data: /// #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs index 43f3f277..4118a8c7 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs @@ -1,4 +1,8 @@ //! Host-papp v2 handshake wire types. +//! +//! Host-spec B.1 defines the pairing handshake shape that this deployed v2 +//! codec implements as a wire-compatible host-papp dialect: +//! use parity_scale_codec::{Decode, Encode}; diff --git a/rust/crates/truapi-server/src/host_logic/statement_store.rs b/rust/crates/truapi-server/src/host_logic/statement_store.rs index 06980545..3068285b 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store.rs @@ -4,6 +4,8 @@ //! `ChainProvider` JSON-RPC connection. Transport mechanics live in //! `HostRpcClient`; this module owns statement-store payload encoding, //! proof verification, and subscription-result parsing. +//! Host-spec N.5 names the statement-store JSON-RPC methods hosts use: +//! use thiserror::Error; diff --git a/rust/crates/truapi-server/src/host_logic/statement_store/rpc.rs b/rust/crates/truapi-server/src/host_logic/statement_store/rpc.rs index bedbce5d..28e71554 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store/rpc.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store/rpc.rs @@ -1,5 +1,8 @@ //! Statement-store JSON-RPC shapes mirrored from `sp_statement_store`. //! +//! Host-spec N.5 lists the statement-store methods hosts must use: +//! +//! //! See the upstream RPC methods plus `TopicFilter` / `StatementEvent` types: //! //! diff --git a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs index faef35c1..c90bb848 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs @@ -7,6 +7,10 @@ use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; use crate::host_logic::session::SsoSessionInfo; /// Verified statement payload plus the sr25519 signer recovered from proof. +/// +/// SSO receivers verify incoming statement proofs against the paired +/// `identityAccountId` as required by host-spec B.3.4: +/// #[derive(Debug, Clone, PartialEq, Eq)] pub struct VerifiedStatementData { /// Raw statement data field. @@ -120,6 +124,11 @@ pub fn decode_signed_statement( } /// Build a signed statement on the active SSO request channel. +/// +/// Host-spec B.3.1 defines directional session topics; B.4.1 defines the +/// encrypted statement `data` wrapper carried by this statement: +/// +/// pub fn build_signed_session_request_statement( session: &SsoSessionInfo, encrypted_data: Vec,