From ddf07864091f8d8e56c8e65ead25e1ed8bd5cbea Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 17 Jul 2026 19:07:23 +0200 Subject: [PATCH 01/34] feat(core): implement ring-vrf signing --- Cargo.lock | 390 ++++++++++++- deny.toml | 4 + rust/crates/truapi-server/Cargo.toml | 2 + rust/crates/truapi-server/README.md | 16 +- rust/crates/truapi-server/src/host_core.rs | 9 +- .../truapi-server/src/runtime/signing_host.rs | 200 ++++++- .../src/runtime/signing_host/ring_vrf.rs | 517 ++++++++++++++++++ .../src/runtime/statement_store.rs | 2 +- 8 files changed, 1112 insertions(+), 28 deletions(-) create mode 100644 rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs diff --git a/Cargo.lock b/Cargo.lock index 5c8b247b..8b848555 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,194 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ark-bls12-381" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +dependencies = [ + "ahash", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", + "itertools 0.13.0", + "num-bigint", + "num-integer", + "num-traits", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ed-on-bls12-381-bandersnatch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1786b2e3832f6f0f7c8d62d5d5a282f6952a1ab99981c54cd52b6ac1d8f02df5" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-std", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "arrayvec 0.7.6", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ark-poly" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" +dependencies = [ + "ahash", + "ark-ff", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", + "rayon", +] + +[[package]] +name = "ark-scale" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985c81a9c7b23a72f62b7b20686d5326d2a9956806f37de9ee35cb1238faf0c0" +dependencies = [ + "ark-serialize", + "ark-std", + "parity-scale-codec", + "scale-info", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "arrayvec 0.7.6", + "digest 0.10.7", + "num-bigint", + "rayon", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand", + "rayon", +] + +[[package]] +name = "ark-transcript" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c1c928edb9d8ff24cb5dcb7651d3a98494fff3099eee95c2404cd813a9139f" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "digest 0.10.7", + "rand_core", + "sha3", +] + +[[package]] +name = "ark-vrf" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b9bd02dbd2f282fe742d51f681adb2745a79dc8a025bc8d16cac9b255d55bf7" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ed-on-bls12-381-bandersnatch", + "ark-ff", + "ark-serialize", + "ark-std", + "digest 0.10.7", + "generic-array", + "rayon", + "sha2 0.10.9", + "w3f-ring-proof", + "zeroize", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -407,6 +595,19 @@ dependencies = [ "piper", ] +[[package]] +name = "bounded-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee8eddd066a8825ec5570528e6880471210fd5d88cb6abbe1cfdd51ca249c33" +dependencies = [ + "jam-codec", + "log", + "parity-scale-codec", + "scale-info", + "serde", +] + [[package]] name = "bs58" version = "0.5.1" @@ -646,6 +847,25 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -899,6 +1119,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.16.0" @@ -924,6 +1156,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "enum-ordinalize" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1240,6 +1492,7 @@ version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea1015b5a70616b688dc230cfe50c8af89d972cb132d5a622814d29773b10b9" dependencies = [ + "rand", "rand_core", ] @@ -1326,6 +1579,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", "foldhash 0.1.5", ] @@ -1610,6 +1864,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1625,6 +1888,34 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jam-codec" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb948eace373d99de60501a02fb17125d30ac632570de20dccc74370cdd611b9" +dependencies = [ + "arrayvec 0.7.6", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "jam-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "jam-codec-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "319af585c4c8a6b5552a52b7787a1ab3e4d59df7614190b1f85b9b842488789d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "jni" version = "0.21.1" @@ -2133,6 +2424,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -2388,6 +2685,26 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3000,7 +3317,7 @@ dependencies = [ "hashbrown 0.16.1", "hex", "hmac 0.12.1", - "itertools", + "itertools 0.14.0", "libm", "libsecp256k1", "merlin", @@ -3056,7 +3373,7 @@ dependencies = [ "hashbrown 0.16.1", "hex", "hmac 0.12.1", - "itertools", + "itertools 0.14.0", "libm", "libsecp256k1", "merlin", @@ -3105,7 +3422,7 @@ dependencies = [ "futures-util", "hashbrown 0.16.1", "hex", - "itertools", + "itertools 0.14.0", "log", "lru", "parking_lot", @@ -3704,6 +4021,7 @@ dependencies = [ "nanoid", "p256", "parity-scale-codec", + "scale-decode", "scale-info", "schnorrkel", "send_wrapper 0.6.0", @@ -3721,6 +4039,7 @@ dependencies = [ "truapi-platform", "unicode-normalization", "url", + "verifiable", "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test", @@ -3838,12 +4157,77 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "verifiable" +version = "0.5.0" +source = "git+https://github.com/paritytech/verifiable.git?rev=19b03deb89b8dea484b143afc8bbe048a74bc1ff#19b03deb89b8dea484b143afc8bbe048a74bc1ff" +dependencies = [ + "ark-scale", + "ark-serialize", + "ark-vrf", + "bounded-collections", + "parity-scale-codec", + "scale-info", + "sha2 0.10.9", + "smallvec", + "spin", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "w3f-pcs" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ea1046a1deb6d26c34ba2d1f1bab4222d695d126502ee765f80b021753cb674" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "merlin", + "rayon", +] + +[[package]] +name = "w3f-plonk-common" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30408cda37b81bd7257319942584c794c5784d00d749757bc664656749a1472a" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "getrandom_or_panic", + "rand_core", + "rayon", + "w3f-pcs", +] + +[[package]] +name = "w3f-ring-proof" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cbfc4cb881a934e6f33c25927bf955d0cb18e52b94528bbc5fa28dddedb4cd1" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "ark-transcript", + "rayon", + "w3f-pcs", + "w3f-plonk-common", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/deny.toml b/deny.toml index 9305a03f..1bdbccea 100644 --- a/deny.toml +++ b/deny.toml @@ -19,6 +19,10 @@ confidence-threshold = 0.8 # dependency, which MPL-2.0 permits without affecting the MIT outbound licence. # Scoped per crate so MPL-2.0 stays disallowed everywhere else. exceptions = [ + # Ring-VRF implementation shared with the Individuality runtimes and Nova. + # The Classpath exception permits linking it without changing this crate's + # MIT outbound licence. + { name = "verifiable", allow = ["GPL-3.0-or-later WITH Classpath-exception-2.0"] }, { name = "uniffi", allow = ["MPL-2.0"] }, { name = "uniffi_bindgen", allow = ["MPL-2.0"] }, { name = "uniffi_core", allow = ["MPL-2.0"] }, diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index bc068de5..1d23a276 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -39,6 +39,7 @@ derive_more = { version = "2", features = ["debug", "display", "error"] } futures = "0.3" futures-timer = "3" parity-scale-codec = { version = "3", features = ["derive"] } +scale-decode = { version = "0.16", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "1" @@ -60,6 +61,7 @@ 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"] } +verifiable = { git = "https://github.com/paritytech/verifiable.git", rev = "19b03deb89b8dea484b143afc8bbe048a74bc1ff" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] subxt = { version = "0.50.2", default-features = false, features = ["native"] } diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index a69dca86..cabe2e22 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -84,9 +84,9 @@ stage in the frame path; the host's `Platform` impl is the syscall floor. `ProductRuntimeHost` handles everything role-neutral (id normalization, permission gating, confirmation, soft product-key derivation), then delegates the wallet-authority tail (`sign_*`, `create_transaction`, `account_alias`, -`allocate_resources`, `derive_entropy`) through an `Arc` -handle with an `AuthoritySession` snapshot the role revalidates before touching -key material. +`create_proof`, `allocate_resources`, `derive_entropy`) through an +`Arc` handle with an `AuthoritySession` snapshot the +role revalidates before touching key material. ### Permission flow @@ -185,9 +185,13 @@ role-specific lifecycle, so no method exists on a role that can't mean it: signing-host liveness monitoring. - **`SigningHost`** (wallet-local): signs on device from local BIP-39 entropy, no pairing flow. `signing_host/local_activation.rs` establishes a session - from host-held secret material. Extrinsic signing / transaction construction / - ring-VRF aliases / resource allocation currently return `Unavailable` pending - chain-metadata and on-chain support. + from host-held secret material. It derives the same full- and lite-person + Bandersnatch keys as Nova, resolves RFC-0004 `RingLocation` values against + the chain's `Members` pallet, and pins membership, ring pages, exponent, and + revision reads to one finalized block before creating an alias or proof. + Full personhood is preferred over lite personhood. Extrinsic-payload signing + and resource allocation still return `Unavailable` pending chain-metadata + and on-chain support. `host_logic` stays pure: the orchestrators above call into it for codecs, session/SSO crypto, key derivation, and permission policy, while all I/O diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 1dbae792..69da527f 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -185,9 +185,10 @@ impl PairingHostAdmin for PairingHostRuntime { /// Owns the shared services plus signing-host state. There is no pairing flow, /// so pairing cancellation is not present here. /// -/// Raw-bytes signing and product entropy are implemented; extrinsic-payload -/// signing, transaction construction, ring-VRF aliases, and resource allocation -/// return an `Unavailable` error pending chain-metadata and on-chain support. +/// Raw-bytes signing, transaction construction, product entropy, and RFC-0004 +/// ring-VRF aliases/proofs are implemented; extrinsic-payload signing and +/// resource allocation return an `Unavailable` error pending chain-metadata +/// and on-chain support. pub struct SigningHostRuntime { services: Arc, signing_host: Arc, @@ -207,7 +208,7 @@ impl SigningHostRuntime { config.bulletin_chain_genesis_hash, spawner, ); - let signing_host = SigningHostRole::new(platform); + let signing_host = SigningHostRole::new(services.clone()); Self { services, signing_host, diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 886853b8..afe09e97 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -7,6 +7,7 @@ //! for the session, zeroized on disconnect. mod local_activation; +mod ring_vrf; use std::sync::{Arc, Mutex}; @@ -18,7 +19,7 @@ use super::authority::{ SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, authority_session, require_current_session, }; -use super::connected_session_ui_info; +use super::{RuntimeServices, connected_session_ui_info}; use crate::host_logic::entropy::derive_product_entropy; use crate::host_logic::extrinsic::{Sr25519Signer, build_signed_extrinsic_v4}; use crate::host_logic::product_account::{ @@ -28,10 +29,16 @@ use crate::host_logic::product_account::{ use crate::host_logic::session::SessionState; use crate::host_logic::sso::messages::RingVrfError; use crate::runtime::auth_state::AuthStateMachine; +use ring_vrf::{ + ChainRingResolver, MemberCandidate, PersonKey, RingResolver, alias_from_entropy, context_bytes, + create_proof, key_for_collection, member_from_entropy, person_entropy, +}; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, v01}; -use truapi_platform::{Platform, ProductContext, normalize_product_identifier}; +#[cfg(test)] +use truapi_platform::Platform; +use truapi_platform::{ProductContext, normalize_product_identifier}; use zeroize::Zeroizing; const BYTES_WRAP_PREFIX: &[u8] = b""; @@ -41,15 +48,32 @@ const BYTES_WRAP_SUFFIX: &[u8] = b""; pub(crate) struct SigningHost { session_state: Arc, auth_state: AuthStateMachine, + ring_resolver: Arc, /// Root BIP-39 entropy held only while a session is active. root_entropy: Mutex>>>, } impl SigningHost { - pub(crate) fn new(platform: Arc) -> Arc { + pub(crate) fn new(services: Arc) -> Arc { + let platform = services.platform.clone(); + let ring_resolver = ChainRingResolver::new(services.chain.clone()); + Arc::new(Self { + session_state: SessionState::new(), + auth_state: AuthStateMachine::new(platform), + ring_resolver, + root_entropy: Mutex::new(None), + }) + } + + #[cfg(test)] + fn new_with_ring_resolver( + platform: Arc, + ring_resolver: Arc, + ) -> Arc { Arc::new(Self { session_state: SessionState::new(), auth_state: AuthStateMachine::new(platform), + ring_resolver, root_entropy: Mutex::new(None), }) } @@ -88,6 +112,34 @@ impl SigningHost { derive_product_keypair(&root, &product_id, account.derivation_index) .map_err(product_authority_error) } + + fn person_entropy( + &self, + session: &AuthoritySession, + key: PersonKey, + ) -> Result, RingVrfError> { + require_current_session(&self.session_state, session)?; + let root = self.root_entropy()?; + Ok(person_entropy(&root, key)) + } + + fn member_candidates( + &self, + session: &AuthoritySession, + ) -> Result<[MemberCandidate; 2], RingVrfError> { + let full_entropy = self.person_entropy(session, PersonKey::Full)?; + let lite_entropy = self.person_entropy(session, PersonKey::Lite)?; + Ok([ + MemberCandidate { + key: PersonKey::Full, + member: member_from_entropy(&full_entropy)?, + }, + MemberCandidate { + key: PersonKey::Lite, + member: member_from_entropy(&lite_entropy)?, + }, + ]) + } } #[async_trait::async_trait] @@ -213,22 +265,44 @@ impl ProductAuthority for SigningHost { async fn account_alias( &self, _cx: &CallContext, - _session: &AuthoritySession, - _request: AccountAliasAuthorityRequest, + session: &AuthoritySession, + request: AccountAliasAuthorityRequest, ) -> Result { - Err(RingVrfError::Unknown { - reason: "signing host: ring-VRF alias derivation not yet implemented".to_string(), + require_current_session(&self.session_state, session)?; + let collection = self.ring_resolver.validate(&request.ring_location).await?; + let context = context_bytes(&request.context); + let entropy = self.person_entropy(session, key_for_collection(&collection))?; + let alias = alias_from_entropy(&entropy, &context)?; + Ok(v01::ContextualAlias { + context, + alias: alias.to_vec(), }) } async fn create_proof( &self, _cx: &CallContext, - _session: &AuthoritySession, - _request: CreateProofAuthorityRequest, + session: &AuthoritySession, + request: CreateProofAuthorityRequest, ) -> Result { - Err(RingVrfError::Unknown { - reason: "signing host: ring-VRF proof generation not yet implemented".to_string(), + let candidates = self.member_candidates(session)?; + let resolved = self + .ring_resolver + .resolve(&request.ring_location, &candidates) + .await?; + // Reject a stale request if the local session disconnected or changed + // while its chain snapshot was being resolved. + let entropy = self.person_entropy(session, resolved.selected.key)?; + let context = context_bytes(&request.context); + let (proof, alias) = create_proof(&entropy, &resolved, &context, &request.message)?; + Ok(v01::HostAccountCreateProofResponse { + proof, + contextual_alias: v01::ContextualAlias { + context, + alias: alias.to_vec(), + }, + ring_index: resolved.ring_index, + ring_revision: resolved.ring_revision, }) } @@ -382,10 +456,16 @@ mod tests { use std::sync::Arc; use super::super::authority::{ - AuthorityError, CreateTransactionAuthorityRequest, SignRawAuthorityRequest, + AccountAliasAuthorityRequest, AuthorityError, CreateProofAuthorityRequest, + CreateTransactionAuthorityRequest, SignRawAuthorityRequest, }; use super::super::{ProductAuthority, ProductRuntimeHost, RuntimeServices, SigningHostRole}; - use super::{BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, raw_payload_bytes}; + use super::ring_vrf::{ + MemberCandidate, PersonKey, ResolvedRing, RingResolver, member_from_entropy, person_entropy, + }; + use super::{ + BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, RingVrfError, raw_payload_bytes, + }; use crate::host_logic::extrinsic::tests::split_v4; use crate::host_logic::product_account::{ derive_product_keypair, derive_root_keypair_from_entropy, @@ -397,9 +477,35 @@ mod tests { use truapi::versioned::signing::{HostSignRawError, HostSignRawRequest, HostSignRawResponse}; use truapi::{CallContext, CallError, v01}; use truapi_platform::{HostInfo, PlatformInfo, ProductContext, SigningHostConfig}; + use verifiable::ring::RingDomainSize; const ENTROPY: [u8; 16] = [0xAB; 16]; + #[derive(Clone)] + struct StubRingResolver { + collection: [u8; 32], + ring: ResolvedRing, + } + + #[async_trait::async_trait] + impl RingResolver for StubRingResolver { + async fn validate(&self, _location: &v01::RingLocation) -> Result<[u8; 32], RingVrfError> { + Ok(self.collection) + } + + async fn resolve( + &self, + _location: &v01::RingLocation, + candidates: &[MemberCandidate], + ) -> Result { + assert!( + candidates.contains(&self.ring.selected), + "signing host offered the selected person key" + ); + Ok(self.ring.clone()) + } + } + fn signing_runtime() -> (Arc, Arc) { // Auto-confirm raw signing so the role-neutral confirmation gate does // not reject before reaching the signing authority. @@ -424,7 +530,7 @@ mod tests { config.bulletin_chain_genesis_hash, test_spawner(), ); - let signing_host = SigningHostRole::new(platform); + let signing_host = SigningHostRole::new(services.clone()); (services, signing_host) } @@ -451,6 +557,72 @@ mod tests { ) } + #[test] + fn ring_alias_and_proof_share_the_selected_person_key() { + let full_entropy = person_entropy(&ENTROPY, PersonKey::Full); + let full_member = member_from_entropy(&full_entropy).expect("full-person member"); + let selected = MemberCandidate { + key: PersonKey::Full, + member: full_member, + }; + let resolver = Arc::new(StubRingResolver { + collection: *b"pop:polkadot.network/people ", + ring: ResolvedRing { + selected, + ring_index: 7, + ring_revision: 11, + domain_size: RingDomainSize::Domain11, + members: vec![full_member], + }, + }); + let platform: Arc = Arc::new(StubPlatform::default()); + let authority = SigningHostRole::new_with_ring_resolver(platform, resolver); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = authority.current_session().expect("active session"); + let cx = CallContext::new(); + let context = v01::ProductProofContext { + product_id: "myapp.dot".to_string(), + suffix: b"account".to_vec(), + }; + let ring_location = v01::RingLocation { + chain_id: [0x22; 32], + junctions: vec![ + v01::RingLocationJunction::PalletInstance(42), + v01::RingLocationJunction::CollectionId( + b"pop:polkadot.network/people ".to_vec(), + ), + ], + }; + + let alias = futures::executor::block_on(authority.account_alias( + &cx, + &session, + AccountAliasAuthorityRequest { + calling_product_id: "myapp.dot".to_string(), + context: context.clone(), + ring_location: ring_location.clone(), + }, + )) + .expect("alias succeeds"); + let proof = futures::executor::block_on(authority.create_proof( + &cx, + &session, + CreateProofAuthorityRequest { + calling_product_id: "myapp.dot".to_string(), + context, + ring_location, + message: b"prove me".to_vec(), + }, + )) + .expect("proof succeeds"); + + assert!(!proof.proof.is_empty()); + assert_eq!(proof.contextual_alias, alias); + assert_eq!(proof.ring_index, 7); + assert_eq!(proof.ring_revision, 11); + } + #[test] fn activate_then_sign_raw_verifies_against_derived_product_key() { let (services, activation) = signing_runtime(); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs b/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs new file mode 100644 index 00000000..cf2bb939 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs @@ -0,0 +1,517 @@ +//! Ring location resolution and Bandersnatch ring-VRF primitives. +//! +//! The resolver mirrors Nova's RFC-0004 implementation: it validates the +//! requested Members pallet from runtime metadata, pins every storage read to +//! one finalized block, selects the full-person key before the lite-person key, +//! and returns the ring members, exponent, and revision from that snapshot. + +use std::sync::Arc; + +use async_trait::async_trait; +use subxt::dynamic; +use subxt::ext::scale_decode::DecodeAsType; +use truapi::v01::{ProductProofContext, RingLocation, RingLocationJunction}; +use verifiable::GenerateVerifiable; +use verifiable::ring::RingDomainSize; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; +use zeroize::Zeroizing; + +use crate::chain_runtime::ChainRuntime; +use crate::host_logic::sso::messages::RingVrfError; + +const MEMBERS_PALLET: &str = "Members"; +const FULL_PERSON_COLLECTION: [u8; 32] = *b"pop:polkadot.network/people "; +const LITE_PERSON_COLLECTION: [u8; 32] = *b"pop:polkadot.network/people-lite"; +const FULL_PERSON_ENTROPY_KEY: &[u8] = b"candidate"; + +type RingMember = ::Member; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum PersonKey { + Full, + Lite, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct MemberCandidate { + pub(super) key: PersonKey, + pub(super) member: [u8; 32], +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ResolvedRing { + pub(super) selected: MemberCandidate, + pub(super) ring_index: u32, + pub(super) ring_revision: u32, + pub(super) domain_size: RingDomainSize, + pub(super) members: Vec<[u8; 32]>, +} + +#[async_trait] +pub(super) trait RingResolver: Send + Sync { + /// Validate the chain and Members pallet, returning the requested + /// collection (or the RFC-0004 full-person fallback). + async fn validate(&self, location: &RingLocation) -> Result<[u8; 32], RingVrfError>; + + /// Resolve a current, single-block ring snapshot and select the first + /// candidate with an active membership. + async fn resolve( + &self, + location: &RingLocation, + candidates: &[MemberCandidate], + ) -> Result; +} + +pub(super) struct ChainRingResolver { + chain: ChainRuntime, +} + +impl ChainRingResolver { + pub(super) fn new(chain: ChainRuntime) -> Arc { + Arc::new(Self { chain }) + } + + async fn at_ring( + &self, + location: &RingLocation, + ) -> Result< + subxt::client::OnlineClientAtBlock, + RingVrfError, + > { + let client = self + .chain + .online_client(&location.chain_id) + .await + .map_err(unknown)?; + let at_block = client.at_current_block().await.map_err(unknown)?; + let Some(pallet) = at_block.metadata_ref().pallet_by_name(MEMBERS_PALLET) else { + return Err(RingVrfError::RingNotFound); + }; + if let Some(expected) = pallet_instance(location) + && pallet.call_index() != expected + { + return Err(RingVrfError::RingNotFound); + } + Ok(at_block) + } +} + +#[async_trait] +impl RingResolver for ChainRingResolver { + async fn validate(&self, location: &RingLocation) -> Result<[u8; 32], RingVrfError> { + self.at_ring(location).await?; + collection_id(location) + } + + async fn resolve( + &self, + location: &RingLocation, + candidates: &[MemberCandidate], + ) -> Result { + let collection = collection_id(location)?; + let at_block = self.at_ring(location).await?; + let storage = at_block.storage(); + + let mut selected = None; + for candidate in candidates { + let address = + dynamic::storage::<([u8; 32], [u8; 32]), RingPosition>(MEMBERS_PALLET, "Members"); + let Some(position) = storage + .try_fetch(address, (collection, candidate.member)) + .await + .map_err(unknown)? + else { + continue; + }; + let RingPosition::Included { + ring_index, + ring_position, + .. + } = position.decode().map_err(unknown)? + else { + continue; + }; + + let status_address = + dynamic::storage::<([u8; 32], u32), RingStatus>(MEMBERS_PALLET, "RingKeysStatus"); + let status = storage + .fetch(status_address, (collection, ring_index)) + .await + .map_err(unknown)? + .decode() + .map_err(unknown)?; + if status.included > ring_position { + selected = Some((*candidate, ring_index, status.included)); + break; + } + } + + let Some((selected, ring_index, included_count)) = selected else { + return Err(RingVrfError::NotMember); + }; + + let collection_address = + dynamic::storage::<([u8; 32],), CollectionInfo>(MEMBERS_PALLET, "Collections"); + let Some(collection_info) = storage + .try_fetch(collection_address, (collection,)) + .await + .map_err(unknown)? + else { + return Err(RingVrfError::RingNotFound); + }; + let collection_info = collection_info.decode().map_err(unknown)?; + + let ring_keys_address = + dynamic::storage::<([u8; 32], u32, u32), BoundedMembers>(MEMBERS_PALLET, "RingKeys"); + let mut pages = storage + .iter(ring_keys_address, (collection, ring_index)) + .await + .map_err(unknown)?; + let mut members_by_page = Vec::new(); + while let Some(entry) = pages.next().await { + let entry = entry.map_err(unknown)?; + let page_index = entry + .key() + .map_err(unknown)? + .part(2) + .ok_or_else(|| RingVrfError::Unknown { + reason: "Members.RingKeys returned a key without a page index".to_string(), + })? + .decode_as::() + .map_err(unknown)? + .ok_or_else(|| RingVrfError::Unknown { + reason: "Members.RingKeys page index is not recoverable".to_string(), + })?; + let members = entry.value().decode().map_err(unknown)?.0; + members_by_page.push((page_index, members)); + } + members_by_page.sort_unstable_by_key(|(page, _)| *page); + let mut members: Vec<_> = members_by_page + .into_iter() + .flat_map(|(_, members)| members) + .collect(); + let included_count = usize::try_from(included_count).map_err(unknown)?; + if members.len() < included_count { + return Err(RingVrfError::Unknown { + reason: format!( + "Members.RingKeys contains {} keys but RingKeysStatus includes {included_count}", + members.len() + ), + }); + } + members.truncate(included_count); + if !members.contains(&selected.member) { + return Err(RingVrfError::NotMember); + } + + let root_address = dynamic::storage::<([u8; 32], u32), RingRoot>(MEMBERS_PALLET, "Root"); + let Some(root) = storage + .try_fetch(root_address, (collection, ring_index)) + .await + .map_err(unknown)? + else { + return Err(RingVrfError::RingNotFound); + }; + let ring_revision = root.decode().map_err(unknown)?.revision; + + Ok(ResolvedRing { + selected, + ring_index, + ring_revision, + domain_size: collection_info.ring_size.domain_size(), + members, + }) + } +} + +pub(super) fn context_bytes(context: &ProductProofContext) -> [u8; 32] { + let mut input = Vec::with_capacity(9 + context.product_id.len() + context.suffix.len()); + input.extend_from_slice(b"product/"); + input.extend_from_slice(context.product_id.as_bytes()); + input.push(b'/'); + input.extend_from_slice(&context.suffix); + blake2b_256(&input, None) +} + +pub(super) fn person_entropy(root_entropy: &[u8], key: PersonKey) -> Zeroizing<[u8; 32]> { + let key = match key { + PersonKey::Full => Some(FULL_PERSON_ENTROPY_KEY), + PersonKey::Lite => None, + }; + Zeroizing::new(blake2b_256(root_entropy, key)) +} + +pub(super) fn member_from_entropy(entropy: &[u8; 32]) -> Result<[u8; 32], RingVrfError> { + use parity_scale_codec::Encode; + + let secret = BandersnatchVrfVerifiable::new_secret(*entropy); + BandersnatchVrfVerifiable::member_from_secret(&secret) + .encode() + .try_into() + .map_err(|member: Vec| RingVrfError::Unknown { + reason: format!( + "Bandersnatch member encoded to {} bytes instead of 32", + member.len() + ), + }) +} + +pub(super) fn alias_from_entropy( + entropy: &[u8; 32], + context: &[u8], +) -> Result<[u8; 32], RingVrfError> { + let secret = BandersnatchVrfVerifiable::new_secret(*entropy); + BandersnatchVrfVerifiable::alias_in_context(&secret, context).map_err(unknown) +} + +pub(super) fn create_proof( + entropy: &[u8; 32], + resolved: &ResolvedRing, + context: &[u8], + message: &[u8], +) -> Result<(Vec, [u8; 32]), RingVrfError> { + use parity_scale_codec::Decode; + + let mut selected_bytes = &resolved.selected.member[..]; + let selected = RingMember::decode(&mut selected_bytes).map_err(unknown)?; + let members = resolved + .members + .iter() + .map(|member| { + let mut bytes = &member[..]; + RingMember::decode(&mut bytes).map_err(unknown) + }) + .collect::, _>>()?; + + let secret = BandersnatchVrfVerifiable::new_secret(*entropy); + let prover = + BandersnatchVrfVerifiable::open(resolved.domain_size, &selected, members.into_iter()) + .map_err(|_| RingVrfError::NotMember)?; + let (proof, alias) = + BandersnatchVrfVerifiable::create(prover, &secret, context, message).map_err(unknown)?; + Ok((proof.to_vec(), alias)) +} + +pub(super) fn key_for_collection(collection: &[u8; 32]) -> PersonKey { + if collection == &LITE_PERSON_COLLECTION { + PersonKey::Lite + } else { + PersonKey::Full + } +} + +fn collection_id(location: &RingLocation) -> Result<[u8; 32], RingVrfError> { + location + .junctions + .iter() + .find_map(|junction| match junction { + RingLocationJunction::CollectionId(value) => Some(value), + RingLocationJunction::PalletInstance(_) => None, + }) + .map_or(Ok(FULL_PERSON_COLLECTION), |value| { + value + .as_slice() + .try_into() + .map_err(|_| RingVrfError::RingNotFound) + }) +} + +fn pallet_instance(location: &RingLocation) -> Option { + location + .junctions + .iter() + .find_map(|junction| match junction { + RingLocationJunction::PalletInstance(index) => Some(*index), + RingLocationJunction::CollectionId(_) => None, + }) +} + +fn blake2b_256(input: &[u8], key: Option<&[u8]>) -> [u8; 32] { + let mut params = blake2b_simd::Params::new(); + params.hash_length(32); + if let Some(key) = key { + params.key(key); + } + let hash = params.hash(input); + let mut output = [0u8; 32]; + output.copy_from_slice(hash.as_bytes()); + output +} + +fn unknown(error: impl std::fmt::Debug) -> RingVrfError { + RingVrfError::Unknown { + reason: format!("{error:?}"), + } +} + +#[derive(Debug, PartialEq, Eq, DecodeAsType)] +enum RingPosition { + Onboarding {}, + Included { ring_index: u32, ring_position: u32 }, + Suspended, +} + +#[derive(Debug, PartialEq, Eq, DecodeAsType)] +struct RingStatus { + included: u32, +} + +#[derive(Debug, PartialEq, Eq, DecodeAsType)] +struct CollectionInfo { + ring_size: RingExponent, +} + +#[derive(Debug, PartialEq, Eq, DecodeAsType)] +enum RingExponent { + R2e9, + R2e10, + R2e14, +} + +impl RingExponent { + fn domain_size(self) -> RingDomainSize { + match self { + Self::R2e9 => RingDomainSize::Domain11, + Self::R2e10 => RingDomainSize::Domain12, + Self::R2e14 => RingDomainSize::Domain16, + } + } +} + +#[derive(Debug, PartialEq, Eq, DecodeAsType)] +struct BoundedMembers(Vec<[u8; 32]>); + +#[derive(Debug, PartialEq, Eq, DecodeAsType)] +struct RingRoot { + revision: u32, +} + +#[cfg(test)] +mod tests { + use parity_scale_codec::Encode; + use scale_info::TypeInfo; + + use super::*; + + fn decode_as(source: A) -> B + where + A: Encode + TypeInfo + 'static, + B: DecodeAsType, + { + let mut registry = scale_info::Registry::new(); + let type_id = registry.register_type(&scale_info::meta_type::()).id; + let types: scale_info::PortableRegistry = registry.into(); + B::decode_as_type(&mut &*source.encode(), type_id, &types).expect("dynamic decode succeeds") + } + + #[test] + fn context_matches_rfc_0004_vector() { + let context = ProductProofContext { + product_id: "example.dot".to_string(), + suffix: b"login".to_vec(), + }; + assert_eq!( + hex::encode(context_bytes(&context)), + "be397823154bdcc0f4d86938af932cd4d5c49d0793e0138663ccdb3d8e0062eb" + ); + } + + #[test] + fn collection_selects_corresponding_person_key() { + assert_eq!(key_for_collection(&FULL_PERSON_COLLECTION), PersonKey::Full); + assert_eq!(key_for_collection(&LITE_PERSON_COLLECTION), PersonKey::Lite); + assert_eq!(key_for_collection(&[0xff; 32]), PersonKey::Full); + } + + #[test] + fn missing_collection_defaults_to_full_personhood() { + let location = RingLocation { + chain_id: [0; 32], + junctions: vec![RingLocationJunction::PalletInstance(42)], + }; + assert_eq!(collection_id(&location), Ok(FULL_PERSON_COLLECTION)); + } + + #[test] + fn storage_projections_decode_runtime_shapes() { + #[derive(Encode, TypeInfo)] + enum SourceRingPosition { + Onboarding { + queue_page: u32, + queued_at: u64, + }, + Included { + ring_index: u32, + ring_page: u32, + ring_position: u32, + }, + Suspended, + } + #[derive(Encode, TypeInfo)] + enum SourceRingExponent { + R2e9, + R2e10, + R2e14, + } + #[derive(Encode, TypeInfo)] + struct SourceCollectionInfo { + owner: u8, + mode: u8, + ring_size: SourceRingExponent, + self_inclusion_delay: Option, + } + #[derive(Encode, TypeInfo)] + struct SourceBoundedMembers(Vec<[u8; 32]>); + #[derive(Encode, TypeInfo)] + struct SourceRingRoot { + root: [u8; 4], + revision: u32, + intermediate: [u8; 8], + } + + assert_eq!( + decode_as::<_, RingPosition>(SourceRingPosition::Included { + ring_index: 7, + ring_page: 3, + ring_position: 19, + }), + RingPosition::Included { + ring_index: 7, + ring_position: 19, + } + ); + assert_eq!( + decode_as::<_, CollectionInfo>(SourceCollectionInfo { + owner: 1, + mode: 0, + ring_size: SourceRingExponent::R2e10, + self_inclusion_delay: Some(3_600), + }), + CollectionInfo { + ring_size: RingExponent::R2e10, + } + ); + assert_eq!( + decode_as::<_, BoundedMembers>(SourceBoundedMembers(vec![[0x11; 32], [0x22; 32]])), + BoundedMembers(vec![[0x11; 32], [0x22; 32]]) + ); + assert_eq!( + decode_as::<_, RingRoot>(SourceRingRoot { + root: [0x33; 4], + revision: 12, + intermediate: [0x44; 8], + }), + RingRoot { revision: 12 } + ); + + // Keep every runtime variant in the type registry so this test also + // checks their names remain aligned with the partial decoder. + let _ = SourceRingPosition::Onboarding { + queue_page: 0, + queued_at: 0, + }; + let _ = SourceRingPosition::Suspended; + let _ = SourceRingExponent::R2e9; + let _ = SourceRingExponent::R2e14; + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index b55d34d4..a8adf8a9 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -420,7 +420,7 @@ mod tests { fn signing_host_runtime(product_id: &str) -> (ProductRuntimeHost, Arc) { let platform: Arc = Arc::new(StubPlatform::default()); let services = RuntimeServices::new(platform.clone(), [0; 32], [0xbb; 32], test_spawner()); - let signing_host = SigningHostRole::new(platform); + let signing_host = SigningHostRole::new(services.clone()); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); let host = ProductRuntimeHost::from_services( From 01513f9a100410e896ba6248cee49697daa1beaa Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 23 Jul 2026 11:30:39 +0200 Subject: [PATCH 02/34] update --- Cargo.lock | 3 + rust/crates/truapi-platform/Cargo.toml | 1 + rust/crates/truapi-server/Cargo.toml | 4 +- .../crates/truapi-server/src/chain_runtime.rs | 142 +- rust/crates/truapi-server/src/host_core.rs | 39 +- rust/crates/truapi-server/src/host_logic.rs | 2 + .../truapi-server/src/host_logic/alias.rs | 81 + .../src/host_logic/attestation.rs | 167 ++ .../truapi-server/src/host_logic/entropy.rs | 10 +- .../src/host_logic/product_account.rs | 17 + .../src/host_logic/sso/messages.rs | 480 +++++- .../src/host_logic/sso/messages/v1.rs | 5 +- .../src/host_logic/sso/pairing.rs | 284 +++- .../src/host_logic/statement_store.rs | 2 +- .../host_logic/statement_store/statement.rs | 146 ++ .../src/host_logic/transaction.rs | 217 +++ rust/crates/truapi-server/src/lib.rs | 3 + rust/crates/truapi-server/src/runtime.rs | 138 +- .../truapi-server/src/runtime/bulletin_rpc.rs | 12 + .../truapi-server/src/runtime/identity.rs | 77 +- .../src/runtime/pairing_host/sso_channel.rs | 2 +- .../truapi-server/src/runtime/signing_host.rs | 416 ++++- .../runtime/signing_host/local_activation.rs | 19 +- .../src/runtime/signing_host/ring_vrf.rs | 12 + .../src/runtime/signing_host/sso_responder.rs | 1351 +++++++++++++++++ .../src/runtime/statement_allowance.rs | 466 ++++++ .../runtime/statement_allowance/dynamic.rs | 164 ++ .../runtime/statement_allowance/extension.rs | 419 +++++ .../runtime/statement_allowance/extrinsic.rs | 204 +++ .../src/runtime/statement_allowance/proof.rs | 111 ++ .../src/runtime/statement_allowance/ring.rs | 201 +++ .../src/runtime/statement_allowance/rpc.rs | 153 ++ .../src/runtime/statement_allowance/slot.rs | 247 +++ .../src/runtime/statement_store.rs | 4 +- .../src/runtime/statement_store_rpc.rs | 79 +- rust/crates/truapi-server/src/test_support.rs | 4 +- .../fixtures/paseo-next-v2-metadata.scale | Bin 0 -> 388952 bytes 37 files changed, 5457 insertions(+), 225 deletions(-) create mode 100644 rust/crates/truapi-server/src/host_logic/alias.rs create mode 100644 rust/crates/truapi-server/src/host_logic/attestation.rs create mode 100644 rust/crates/truapi-server/src/host_logic/transaction.rs create mode 100644 rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs create mode 100644 rust/crates/truapi-server/src/runtime/statement_allowance.rs create mode 100644 rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs create mode 100644 rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs create mode 100644 rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs create mode 100644 rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs create mode 100644 rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs create mode 100644 rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs create mode 100644 rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs create mode 100644 rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata.scale diff --git a/Cargo.lock b/Cargo.lock index 82943a36..68649b21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3668,6 +3668,7 @@ dependencies = [ "serde_json", "subxt-lightclient", "thiserror 2.0.18", + "tokio-util", "tracing", "url", "wasm-bindgen-futures", @@ -4001,6 +4002,7 @@ dependencies = [ "truapi", "unicode-normalization", "url", + "zeroize", ] [[package]] @@ -4012,6 +4014,7 @@ dependencies = [ "blake2b_simd", "console_error_panic_hook", "derive_more 2.1.1", + "frame-metadata", "futures", "futures-timer", "getrandom 0.2.17", diff --git a/rust/crates/truapi-platform/Cargo.toml b/rust/crates/truapi-platform/Cargo.toml index cfbd0fc4..a61ea4c4 100644 --- a/rust/crates/truapi-platform/Cargo.toml +++ b/rust/crates/truapi-platform/Cargo.toml @@ -13,6 +13,7 @@ futures = "0.3" parity-scale-codec = { version = "3", features = ["derive"] } unicode-normalization = "0.1" url = "2" +zeroize = { version = "1", default-features = false, features = ["derive"] } [lints.rust] unsafe_code = "forbid" diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 1d23a276..63cffb69 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -65,7 +65,9 @@ verifiable = { git = "https://github.com/paritytech/verifiable.git", rev = "19b0 [target.'cfg(not(target_arch = "wasm32"))'.dependencies] subxt = { version = "0.50.2", default-features = false, features = ["native"] } -subxt-rpcs = { version = "0.50.1", default-features = false, features = ["native"] } +subxt-rpcs = { version = "0.50.1", default-features = false, features = ["jsonrpsee", "native"] } +frame-metadata = { version = "23", default-features = false, features = ["std", "current", "decode"] } +scale-info = { version = "2.11", default-features = false, features = ["decode"] } [target.'cfg(target_arch = "wasm32")'.dependencies] futures-timer = { version = "3", features = ["wasm-bindgen"] } diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 73028111..4458bb67 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -229,6 +229,14 @@ pub struct ChainRuntime { spawner: Spawner, connections: Arc>>>, connection_setups: Arc>>, + transaction_operations: Arc>>, + next_transaction_operation_id: Arc, +} + +#[derive(Clone)] +struct TransactionOperation { + genesis_hash: Vec, + provider_operation_id: String, } impl ChainRuntime { @@ -240,6 +248,8 @@ impl ChainRuntime { spawner, connections: Arc::new(Mutex::new(HashMap::new())), connection_setups: Arc::new(Mutex::new(HashMap::new())), + transaction_operations: Arc::new(Mutex::new(HashMap::new())), + next_transaction_operation_id: Arc::new(AtomicU64::new(1)), } } @@ -503,11 +513,29 @@ impl ChainRuntime { ) -> Result { let method = "remote_chain_transaction_broadcast"; let connection = self.connection_for(method, &request.genesis_hash).await?; - let operation_id = connection + let provider_operation_id = connection .methods .transaction_v1_broadcast(&request.transaction) .await .map_err(|err| rpc_failure(method, err))?; + let operation_id = provider_operation_id.map(|provider_operation_id| { + let operation_id = format!( + "truapi-tx-{}", + self.next_transaction_operation_id + .fetch_add(1, Ordering::Relaxed) + ); + self.transaction_operations + .lock() + .expect("transaction operation mutex poisoned") + .insert( + operation_id.clone(), + TransactionOperation { + genesis_hash: request.genesis_hash, + provider_operation_id, + }, + ); + operation_id + }); Ok(RemoteChainTransactionBroadcastResponse { operation_id }) } @@ -518,12 +546,49 @@ impl ChainRuntime { request: RemoteChainTransactionStopRequest, ) -> Result<(), RuntimeFailure> { let method = "remote_chain_transaction_stop"; - let connection = self.connection_for(method, &request.genesis_hash).await?; - connection + let operation = self + .transaction_operations + .lock() + .expect("transaction operation mutex poisoned") + .remove(&request.operation_id) + .ok_or_else(|| { + RuntimeFailure::host_failure(method, "unknown transaction operation id") + })?; + if operation.genesis_hash != request.genesis_hash { + self.transaction_operations + .lock() + .expect("transaction operation mutex poisoned") + .insert(request.operation_id, operation); + return Err(RuntimeFailure::host_failure( + method, + "transaction operation belongs to a different chain", + )); + } + let connection = match self.connection_for(method, &request.genesis_hash).await { + Ok(connection) => connection, + Err(err) => { + self.transaction_operations + .lock() + .expect("transaction operation mutex poisoned") + .insert(request.operation_id, operation); + return Err(err); + } + }; + match connection .methods - .transaction_v1_stop(&request.operation_id) + .transaction_v1_stop(&operation.provider_operation_id) .await - .map_err(|err| rpc_failure(method, err)) + { + Ok(()) => Ok(()), + Err(err) if transaction_operation_already_finished(&err) => Ok(()), + Err(err) => { + self.transaction_operations + .lock() + .expect("transaction operation mutex poisoned") + .insert(request.operation_id, operation); + Err(rpc_failure(method, err)) + } + } } /// Genesis-pinned Subxt client for the chain identified by `genesis_hash`. @@ -537,6 +602,20 @@ impl ChainRuntime { Ok(self.subxt_connection(genesis_hash).await?.client) } + /// Raw JSON-RPC client for the chain identified by `genesis_hash`. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn rpc_client( + &self, + method: &'static str, + genesis_hash: &[u8], + ) -> Result { + Ok(self + .connection_for(method, genesis_hash) + .await? + .rpc_client + .clone()) + } + async fn subxt_connection( &self, genesis_hash: &[u8], @@ -1270,6 +1349,11 @@ fn hash_to_bytes(hash: H256) -> Vec { hash.as_bytes().to_vec() } +fn transaction_operation_already_finished(error: &SubxtRpcError) -> bool { + let reason = error.to_string(); + reason.contains("Invalid operation id") && reason.contains("-32602") +} + fn rpc_failure(method: &'static str, error: SubxtRpcError) -> RuntimeFailure { match error { SubxtRpcError::Client(_) | SubxtRpcError::DisconnectedWillReconnect(_) => { @@ -1722,6 +1806,54 @@ mod tests { assert!(sent[1].contains("chainHead_v1_header")); } + #[test] + fn transaction_stop_uses_host_handle_and_accepts_finished_provider_operation() { + let provider = Arc::new(ScriptedProvider::new(|request| { + let id = extract_id(request).unwrap(); + if request.contains("transaction_v1_broadcast") { + Some(format!( + r#"{{"jsonrpc":"2.0","id":"{id}","result":"REMOTE-OP"}}"# + )) + } else if request.contains("transaction_v1_stop") { + Some(format!( + r#"{{"jsonrpc":"2.0","id":"{id}","error":{{"code":-32602,"message":"Invalid operation id"}}}}"# + )) + } else { + None + } + })); + let runtime = ChainRuntime::new(provider.clone(), spawner_for_tests()); + let genesis_hash = vec![0x11; 32]; + + let broadcast = futures::executor::block_on(runtime.remote_chain_transaction_broadcast( + RemoteChainTransactionBroadcastRequest { + genesis_hash: genesis_hash.clone(), + transaction: vec![0], + }, + )) + .expect("broadcast succeeds"); + let local_id = broadcast.operation_id.expect("operation id"); + assert!(local_id.starts_with("truapi-tx-")); + assert_ne!(local_id, "REMOTE-OP"); + + futures::executor::block_on(runtime.remote_chain_transaction_stop( + RemoteChainTransactionStopRequest { + genesis_hash, + operation_id: local_id, + }, + )) + .expect("an already-finished provider operation is stopped idempotently"); + + let sent = provider.sent.lock().unwrap().clone(); + let stop: Value = serde_json::from_str( + sent.iter() + .find(|request| request.contains("transaction_v1_stop")) + .expect("stop request"), + ) + .unwrap(); + assert_eq!(stop["params"][0].as_str(), Some("REMOTE-OP")); + } + #[test] fn unpin_uses_typed_subxt_method_for_each_hash() { let provider = Arc::new(ScriptedProvider::new(|request| { diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 69da527f..3d34372c 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -26,8 +26,8 @@ use truapi_platform::{ use crate::core::TrUApiCore; use crate::frame::ProtocolMessage; use crate::runtime::{ - LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, RuntimeServices, - SigningHostRole, + LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, ResponderExit, + RuntimeServices, SigningHostRole, respond_to_pairing, }; use crate::subscription::Spawner; use crate::transport::Transport; @@ -185,10 +185,9 @@ impl PairingHostAdmin for PairingHostRuntime { /// Owns the shared services plus signing-host state. There is no pairing flow, /// so pairing cancellation is not present here. /// -/// Raw-bytes signing, transaction construction, product entropy, and RFC-0004 -/// ring-VRF aliases/proofs are implemented; extrinsic-payload signing and -/// resource allocation return an `Unavailable` error pending chain-metadata -/// and on-chain support. +/// Raw-bytes and extrinsic-payload signing, v4 transaction construction, and +/// product entropy are implemented; native signing hosts can also serve +/// ring-VRF aliases and on-chain resource allocation. pub struct SigningHostRuntime { services: Arc, signing_host: Arc, @@ -253,6 +252,34 @@ impl SigningHostRuntime { reason: err.reason(), }) } + + /// Activate a wallet-local session from host-held secret material and + /// attach known identity metadata. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.activate_local_session_with_identity"))] + pub async fn activate_local_session_with_identity( + &self, + secret: Vec, + lite_username: Option, + ) -> Result<(), v01::GenericError> { + self.signing_host + .activate_local_session_with_identity(secret, lite_username) + .await + .map_err(|err| v01::GenericError { + reason: err.reason(), + }) + } + + /// Answer a pairing host's handshake deeplink and serve the resulting SSO + /// session until it ends. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.respond_to_pairing"))] + pub async fn respond_to_pairing( + &self, + deeplink: &str, + ) -> Result { + respond_to_pairing(self.services.clone(), self.signing_host.clone(), deeplink) + .await + .map_err(|reason| v01::GenericError { reason }) + } } /// Product-scoped administration handle for host UI. diff --git a/rust/crates/truapi-server/src/host_logic.rs b/rust/crates/truapi-server/src/host_logic.rs index 12a1c516..e687fafa 100644 --- a/rust/crates/truapi-server/src/host_logic.rs +++ b/rust/crates/truapi-server/src/host_logic.rs @@ -4,6 +4,7 @@ //! storage, URL handler, notification center). Everything else lives here so //! iOS, Android, and web hosts share one canonical implementation. +pub mod attestation; pub mod bulletin; pub mod dotns; pub mod entropy; @@ -16,3 +17,4 @@ pub mod session; pub mod session_store; pub mod sso; pub mod statement_store; +pub mod transaction; diff --git a/rust/crates/truapi-server/src/host_logic/alias.rs b/rust/crates/truapi-server/src/host_logic/alias.rs new file mode 100644 index 00000000..3b6874e1 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/alias.rs @@ -0,0 +1,81 @@ +//! Bandersnatch ring-VRF product-account aliases (signing host). +//! +//! Mirrors the mobile app's `ProductAccountHolder.deriveAlias`: the alias is a +//! thin bandersnatch VRF output over a per-product context, using a +//! bandersnatch secret derived from the wallet's BIP-39 entropy. No ring +//! commitment or SRS is involved (that machinery is only for membership +//! proofs, which this path does not use). +//! +//! Reference: polkadot-app-ios-v2 `Packages/Products/.../ProductAccountHolder.swift` +//! and `verifiable-swift` over `paritytech/verifiable`. + +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +/// A product-account contextual alias. +pub struct ProductAlias { + /// 32-byte context identifier (blake2b-256 of the derivation path). + pub context: [u8; 32], + /// 32-byte ring-VRF alias output. + pub alias: [u8; 32], +} + +/// Derive the contextual alias for a product account from the wallet entropy. +/// +/// - `context = blake2b_256("/product/{product_id}/{derivation_index}")` +/// - `bandersnatch_entropy = blake2b_256(root_entropy)` +/// - `alias = BandersnatchVrf::alias_in_context(new_secret(bandersnatch_entropy), context)` +pub fn derive_product_alias( + root_entropy: &[u8], + product_id: &str, + derivation_index: u32, +) -> Result { + let derivation_path = format!("/product/{product_id}/{derivation_index}"); + let context = blake2b256(derivation_path.as_bytes()); + let bandersnatch_entropy = blake2b256(root_entropy); + let secret = BandersnatchVrfVerifiable::new_secret(bandersnatch_entropy); + let alias = BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("ring-VRF alias derivation failed: {err:?}"))?; + Ok(ProductAlias { context, alias }) +} + +fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .hash(message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The alias is deterministic in the entropy, product id, and index, and + /// the context is the blake2b-256 of the derivation path. + #[test] + fn alias_is_deterministic_with_expected_context() { + let entropy = [0xABu8; 16]; + let first = derive_product_alias(&entropy, "truapi-playground.dot", 0).unwrap(); + let again = derive_product_alias(&entropy, "truapi-playground.dot", 0).unwrap(); + + assert_eq!(first.context, again.context); + assert_eq!(first.alias, again.alias); + assert_eq!( + first.context, + blake2b256(b"/product/truapi-playground.dot/0") + ); + } + + #[test] + fn alias_varies_by_product_and_index() { + let entropy = [0xABu8; 16]; + let base = derive_product_alias(&entropy, "a.dot", 0).unwrap(); + let other_product = derive_product_alias(&entropy, "b.dot", 0).unwrap(); + let other_index = derive_product_alias(&entropy, "a.dot", 1).unwrap(); + + assert_ne!(base.alias, other_product.alias); + assert_ne!(base.alias, other_index.alias); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/attestation.rs b/rust/crates/truapi-server/src/host_logic/attestation.rs new file mode 100644 index 00000000..4befc7fe --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/attestation.rs @@ -0,0 +1,167 @@ +//! Lite-person username registration parameters (signing host, native only). +//! +//! Builds the client-side proofs the People-chain identity backend needs to +//! attest a lite username for an account: an sr25519 proof-of-ownership, a +//! bandersnatch ring-VRF member key + plain-VRF proof, and an sr25519 +//! consumer-registration signature. The backend submits the on-chain +//! `register_lite_person` extrinsic; the host never signs a chain extrinsic. +//! +//! Byte layout mirrors signing-bot `src/core/attestation.ts` for backend +//! parity. The registered account is the account whose secret signs here; the +//! paired host resolves the username from `Resources.Consumers[that account]`. + +use parity_scale_codec::Encode; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use crate::host_logic::product_account::{ + SR25519_SIGNING_CONTEXT, derive_sr25519_hard_path, product_public_key_to_address, +}; +use crate::host_logic::sso::pairing::derive_p256_keypair_from_entropy; + +/// sr25519 proof-of-ownership message prefix (exact bytes; one space). +const REGISTER_PREFIX: &[u8] = b"pop:people-lite:register using"; +/// Domain label for the P-256 identifier key advertised to the backend. +const IDENTIFIER_KEY_LABEL: &[u8] = b"chat-encryption"; + +/// Client-computed parameters for `POST /usernames`. +pub struct LiteRegistration { + /// SS58 (prefix 42) of the candidate account. + pub candidate_account_id: String, + /// Raw 32-byte candidate public key (the future `Resources.Consumers` key). + pub candidate_public_key: [u8; 32], + /// sr25519 signature over `prefix ‖ candidate_pub ‖ ring_vrf_key`. + pub candidate_signature: [u8; 64], + /// Bandersnatch ring-VRF member key. + pub ring_vrf_key: [u8; 32], + /// Plain bandersnatch VRF proof over the same proof message. + pub proof_of_ownership: [u8; 64], + /// 65-byte uncompressed P-256 identifier key. + pub identifier_key: [u8; 65], + /// sr25519 signature over the SCALE consumer-registration tuple. + pub consumer_registration_signature: [u8; 64], +} + +/// Build the lite-person registration parameters for `username_base` +/// (6+ lowercase letters, no digit suffix) against the backend `verifier`. +pub fn build_lite_registration( + entropy: &[u8], + verifier_account_id: [u8; 32], + username_base: &str, +) -> Result { + // The candidate is the `//wallet//sso` statement account, matching the + // account the SSO responder presents as `identity_account_id`. + let candidate = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) + .map_err(|err| format!("//wallet//sso derivation failed: {err}"))?; + let candidate_public_key = candidate.public.to_bytes(); + + let vrf_entropy = blake2b256(entropy); + let vrf_secret = BandersnatchVrfVerifiable::new_secret(vrf_entropy); + let ring_vrf_key = BandersnatchVrfVerifiable::member_from_secret(&vrf_secret); + + let mut proof_message = Vec::with_capacity(REGISTER_PREFIX.len() + 64); + proof_message.extend_from_slice(REGISTER_PREFIX); + proof_message.extend_from_slice(&candidate_public_key); + proof_message.extend_from_slice(&ring_vrf_key); + + let candidate_signature = candidate + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &proof_message, &candidate.public) + .to_bytes(); + let proof_of_ownership = BandersnatchVrfVerifiable::sign(&vrf_secret, &proof_message) + .map_err(|err| format!("ring-VRF proof-of-ownership failed: {err:?}"))?; + + let (_identifier_secret, identifier_key) = + derive_p256_keypair_from_entropy(entropy, IDENTIFIER_KEY_LABEL) + .map_err(|err| format!("identifier key derivation failed: {err}"))?; + + // SCALE Tuple(Bytes(32), Bytes(32), Bytes(65), str, Option(Bytes())=None). + let mut consumer_message = Vec::new(); + consumer_message.extend_from_slice(&candidate_public_key); + consumer_message.extend_from_slice(&verifier_account_id); + consumer_message.extend_from_slice(&identifier_key); + consumer_message.extend_from_slice(&username_base.encode()); + consumer_message.push(0u8); + let consumer_registration_signature = candidate + .secret + .sign_simple( + SR25519_SIGNING_CONTEXT, + &consumer_message, + &candidate.public, + ) + .to_bytes(); + + Ok(LiteRegistration { + candidate_account_id: product_public_key_to_address(candidate_public_key), + candidate_public_key, + candidate_signature, + ring_vrf_key, + proof_of_ownership, + identifier_key, + consumer_registration_signature, + }) +} + +fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .hash(message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + use schnorrkel::{PublicKey, Signature}; + + const ENTROPY: [u8; 16] = [0xAB; 16]; + + #[test] + fn registration_params_have_expected_shapes_and_verify() { + let verifier = [0x11u8; 32]; + let reg = build_lite_registration(&ENTROPY, verifier, "headlesstester").unwrap(); + + assert_eq!(reg.identifier_key[0], 0x04, "P-256 uncompressed prefix"); + assert!( + reg.candidate_account_id + .chars() + .all(|c| c.is_alphanumeric()) + ); + + // candidateSignature verifies over prefix ‖ candidate_pub ‖ ring_vrf_key. + let mut proof_message = Vec::new(); + proof_message.extend_from_slice(REGISTER_PREFIX); + proof_message.extend_from_slice(®.candidate_public_key); + proof_message.extend_from_slice(®.ring_vrf_key); + let public = PublicKey::from_bytes(®.candidate_public_key).unwrap(); + let sig = Signature::from_bytes(®.candidate_signature).unwrap(); + assert!( + public + .verify_simple(SR25519_SIGNING_CONTEXT, &proof_message, &sig) + .is_ok(), + "candidate signature verifies" + ); + + // proofOfOwnership verifies as a plain VRF signature for the member key. + assert!( + BandersnatchVrfVerifiable::verify_signature( + ®.proof_of_ownership, + &proof_message, + ®.ring_vrf_key + ), + "ring-VRF proof-of-ownership validates against the member key" + ); + } + + #[test] + fn registration_is_deterministic_per_entropy_and_username() { + let verifier = [0x22u8; 32]; + let first = build_lite_registration(&ENTROPY, verifier, "aliceheadless").unwrap(); + let again = build_lite_registration(&ENTROPY, verifier, "aliceheadless").unwrap(); + assert_eq!(first.candidate_public_key, again.candidate_public_key); + assert_eq!(first.ring_vrf_key, again.ring_vrf_key); + assert_eq!(first.candidate_account_id, again.candidate_account_id); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/entropy.rs b/rust/crates/truapi-server/src/host_logic/entropy.rs index 652a880c..47176f2f 100644 --- a/rust/crates/truapi-server/src/host_logic/entropy.rs +++ b/rust/crates/truapi-server/src/host_logic/entropy.rs @@ -23,8 +23,14 @@ pub fn derive_product_entropy( product_id: &str, key: &[u8], ) -> Result<[u8; 32], ProductEntropyError> { - let root_entropy_source = blake2b256_keyed(entropy_secret, DOMAIN_SEPARATOR); - derive_product_entropy_from_source(&root_entropy_source, product_id, key) + derive_product_entropy_from_source(&root_entropy_source(entropy_secret), product_id, key) +} + +/// Pre-hashed root entropy source (RFC-0007 layer 1). Signing hosts share this +/// value with paired hosts during the SSO handshake so both sides derive the +/// same product entropy. +pub fn root_entropy_source(entropy_secret: &[u8]) -> [u8; 32] { + blake2b256_keyed(entropy_secret, DOMAIN_SEPARATOR) } /// Derive product-scoped entropy when the session already stores the diff --git a/rust/crates/truapi-server/src/host_logic/product_account.rs b/rust/crates/truapi-server/src/host_logic/product_account.rs index 2a660db7..f040841d 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -97,6 +97,23 @@ pub fn public_key_from_address(address: &str) -> Option<[u8; 32]> { Some(subxt::utils::AccountId32::from_str(address).ok()?.0) } +/// Derive an sr25519 keypair down a path of hard string junctions from the +/// canonical BIP-39 root key. +pub fn derive_sr25519_hard_path( + entropy: &[u8], + junctions: &[&str], +) -> Result { + let mut keypair = derive_root_keypair_from_entropy(entropy)?; + for junction in junctions { + let chain_code = ChainCode(create_chain_code(junction)?); + let (mini_secret, _) = keypair + .secret + .hard_derive_mini_secret_key(Some(chain_code), b""); + keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + } + Ok(keypair) +} + /// Create a Substrate soft-derivation chain code for one junction. fn create_chain_code(code: &str) -> Result<[u8; 32], ProductAccountError> { let encoded = if !code.is_empty() && code.bytes().all(|byte| byte.is_ascii_digit()) { diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index b46d4fe8..26626610 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -13,27 +13,30 @@ //! //! Deployed extension variants are tracked as a host-spec divergence: //! -//! Field order and enum variant order are kept wire-compatible with host-papp: -//! -//! -//! -//! +//! Field order and enum variant order are kept wire-compatible with +//! `@novasamatech/host-papp` 0.8.11: +//! +//! +//! +//! +//! use parity_scale_codec::{Decode, Encode, OptionBool}; use truapi::latest::{ AccountId, AllocatableResource, HostAccountCreateProofResponse, HostAccountGetAliasResponse, - HostSignPayloadRequest, HostSignRawRequest, LegacyAccountTxPayload, ProductAccountId, - ProductAccountTxPayload, ProductProofContext, RawPayload, RingLocation, + LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, ProductProofContext, + RawPayload, RingLocation, }; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::pairing::{ AES_GCM_NONCE_LEN, SsoStatementData, decrypt_session_statement_data, encrypt_session_statement_data, encrypt_session_statement_data_with_nonce, + peer_response_channel, }; use crate::host_logic::statement_store::{ - build_signed_session_request_statement, current_unix_secs, decode_verified_statement_data, - statement_expiry_elapsed, + build_signed_session_request_statement, build_signed_statement, current_unix_secs, + decode_verified_statement_data, statement_expiry_elapsed, }; pub mod v1; @@ -89,7 +92,7 @@ pub enum SigningRequest { /// Request sent when a product asks the paired signing host to sign a Substrate /// payload with a product-derived account. /// -/// Built from [`HostSignPayloadRequest`] but kept as a dedicated wire type +/// Built from [`truapi::v01::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)] @@ -113,7 +116,7 @@ pub struct SigningPayloadRequest { } impl SigningPayloadRequest { - fn from_host_request(value: HostSignPayloadRequest) -> Self { + fn from_host_request(value: truapi::v01::HostSignPayloadRequest) -> Self { let payload = value.payload; Self { product_account_id: value.account, @@ -136,10 +139,35 @@ impl SigningPayloadRequest { } } +impl From for truapi::v01::HostSignPayloadRequest { + fn from(value: SigningPayloadRequest) -> Self { + Self { + account: value.product_account_id, + payload: truapi::v01::HostSignPayloadData { + block_hash: value.block_hash, + block_number: value.block_number, + era: value.era, + genesis_hash: value.genesis_hash, + method: value.method, + nonce: value.nonce, + spec_version: value.spec_version, + tip: value.tip, + transaction_version: value.transaction_version, + signed_extensions: value.signed_extensions, + version: value.version, + asset_id: value.asset_id, + metadata_hash: value.metadata_hash, + mode: value.mode, + with_signed_transaction: value.with_signed_transaction.0, + }, + } + } +} + /// Request sent when a product asks the paired signing host to sign raw bytes or a /// string message with a product-derived account. /// -/// Built from [`HostSignRawRequest`] and wrapped in +/// Built from [`truapi::v01::HostSignRawRequest`] and wrapped in /// [`v1::RemoteMessage::SignRequest`] before being encrypted into an SSO session /// statement. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -149,7 +177,7 @@ pub struct SigningRawRequest { } impl SigningRawRequest { - fn from_host_request(value: HostSignRawRequest) -> Self { + fn from_host_request(value: truapi::v01::HostSignRawRequest) -> Self { Self { product_account_id: value.account, data: value.payload.into(), @@ -188,6 +216,24 @@ impl From for SigningRawPayload { } } +impl From for RawPayload { + fn from(value: SigningRawPayload) -> Self { + match value { + SigningRawPayload::Bytes(bytes) => Self::Bytes { bytes }, + SigningRawPayload::Payload(payload) => Self::Payload { payload }, + } + } +} + +impl From for truapi::v01::HostSignRawRequest { + fn from(value: SigningRawRequest) -> Self { + Self { + account: value.product_account_id, + payload: value.data.into(), + } + } +} + /// Response returned by the signing host for a product-account signing request. /// /// Decoded from [`v1::RemoteMessage::SignResponse`] while the runtime is waiting @@ -215,6 +261,24 @@ pub struct SignRawLegacyResponse { pub signature: Result, String>, } +/// Request exact statement-store proof signing with a product-derived account. +/// +/// Raw signing cannot be reused because it applies the public +/// `...` payload convention, while statement proofs sign the +/// unsigned statement payload bytes directly. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct StatementStoreProductSignRequest { + pub product_account_id: ProductAccountId, + pub payload: Vec, +} + +/// Response returned for exact statement-store proof signing. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct StatementStoreProductSignResponse { + pub responding_to: String, + pub signature: Result, String>, +} + /// Failure returned by the Account Holder for a ring-VRF proof or alias request. /// /// Mirrors the identical error sets of `Account::create_account_proof` and @@ -391,6 +455,7 @@ pub enum SsoSessionStatement { pub enum SsoRemoteResponse { Sign(SigningResponse), SignRawLegacy(SignRawLegacyResponse), + StatementStoreProductSign(StatementStoreProductSignResponse), RingVrfAlias(RingVrfAliasResponse), RingVrfProof(RingVrfProofResponse), ResourceAllocation(ResourceAllocationResponse), @@ -496,6 +561,11 @@ fn remote_response_for_message( { Some(SsoRemoteResponse::SignRawLegacy(response)) } + v1::RemoteMessage::StatementStoreProductSignResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::StatementStoreProductSign(response)) + } v1::RemoteMessage::ResourceAllocationResponse(response) if response.responding_to == expected_remote_message_id => { @@ -510,8 +580,28 @@ fn remote_response_for_message( } } +/// Build an exact statement-store proof signing request. +pub fn statement_store_product_sign_message( + message_id: String, + product_account_id: ProductAccountId, + payload: Vec, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(v1::RemoteMessage::StatementStoreProductSignRequest( + StatementStoreProductSignRequest { + product_account_id, + payload, + }, + )), + } +} + /// Build a signing-host payload-signing request message. -pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) -> RemoteMessage { +pub fn sign_payload_message( + message_id: String, + request: truapi::v01::HostSignPayloadRequest, +) -> RemoteMessage { RemoteMessage { message_id, data: RemoteMessageData::V1(v1::RemoteMessage::SignRequest(Box::new( @@ -521,7 +611,10 @@ pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) } /// Build a signing-host raw-signing request message. -pub fn sign_raw_message(message_id: String, request: HostSignRawRequest) -> RemoteMessage { +pub fn sign_raw_message( + message_id: String, + request: truapi::v01::HostSignRawRequest, +) -> RemoteMessage { RemoteMessage { message_id, data: RemoteMessageData::V1(v1::RemoteMessage::SignRequest(Box::new( @@ -636,6 +729,77 @@ pub fn create_transaction_legacy_message( } } +/// Inbound request decoded from a peer-signed session statement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IncomingSsoRequest { + /// Statement-level request id used by the transport acknowledgement. + pub request_id: String, + /// Application messages batched into the request. + pub messages: Vec, +} + +/// Decode a peer request for a signing-host responder. +/// +/// Own echoes, response acknowledgements, and expired statements are ignored. +pub fn decode_incoming_sso_request( + session: &SsoSessionInfo, + statement: &[u8], +) -> Result, String> { + let verified = + decode_verified_statement_data(statement, None).map_err(|err| err.to_string())?; + if verified.signer == session.ss_public_key { + return Ok(None); + } + if verified.signer != session.identity_account_id { + return Err("statement proof signer does not match expected peer".to_string()); + } + if verified + .expiry + .is_some_and(|expiry| statement_expiry_elapsed(expiry, current_unix_secs())) + { + return Ok(None); + } + match decrypt_session_statement_data(session, &verified.data)? { + SsoStatementData::Response { .. } => Ok(None), + SsoStatementData::Request { request_id, data } => { + let messages = data + .iter() + .map(|message| { + RemoteMessage::decode(&mut message.as_slice()) + .map_err(|err| format!("invalid SSO remote message: {err}")) + }) + .collect::, _>>()?; + Ok(Some(IncomingSsoRequest { + request_id, + messages, + })) + } + } +} + +/// Build the signed transport acknowledgement for a peer-initiated request. +pub fn build_signed_session_response_statement( + session: &SsoSessionInfo, + request_id: String, + response_code: u8, + expiry: u64, +) -> Result, String> { + let encrypted = encrypt_session_statement_data( + session, + &SsoStatementData::Response { + request_id, + response_code, + }, + )?; + build_signed_statement( + session, + peer_response_channel(session), + session.session_id_peer, + encrypted, + expiry, + ) +} + /// Build a signed outbound SSO request statement with a random nonce. pub fn build_outgoing_request_statement( session: &SsoSessionInfo, @@ -708,6 +872,7 @@ mod tests { use p256::elliptic_curve::sec1::ToEncodedPoint; use schnorrkel::{ExpansionMode, MiniSecretKey}; use truapi::latest::{HostSignPayloadData, TxPayloadExtension}; + use truapi::v01::RingLocationJunction; fn account() -> ProductAccountId { ProductAccountId { @@ -762,7 +927,7 @@ mod tests { fn raw_sign_request_uses_remote_message_variant_indices() { let message = sign_raw_message( "m1".to_string(), - HostSignRawRequest { + truapi::v01::HostSignRawRequest { account: account(), payload: RawPayload::Bytes { bytes: vec![0xde, 0xad], @@ -798,11 +963,84 @@ mod tests { assert_eq!(legacy_raw[..3], [0, 0, 10]); } + #[test] + fn ring_vrf_messages_match_host_papp_0_8_11_fixtures() { + let context = ProductProofContext { + product_id: "voting.dot".to_string(), + suffix: vec![0, 1, 2, 3], + }; + let ring_location = RingLocation { + chain_id: [0x11; 32], + junctions: vec![ + RingLocationJunction::PalletInstance(67), + RingLocationJunction::CollectionId(b"pop".to_vec()), + ], + }; + + let alias = alias_request_message( + "m-alias".to_string(), + "caller.dot".to_string(), + context.clone(), + ring_location.clone(), + ); + let proof = proof_request_message( + "m-proof".to_string(), + "caller.dot".to_string(), + context, + ring_location, + b"vote".to_vec(), + ); + let contextual_alias = HostAccountGetAliasResponse { + context: [0x22; 32], + alias: vec![0x33, 0x44], + }; + let alias_response = RemoteMessage { + message_id: "r-alias".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfAliasResponse( + RingVrfAliasResponse { + responding_to: "m-alias".to_string(), + payload: Ok(contextual_alias.clone()), + }, + )), + }; + let proof_response = RemoteMessage { + message_id: "r-proof".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfProofResponse( + RingVrfProofResponse { + responding_to: "m-proof".to_string(), + payload: Ok(HostAccountCreateProofResponse { + proof: vec![0x55, 0x66], + contextual_alias, + ring_index: 7, + ring_revision: 9, + }), + }, + )), + }; + + assert_host_papp_0_8_11_fixture( + alias, + "0x1c6d2d616c69617300032863616c6c65722e646f7428766f74696e672e646f7410000102031111111111111111111111111111111111111111111111111111111111111111080043010c706f70", + ); + assert_host_papp_0_8_11_fixture( + proof, + "0x1c6d2d70726f6f66000c2863616c6c65722e646f7428766f74696e672e646f7410000102031111111111111111111111111111111111111111111111111111111111111111080043010c706f7010766f7465", + ); + assert_host_papp_0_8_11_fixture( + alias_response, + "0x1c722d616c69617300041c6d2d616c696173002222222222222222222222222222222222222222222222222222222222222222083344", + ); + assert_host_papp_0_8_11_fixture( + proof_response, + "0x1c722d70726f6f66000d1c6d2d70726f6f660008556622222222222222222222222222222222222222222222222222222222222222220833440700000009000000", + ); + } + 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) { + fn assert_host_papp_0_8_11_fixture(message: RemoteMessage, expected: &str) { assert_eq!( hex::encode(message.encode()), expected.trim_start_matches("0x") @@ -810,7 +1048,7 @@ mod tests { } #[test] - fn resource_allocation_message_matches_host_papp_0_8_8_fixture() { + fn resource_allocation_message_matches_host_papp_0_8_11_fixture() { let message = resource_allocation_message( "m-resource".to_string(), "truapi-playground.dot".to_string(), @@ -823,14 +1061,14 @@ mod tests { OnExistingAllowancePolicy::Increase, ); - assert_host_papp_0_8_8_fixture( + assert_host_papp_0_8_11_fixture( message, "0x286d2d7265736f757263650005547472756170692d706c617967726f756e642e646f7410000102090000000301", ); } #[test] - fn create_transaction_message_matches_host_papp_0_8_8_fixture() { + fn create_transaction_message_matches_host_papp_0_8_11_fixture() { let message = create_transaction_message( "m-product-tx".to_string(), ProductAccountTxPayload { @@ -849,14 +1087,14 @@ mod tests { }, ); - assert_host_papp_0_8_8_fixture( + assert_host_papp_0_8_11_fixture( message, "0x306d2d70726f647563742d7478000700547472756170692d706c617967726f756e642e646f7400000000202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f0800000428436865636b4e6f6e6365040108020300", ); } #[test] - fn playground_create_transaction_message_matches_host_papp_0_8_8_fixture() { + fn playground_create_transaction_message_matches_host_papp_0_8_11_fixture() { let message = create_transaction_message( "create-transaction-1".to_string(), ProductAccountTxPayload { @@ -875,14 +1113,14 @@ mod tests { }, ); - assert_host_papp_0_8_8_fixture( + assert_host_papp_0_8_11_fixture( message, "0x506372656174652d7472616e73616374696f6e2d31000700547472756170692d706c617967726f756e642e646f7400000000bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f0800000000", ); } #[test] - fn create_transaction_legacy_message_matches_host_papp_0_8_8_fixture() { + fn create_transaction_legacy_message_matches_host_papp_0_8_11_fixture() { let message = create_transaction_legacy_message( "m-legacy-tx".to_string(), LegacyAccountTxPayload { @@ -898,15 +1136,15 @@ mod tests { }, ); - assert_host_papp_0_8_8_fixture( + assert_host_papp_0_8_11_fixture( message, "0x2c6d2d6c65676163792d7478000900000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f0800000428436865636b4e6f6e6365040108020300", ); } #[test] - fn sign_raw_legacy_messages_match_host_papp_0_8_8_fixtures() { - assert_host_papp_0_8_8_fixture( + fn sign_raw_legacy_messages_match_host_papp_0_8_11_fixtures() { + assert_host_papp_0_8_11_fixture( sign_raw_legacy_message( "m-legacy-raw".to_string(), sequential_bytes(0), @@ -916,7 +1154,7 @@ mod tests { ), "0x306d2d6c65676163792d726177000a000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f00084869", ); - assert_host_papp_0_8_8_fixture( + assert_host_papp_0_8_11_fixture( sign_raw_legacy_message( "m-legacy-raw-payload".to_string(), sequential_bytes(0), @@ -930,7 +1168,7 @@ mod tests { #[test] fn option_bool_matches_host_papp_option_bool_encoding() { - let mut request = HostSignPayloadRequest { + let mut request = truapi::v01::HostSignPayloadRequest { account: account(), payload: HostSignPayloadData { block_hash: vec![], @@ -997,7 +1235,7 @@ mod tests { let session = session(); let remote_message = sign_raw_message( "remote-1".to_string(), - HostSignRawRequest { + truapi::v01::HostSignRawRequest { account: account(), payload: RawPayload::Payload { payload: "hello".to_string(), @@ -1037,7 +1275,7 @@ mod tests { let session = session(); let remote_message = sign_raw_message( "remote-1".to_string(), - HostSignRawRequest { + truapi::v01::HostSignRawRequest { account: account(), payload: RawPayload::Payload { payload: "hello".to_string(), @@ -1059,6 +1297,186 @@ mod tests { assert_eq!(decoded, None); } + fn host_and_responder_sessions() -> (SsoSessionInfo, SsoSessionInfo) { + use crate::host_logic::sso::pairing::{ + ResponderIdentity, create_pairing_bootstrap, derive_p256_keypair_from_entropy, + establish_responder_session_info, establish_sso_session_info, + }; + use truapi_platform::{HostInfo, PairingHostConfig, PlatformInfo}; + + let config = PairingHostConfig::new( + HostInfo { + name: "Test Host".to_string(), + icon: None, + version: None, + }, + PlatformInfo::default(), + [0; 32], + [0xbb; 32], + "polkadotapp".to_string(), + ) + .expect("test pairing config is valid"); + let bootstrap = create_pairing_bootstrap(&config).unwrap(); + let statement_keypair = MiniSecretKey::from_bytes(&[7; 32]) + .unwrap() + .expand_to_keypair(ExpansionMode::Ed25519); + let (encryption_secret_key, encryption_public_key) = + derive_p256_keypair_from_entropy(&[0xAB; 16], b"sso-encryption").unwrap(); + let responder = ResponderIdentity { + statement_secret: statement_keypair.secret.to_bytes(), + statement_public_key: statement_keypair.public.to_bytes(), + encryption_secret_key, + encryption_public_key, + }; + let responder_session = establish_responder_session_info( + &responder, + bootstrap.statement_store_public_key, + bootstrap.encryption_public_key, + ) + .unwrap(); + let host_session = establish_sso_session_info( + &bootstrap, + responder.statement_public_key, + responder.encryption_public_key, + ) + .unwrap(); + (host_session, responder_session) + } + + /// A host-built request statement decodes on the responder side into the + /// batched remote messages, and the responder's ack plus response + /// statements resolve the host's pending wait. + #[test] + fn host_request_round_trips_through_responder_statements() { + let (host_session, responder_session) = host_and_responder_sessions(); + let request = sign_raw_message( + "remote-1".to_string(), + truapi::v01::HostSignRawRequest { + account: account(), + payload: RawPayload::Payload { + payload: "hello".to_string(), + }, + }, + ); + let host_statement = build_outgoing_request_statement( + &host_session, + "statement-1".to_string(), + vec![request.clone()], + fresh_expiry(), + ) + .unwrap(); + + let incoming = decode_incoming_sso_request(&responder_session, &host_statement) + .unwrap() + .expect("responder should surface the host request"); + assert_eq!( + incoming, + IncomingSsoRequest { + request_id: "statement-1".to_string(), + messages: vec![request], + } + ); + + let ack = build_signed_session_response_statement( + &responder_session, + incoming.request_id.clone(), + 0, + fresh_expiry(), + ) + .unwrap(); + assert_eq!( + decode_sso_session_statement(&host_session, &ack, "statement-1", "remote-1").unwrap(), + Some(SsoSessionStatement::RequestAccepted) + ); + + let response = RemoteMessage { + message_id: "resp-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::SignResponse(SigningResponse { + responding_to: "remote-1".to_string(), + payload: Ok(SigningPayloadResponseData { + signature: vec![9; 64], + signed_transaction: None, + }), + })), + }; + let response_statement = build_outgoing_request_statement( + &responder_session, + "resp-statement-1".to_string(), + vec![response], + fresh_expiry(), + ) + .unwrap(); + let decoded = decode_sso_session_statement( + &host_session, + &response_statement, + "statement-1", + "remote-1", + ) + .unwrap(); + assert_eq!( + decoded, + Some(SsoSessionStatement::RemoteResponse( + SsoRemoteResponse::Sign(SigningResponse { + responding_to: "remote-1".to_string(), + payload: Ok(SigningPayloadResponseData { + signature: vec![9; 64], + signed_transaction: None, + }), + }) + )) + ); + } + + #[test] + fn responder_ignores_own_echo_and_transport_acks() { + let (host_session, responder_session) = host_and_responder_sessions(); + let own_statement = build_outgoing_request_statement( + &responder_session, + "resp-statement-1".to_string(), + vec![RemoteMessage { + message_id: "resp-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }], + fresh_expiry(), + ) + .unwrap(); + assert_eq!( + decode_incoming_sso_request(&responder_session, &own_statement).unwrap(), + None + ); + + let host_ack = build_signed_session_response_statement( + &host_session, + "resp-statement-1".to_string(), + 0, + fresh_expiry(), + ) + .unwrap(); + assert_eq!( + decode_incoming_sso_request(&responder_session, &host_ack).unwrap(), + None + ); + } + + #[test] + fn responder_ignores_expired_host_request() { + let (host_session, responder_session) = host_and_responder_sessions(); + let stale_statement = build_outgoing_request_statement( + &host_session, + "statement-1".to_string(), + vec![RemoteMessage { + message_id: "remote-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }], + elapsed_expiry(), + ) + .unwrap(); + + assert_eq!( + decode_incoming_sso_request(&responder_session, &stale_statement).unwrap(), + None + ); + } fn response_ack_statement(session: &SsoSessionInfo, expiry: u64) -> Vec { let encrypted = encrypt_session_statement_data_with_nonce( session, diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs index 9d83d055..6f989b06 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 @@ -11,7 +11,8 @@ use super::{ CreateTransactionLegacyRequest, CreateTransactionRequest, CreateTransactionResponse, ResourceAllocationRequest, ResourceAllocationResponse, RingVrfAliasRequest, RingVrfAliasResponse, RingVrfProofRequest, RingVrfProofResponse, SignRawLegacyRequest, - SignRawLegacyResponse, SigningRequest, SigningResponse, + SignRawLegacyResponse, SigningRequest, SigningResponse, StatementStoreProductSignRequest, + StatementStoreProductSignResponse, }; /// v1 messages exchanged with the paired signing host over the encrypted SSO channel. @@ -34,4 +35,6 @@ pub enum RemoteMessage { SignRawLegacyResponse(SignRawLegacyResponse), RingVrfProofRequest(RingVrfProofRequest), RingVrfProofResponse(RingVrfProofResponse), + StatementStoreProductSignRequest(StatementStoreProductSignRequest), + StatementStoreProductSignResponse(StatementStoreProductSignResponse), } 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 4c09adb5..47af4ea0 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -10,7 +10,7 @@ //! //! 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}; @@ -98,7 +98,7 @@ pub mod v2; /// /// /// Mirrors `@novasamatech/statement-store` session statement data: -/// +/// #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SsoStatementData { Request { @@ -111,6 +111,53 @@ pub enum SsoStatementData { }, } +/// Decode a pairing deeplink (or its bare handshake hex) into the advertised +/// handshake proposal. Inverse of [`build_pairing_deeplink`]. +pub fn decode_pairing_deeplink(deeplink: &str) -> Result { + let hex_payload = match deeplink.split_once("?handshake=") { + Some((_, hex_payload)) => hex_payload, + None => deeplink, + }; + let encoded = hex::decode(hex_payload.trim()) + .map_err(|err| format!("invalid pairing deeplink hex: {err}"))?; + let mut input = encoded.as_slice(); + let proposal = VersionedHandshakeProposal::decode(&mut input) + .map_err(|err| format!("invalid pairing handshake proposal: {err}"))?; + if !input.is_empty() { + return Err("invalid pairing handshake proposal: trailing bytes".to_string()); + } + Ok(proposal) +} + +/// Encrypt a v2 handshake response for the host that advertised +/// `host_encryption_public_key`. Inverse of [`decrypt_v2_handshake_response`]: +/// a fresh ephemeral P-256 key is used per response so each Pending/Success +/// statement carries an independent ciphertext. +pub fn encrypt_v2_handshake_response( + host_encryption_public_key: [u8; 65], + response: &v2::EncryptedResponse, +) -> Result { + let (ephemeral_secret, ephemeral_public) = + generate_p256_keypair().map_err(|err| err.to_string())?; + let shared_secret = shared_secret(ephemeral_secret, host_encryption_public_key)?; + let aes_key = aes_key_from_shared_secret(&shared_secret)?; + let mut nonce = [0u8; AES_GCM_NONCE_LEN]; + getrandom::getrandom(&mut nonce) + .map_err(|err| format!("failed to generate AES-GCM nonce: {err}"))?; + let cipher = Aes256Gcm::new_from_slice(&aes_key) + .map_err(|err| format!("failed to initialize AES-GCM: {err}"))?; + let mut encrypted_message = nonce.to_vec(); + encrypted_message.extend( + cipher + .encrypt(Nonce::from_slice(&nonce), response.encode().as_slice()) + .map_err(|err| format!("failed to encrypt SSO handshake response: {err}"))?, + ); + Ok(VersionedHandshakeResponse::V2 { + encrypted_message, + public_key: ephemeral_public, + }) +} + /// Decode wallet-posted pairing handshake data from SCALE bytes. pub fn decode_app_handshake_data(blob: &[u8]) -> Result { let mut input = blob; @@ -175,6 +222,93 @@ pub fn establish_sso_session_info( }) } +/// Signing-host key material answering one pairing proposal. +/// +/// The statement keypair signs every session statement (its public key is the +/// `identityAccountId` the pairing host binds the session to), and the P-256 +/// secret is the persistent `sso_enc` key both sides feed into the session +/// ECDH. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponderIdentity { + pub statement_secret: [u8; 64], + pub statement_public_key: [u8; 32], + pub encryption_secret_key: [u8; 32], + pub encryption_public_key: [u8; 65], +} + +/// Derive the SSO session channels from the responder (signing host) +/// perspective after answering the handshake advertised by +/// `host_statement_account_id` / `host_encryption_public_key`. +/// +/// Mirror of [`establish_sso_session_info`]: the responder's `session_id_own` +/// equals the pairing host's `session_id_peer` and vice versa, so statements +/// built from either side land on the topics the other side subscribes to. +pub fn establish_responder_session_info( + identity: &ResponderIdentity, + host_statement_account_id: [u8; 32], + host_encryption_public_key: [u8; 65], +) -> Result { + let shared_secret = shared_secret(identity.encryption_secret_key, host_encryption_public_key)?; + let shared_secret_bytes: [u8; 32] = (*shared_secret.raw_secret_bytes()).into(); + let session_id_own = create_session_id( + shared_secret_bytes, + identity.statement_public_key, + host_statement_account_id, + ); + let session_id_peer = create_session_id( + shared_secret_bytes, + host_statement_account_id, + identity.statement_public_key, + ); + + Ok(SsoSessionInfo { + ss_secret: identity.statement_secret, + ss_public_key: identity.statement_public_key, + enc_secret: identity.encryption_secret_key, + peer_enc_pubkey: host_encryption_public_key, + identity_account_id: host_statement_account_id, + session_id_own, + session_id_peer, + request_channel: keyed_hash(session_id_own, REQUEST_CHANNEL_SUFFIX), + response_channel: keyed_hash(session_id_own, RESPONSE_CHANNEL_SUFFIX), + peer_request_channel: keyed_hash(session_id_peer, REQUEST_CHANNEL_SUFFIX), + }) +} + +/// Statement channel acknowledging requests the peer initiated: the peer's +/// own `response` channel seen from this side of the session. +pub fn peer_response_channel(session: &SsoSessionInfo) -> [u8; 32] { + keyed_hash(session.session_id_peer, RESPONSE_CHANNEL_SUFFIX) +} + +/// Derive a deterministic P-256 keypair from BIP-39 entropy and a domain +/// label. Host-spec C.4 leaves signing-host P-256 derivation +/// implementation-defined; this scheme only needs to be stable per entropy. +pub fn derive_p256_keypair_from_entropy( + entropy: &[u8], + label: &[u8], +) -> Result<([u8; 32], [u8; 65]), PairingBootstrapError> { + for attempt in 0..MAX_P256_SECRET_ATTEMPTS { + let mut message = Vec::with_capacity(label.len() + 1); + message.extend_from_slice(label); + message.push(attempt as u8); + let candidate = blake2b256_keyed(&message, entropy); + let Ok(secret) = SecretKey::from_slice(&candidate) else { + continue; + }; + let public = secret.public_key().to_encoded_point(false); + let public = public.as_bytes(); + if public.len() != 65 { + return Err(PairingBootstrapError::InvalidP256Secret); + } + let mut encryption_public_key = [0u8; 65]; + encryption_public_key.copy_from_slice(public); + return Ok((candidate, encryption_public_key)); + } + + Err(PairingBootstrapError::InvalidP256Secret) +} + /// Encrypt session-channel statement data with a random nonce. pub fn encrypt_session_statement_data( session: &SsoSessionInfo, @@ -309,13 +443,17 @@ fn create_session_id( } fn keyed_hash(key: [u8; 32], message: &[u8]) -> [u8; 32] { - let digest = blake2b_simd::Params::new() + blake2b256_keyed(message, &key) +} + +fn blake2b256_keyed(message: &[u8], key: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() .hash_length(32) - .key(&key) - .hash(message); - let mut output = [0u8; 32]; - output.copy_from_slice(digest.as_bytes()); - output + .key(key) + .hash(message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") } /// Create one-shot pairing bootstrap material from runtime config. @@ -666,6 +804,136 @@ mod tests { ); } + #[test] + fn decodes_pairing_deeplink_round_trip() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + + let decoded = decode_pairing_deeplink(&deeplink).unwrap(); + + let VersionedHandshakeProposal::V2(proposal) = decoded; + assert_eq!(proposal.device.statement_account_id, SS_PUBLIC); + assert_eq!(proposal.device.encryption_public_key, ENC_PUBLIC); + } + + #[test] + fn decodes_bare_handshake_hex() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + let hex_payload = deeplink.split("handshake=").nth(1).unwrap(); + + assert_eq!( + decode_pairing_deeplink(hex_payload).unwrap(), + decode_pairing_deeplink(&deeplink).unwrap() + ); + } + + #[test] + fn rejects_pairing_deeplink_with_trailing_bytes() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + + let err = decode_pairing_deeplink(&format!("{deeplink}00")).unwrap_err(); + + assert_eq!(err, "invalid pairing handshake proposal: trailing bytes"); + } + + #[test] + fn encrypted_handshake_response_round_trips_through_host_decrypt() { + let host_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let host_public: [u8; 65] = host_secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(); + let response = v2::EncryptedResponse::Success(Box::new(v2::Success { + identity_account_id: [8; 32], + root_account_id: [7; 32], + identity_chat_private_key: [6; 32], + sso_enc_pub_key: ENC_PUBLIC, + device_enc_pub_key: ENC_PUBLIC, + root_entropy_source: [5; 32], + })); + + let VersionedHandshakeResponse::V2 { + encrypted_message, + public_key, + } = encrypt_v2_handshake_response(host_public, &response).unwrap(); + + assert_eq!( + decrypt_v2_handshake_response( + host_secret.to_bytes().into(), + public_key, + &encrypted_message + ) + .unwrap(), + response + ); + } + + fn responder_identity() -> ResponderIdentity { + let (statement_secret, statement_public_key) = generate_statement_store_keypair().unwrap(); + let (encryption_secret_key, encryption_public_key) = + derive_p256_keypair_from_entropy(&[0xAB; 16], b"sso-encryption").unwrap(); + ResponderIdentity { + statement_secret, + statement_public_key, + encryption_secret_key, + encryption_public_key, + } + } + + /// The responder-perspective session must mirror the host-perspective + /// session: swapped session ids, aligned channels, and one shared AES key + /// so either side can decrypt the other's statement data. + #[test] + fn responder_session_mirrors_host_session() { + let config = runtime_config(); + let host_bootstrap = create_pairing_bootstrap(&config).unwrap(); + let responder = responder_identity(); + + let responder_session = establish_responder_session_info( + &responder, + host_bootstrap.statement_store_public_key, + host_bootstrap.encryption_public_key, + ) + .unwrap(); + let host_session = establish_sso_session_info( + &host_bootstrap, + responder.statement_public_key, + responder.encryption_public_key, + ) + .unwrap(); + + assert_eq!( + responder_session.session_id_own, + host_session.session_id_peer + ); + assert_eq!( + responder_session.session_id_peer, + host_session.session_id_own + ); + assert_eq!( + responder_session.request_channel, + host_session.peer_request_channel + ); + assert_eq!( + peer_response_channel(&responder_session), + host_session.response_channel + ); + + let data = SsoStatementData::Request { + request_id: "req-1".to_string(), + data: vec![vec![0xde, 0xad]], + }; + let encrypted = encrypt_session_statement_data(&responder_session, &data).unwrap(); + assert_eq!( + decrypt_session_statement_data(&host_session, &encrypted).unwrap(), + data + ); + } + #[test] fn statement_data_codec_round_trips_request_and_response() { let request = SsoStatementData::Request { diff --git a/rust/crates/truapi-server/src/host_logic/statement_store.rs b/rust/crates/truapi-server/src/host_logic/statement_store.rs index 7195b5d6..e9bdfec4 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store.rs @@ -24,7 +24,7 @@ pub use statement::{ 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_public_key_from_secret, statement_signing_payload, - unsigned_statement_signing_payload, + unsigned_statement_signing_payload, validate_unsigned_statement_signing_payload, }; /// Error while parsing statement-store JSON-RPC or SCALE statement payloads. diff --git a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs index f7dd7021..f5b4db20 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 @@ -218,6 +218,25 @@ pub fn unsigned_statement_signing_payload( statement_signing_payload(&fields) } +/// Validate an exact unsigned statement signing payload. +/// +/// Statement proof signing intentionally signs the encoded statement fields +/// without the surrounding SCALE vector length. Accept only payloads that +/// round-trip from the public unsigned statement shape, so a paired peer cannot +/// reuse the statement-signing path as a generic product-account signing oracle. +pub fn validate_unsigned_statement_signing_payload(payload: &[u8]) -> Result<(), String> { + let fields = decode_unsigned_statement_signing_payload_fields(payload)?; + if fields.is_empty() { + return Err("statement signing payload has no fields".to_string()); + } + let statement = unsigned_statement_from_fields(fields)?; + let canonical = unsigned_statement_signing_payload(statement_fields_from_v01(statement)?)?; + if canonical != payload { + return Err("statement signing payload is not canonical".to_string()); + } + Ok(()) +} + /// Build the statement signing payload from sorted fields. pub fn statement_signing_payload(fields: &[StatementField]) -> Result, String> { let encoded = fields.to_vec().encode(); @@ -228,6 +247,19 @@ pub fn statement_signing_payload(fields: &[StatementField]) -> Result, S Ok(encoded[compact_len..].to_vec()) } +fn decode_unsigned_statement_signing_payload_fields( + mut input: &[u8], +) -> Result, String> { + let mut fields = Vec::new(); + while !input.is_empty() { + fields.push( + StatementField::decode(&mut input) + .map_err(|err| format!("invalid statement signing payload: {err}"))?, + ); + } + Ok(fields) +} + fn decode_statement_fields( statement: &[u8], ) -> Result, StatementStoreParseError> { @@ -307,6 +339,69 @@ fn verify_statement_proof( Ok(signer) } +fn unsigned_statement_from_fields(fields: Vec) -> Result { + 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(_) => { + return Err("statement signing payload must not include proof".to_string()); + } + StatementField::DecryptionKey(value) => { + if decryption_key.replace(value).is_some() { + return Err( + "statement signing payload has duplicate decryption key".to_string() + ); + } + } + StatementField::Expiry(value) => { + if expiry.replace(value).is_some() { + return Err("statement signing payload has duplicate expiry".to_string()); + } + } + StatementField::Channel(value) => { + if channel.replace(value).is_some() { + return Err("statement signing payload has duplicate channel".to_string()); + } + } + StatementField::Topic1(value) => push_unsigned_statement_topic(&mut topics, 0, value)?, + StatementField::Topic2(value) => push_unsigned_statement_topic(&mut topics, 1, value)?, + StatementField::Topic3(value) => push_unsigned_statement_topic(&mut topics, 2, value)?, + StatementField::Topic4(value) => push_unsigned_statement_topic(&mut topics, 3, value)?, + StatementField::Data(value) => { + if data.replace(value).is_some() { + return Err("statement signing payload has duplicate data".to_string()); + } + } + } + } + + Ok(v01::Statement { + proof: None, + decryption_key, + expiry, + channel, + topics, + data, + }) +} + +fn push_unsigned_statement_topic( + topics: &mut Vec<[u8; 32]>, + expected_index: usize, + value: [u8; 32], +) -> Result<(), String> { + if topics.len() != expected_index { + return Err("statement signing payload topics are not contiguous".to_string()); + } + topics.push(value); + Ok(()) +} + /// 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(); @@ -616,6 +711,57 @@ mod tests { assert_eq!(statement_signing_payload(&fields).unwrap(), encoded[1..]); } + #[test] + fn validates_unsigned_statement_signing_payload() { + let payload = unsigned_statement_signing_payload(vec![ + StatementField::Expiry(42), + StatementField::Topic1([1; 32]), + StatementField::Topic2([2; 32]), + StatementField::Data(vec![0xde, 0xad]), + ]) + .unwrap(); + + validate_unsigned_statement_signing_payload(&payload).unwrap(); + } + + #[test] + fn unsigned_statement_signing_payload_rejects_transaction_preimage() { + let tx_preimage = vec![0x00, 0x00, 0x01, 0x02, 0x03]; + + let err = validate_unsigned_statement_signing_payload(&tx_preimage).unwrap_err(); + + assert!(err.contains("invalid statement signing payload")); + } + + #[test] + fn unsigned_statement_signing_payload_rejects_proof_field() { + let payload = + statement_signing_payload(&[StatementField::Proof(StatementProof::Sr25519 { + signature: [1; 64], + signer: [2; 32], + })]) + .unwrap(); + + assert_eq!( + validate_unsigned_statement_signing_payload(&payload).unwrap_err(), + "statement signing payload must not include proof" + ); + } + + #[test] + fn unsigned_statement_signing_payload_rejects_non_contiguous_topics() { + let payload = statement_signing_payload(&[ + StatementField::Topic1([1; 32]), + StatementField::Topic3([3; 32]), + ]) + .unwrap(); + + assert_eq!( + validate_unsigned_statement_signing_payload(&payload).unwrap_err(), + "statement signing payload topics are not contiguous" + ); + } + #[test] fn builds_signed_session_request_statement() { let session = test_session(); diff --git a/rust/crates/truapi-server/src/host_logic/transaction.rs b/rust/crates/truapi-server/src/host_logic/transaction.rs new file mode 100644 index 00000000..13040bc2 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/transaction.rs @@ -0,0 +1,217 @@ +//! Extrinsic signing preimages and v4 signed-extrinsic assembly. +//! +//! Signing hosts receive pre-encoded payload fields (`HostSignPayloadData`) +//! or pre-encoded transaction extensions (`TxPayloadExtension`), so no chain +//! metadata is needed: the preimage is a byte concatenation and the signed +//! extrinsic is assembled mechanically. Matches the polkadot-app signing +//! convention: preimages longer than 256 bytes are BLAKE2b-256 hashed before +//! signing. + +use parity_scale_codec::{Compact, Encode}; +use truapi::latest::{HostSignPayloadData, TxPayloadExtension}; + +/// Preimages longer than this are hashed before signing (standard Substrate +/// signed-payload rule). +const MAX_SIGNED_PREIMAGE_LEN: usize = 256; + +/// Extrinsic version 4 with the signed bit set. +const EXTRINSIC_V4_SIGNED: u8 = 0x84; +/// `MultiAddress::Id` variant index. +const MULTI_ADDRESS_ID: u8 = 0x00; +/// `MultiSignature::Sr25519` variant index. +const MULTI_SIGNATURE_SR25519: u8 = 0x01; + +/// Signing preimage for an extrinsic payload assembled from pre-encoded +/// fields, in the polkadot-app field order. Empty optional fields are +/// skipped, mirroring the JS falsy-field rule. +pub fn extrinsic_payload_preimage(payload: &HostSignPayloadData) -> Vec { + let parts: [&[u8]; 8] = [ + &payload.method, + &payload.era, + &payload.nonce, + &payload.tip, + &payload.spec_version, + &payload.transaction_version, + &payload.genesis_hash, + &payload.block_hash, + ]; + let mut preimage = Vec::new(); + for part in parts { + preimage.extend_from_slice(part); + } + if let Some(asset_id) = &payload.asset_id { + preimage.extend_from_slice(asset_id); + } + if let Some(metadata_hash) = &payload.metadata_hash { + preimage.extend_from_slice(metadata_hash); + } + hash_large_preimage(preimage) +} + +/// Signing preimage for a transaction built from pre-encoded extensions: +/// call data, then every extension's `extra`, then every extension's +/// `additional_signed`. +pub fn transaction_signing_preimage( + call_data: &[u8], + extensions: &[TxPayloadExtension], +) -> Vec { + let mut preimage = call_data.to_vec(); + for extension in extensions { + preimage.extend_from_slice(&extension.extra); + } + for extension in extensions { + preimage.extend_from_slice(&extension.additional_signed); + } + hash_large_preimage(preimage) +} + +/// Assemble a v4 signed extrinsic from a signer public key, an sr25519 +/// signature over [`transaction_signing_preimage`], the pre-encoded +/// extension `extra` data, and the call data. +pub fn build_v4_signed_extrinsic( + signer_public_key: [u8; 32], + signature: [u8; 64], + extensions: &[TxPayloadExtension], + call_data: &[u8], +) -> Vec { + let mut body = Vec::with_capacity(2 + 32 + 1 + 64 + call_data.len()); + body.push(EXTRINSIC_V4_SIGNED); + body.push(MULTI_ADDRESS_ID); + body.extend_from_slice(&signer_public_key); + body.push(MULTI_SIGNATURE_SR25519); + body.extend_from_slice(&signature); + for extension in extensions { + body.extend_from_slice(&extension.extra); + } + body.extend_from_slice(call_data); + + let mut extrinsic = Compact(body.len() as u32).encode(); + extrinsic.extend_from_slice(&body); + extrinsic +} + +fn hash_large_preimage(preimage: Vec) -> Vec { + if preimage.len() > MAX_SIGNED_PREIMAGE_LEN { + blake2b_simd::Params::new() + .hash_length(32) + .hash(&preimage) + .as_bytes() + .to_vec() + } else { + preimage + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn payload() -> HostSignPayloadData { + HostSignPayloadData { + block_hash: vec![0xB1, 0xB2], + block_number: vec![0xFF], + era: vec![0xE1], + genesis_hash: vec![0x61, 0x62], + method: vec![0x4D], + nonce: vec![0x4E], + spec_version: vec![0x51], + tip: vec![0x54], + transaction_version: vec![0x56], + signed_extensions: vec![], + version: 4, + asset_id: None, + metadata_hash: None, + mode: None, + with_signed_transaction: None, + } + } + + #[test] + fn payload_preimage_uses_polkadot_app_field_order() { + // method, era, nonce, tip, spec_version, transaction_version, + // genesis_hash, block_hash. block_number is not part of the preimage. + assert_eq!( + extrinsic_payload_preimage(&payload()), + vec![0x4D, 0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2] + ); + } + + #[test] + fn payload_preimage_appends_asset_id_and_metadata_hash() { + let mut payload = payload(); + payload.asset_id = Some(vec![0xAA]); + payload.metadata_hash = Some(vec![0xBB]); + + assert_eq!( + extrinsic_payload_preimage(&payload), + vec![ + 0x4D, 0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2, 0xAA, 0xBB + ] + ); + } + + #[test] + fn long_preimages_are_blake2b_hashed() { + let mut payload = payload(); + payload.method = vec![0x4D; 300]; + + let preimage = extrinsic_payload_preimage(&payload); + + assert_eq!(preimage.len(), 32); + let mut raw = vec![0x4D; 300]; + raw.extend_from_slice(&[0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2]); + assert_eq!( + preimage, + blake2b_simd::Params::new() + .hash_length(32) + .hash(&raw) + .as_bytes() + .to_vec() + ); + } + + #[test] + fn transaction_preimage_orders_call_extra_then_implicit() { + let extensions = vec![ + TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![0x01], + additional_signed: vec![0x02], + }, + TxPayloadExtension { + id: "CheckSpecVersion".to_string(), + extra: vec![0x03], + additional_signed: vec![0x04], + }, + ]; + + assert_eq!( + transaction_signing_preimage(&[0xCA, 0x11], &extensions), + vec![0xCA, 0x11, 0x01, 0x03, 0x02, 0x04] + ); + } + + #[test] + fn builds_v4_signed_extrinsic_layout() { + let extensions = vec![TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![0xEE], + additional_signed: vec![0xDD], + }]; + + let extrinsic = + build_v4_signed_extrinsic([0xAB; 32], [0xCD; 64], &extensions, &[0xCA, 0x11]); + + let body_len = 1 + 1 + 32 + 1 + 64 + 1 + 2; + assert_eq!(extrinsic[..2], Compact(body_len as u32).encode()[..]); + let body = &extrinsic[2..]; + assert_eq!(body.len(), body_len); + assert_eq!(body[0], 0x84); + assert_eq!(body[1], 0x00); + assert_eq!(&body[2..34], &[0xAB; 32]); + assert_eq!(body[34], 0x01); + assert_eq!(&body[35..99], &[0xCD; 64]); + assert_eq!(body[99], 0xEE); + assert_eq!(&body[100..], &[0xCA, 0x11]); + } +} diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index 9d2d8489..b3da78bc 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -32,6 +32,9 @@ pub use host_core::{ FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeError, SigningHostRuntime, }; +pub use runtime::ResponderExit; +#[cfg(not(target_arch = "wasm32"))] +pub use runtime::statement_allowance; pub use truapi_platform::{ HostRuntimeConfig, PairingHostConfig, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, ProductContext, SigningHostConfig, diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index ee990b2d..60b71380 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -17,6 +17,8 @@ pub(crate) mod services; mod signing_host; pub(crate) mod sso_pairing; pub(crate) mod sso_remote; +#[cfg(not(target_arch = "wasm32"))] +pub mod statement_allowance; pub(crate) mod statement_store; mod statement_store_rpc; @@ -46,7 +48,10 @@ pub(crate) use authority::{BulletinAllowanceKey, ProductAuthority}; use pairing_host::PairingHost; pub(crate) use pairing_host::PairingHost as PairingHostRole; pub(crate) use services::RuntimeServices; -pub(crate) use signing_host::{LocalActivation, SigningHost as SigningHostRole}; +pub use signing_host::ResponderExit; +pub(crate) use signing_host::{ + LocalActivation, SigningHost as SigningHostRole, respond_to_pairing, +}; use authority::{ AccountAliasAuthorityRequest, AuthorityCancelError, AuthorityError, AuthoritySession, @@ -140,10 +145,10 @@ use truapi::{CallContext, CallError, CancellationReason, Subscription}; #[cfg(test)] use truapi_platform::Platform; use truapi_platform::{ - AccountAccessReview, AccountAliasReview, CreateProofReview, CreateTransactionReview, - IdentityDisclosureReview, PermissionAuthorizationRequest, PermissionAuthorizationStatus, - PreimageSubmitReview, ProductContext, SessionUiInfo, SignPayloadReview, SignRawReview, - UserConfirmationReview, normalize_product_identifier, + AccountAccessReview, CreateTransactionReview, IdentityDisclosureReview, + PermissionAuthorizationRequest, PermissionAuthorizationStatus, PreimageSubmitReview, + ProductContext, SessionUiInfo, SignPayloadReview, SignRawReview, UserConfirmationReview, + normalize_product_identifier, }; pub(super) const REMOTE_PERMISSION_DENIED_REASON: &str = "Permission denied"; @@ -870,24 +875,6 @@ impl Account for ProductRuntimeHost { }; let calling_product_id = self.product_id(); - let confirmed = self - .services - .platform - .confirm_user_action(UserConfirmationReview::AccountAlias(AccountAliasReview { - calling_product_id: calling_product_id.clone(), - context: context.clone(), - ring_location: ring_location.clone(), - })) - .await - .map_err(|err| CallError::HostFailure { - reason: format!("account alias confirmation failed: {err:?}"), - })?; - if !confirmed { - return Err(CallError::Domain(HostAccountGetAliasError::V1( - v01::HostAccountGetAliasError::Rejected, - ))); - } - let cx = remote_authority_context(cx); remote_authority_call( &cx, @@ -925,25 +912,6 @@ impl Account for ProductRuntimeHost { }; let calling_product_id = self.product_id(); - let confirmed = self - .services - .platform - .confirm_user_action(UserConfirmationReview::CreateProof(CreateProofReview { - calling_product_id: calling_product_id.clone(), - context: context.clone(), - ring_location: ring_location.clone(), - message: message.clone(), - })) - .await - .map_err(|err| CallError::HostFailure { - reason: format!("ring-VRF proof confirmation failed: {err:?}"), - })?; - if !confirmed { - return Err(CallError::Domain(HostAccountCreateProofError::V1( - v01::HostAccountCreateProofError::Rejected, - ))); - } - let cx = remote_authority_context(cx); remote_authority_call( &cx, @@ -988,16 +956,24 @@ impl Account for ProductRuntimeHost { )) })?; + let mut accounts = vec![v01::LegacyAccount { + public_key: public_key.to_vec(), + // TODO(#266): gate this legacy display name on + // IdentityDisclosure while keeping the public key for + // compatibility. + name: session.lite_username.clone(), + }]; + if let Some(identity_account_id) = session.identity_account_id + && identity_account_id != public_key + { + accounts.push(v01::LegacyAccount { + public_key: identity_account_id.to_vec(), + name: Some("Identity".to_string()), + }); + } + Ok(HostGetLegacyAccountsResponse::V1( - v01::HostGetLegacyAccountsResponse { - accounts: vec![v01::LegacyAccount { - public_key: public_key.to_vec(), - // TODO(#266): gate this legacy display name on - // IdentityDisclosure while keeping the public key for - // compatibility. - name: session.lite_username.clone(), - }], - }, + v01::HostGetLegacyAccountsResponse { accounts }, )) } @@ -1725,9 +1701,9 @@ impl Chain for ProductRuntimeHost { ) -> Result> { let RemoteChainTransactionStopRequest::V1(inner) = request; - // We intentionally forward the provider operation id here. Transaction - // operation ids are node-assigned and short-lived, so cross-product - // collision or guessing is not worth local id indirection yet. + // ChainRuntime resolves this product-visible host handle to the + // provider's short-lived operation id and makes stopping an operation + // that completed between broadcast and stop idempotent. self.services .chain .remote_chain_transaction_stop(inner) @@ -2012,7 +1988,7 @@ impl Preimage for ProductRuntimeHost { .bulletin_allowance_key(&authority_cx, &session, self.product_id()), ) .await - .map_err(|err| preimage_submit_error(err.reason()))?; + .map_err(|err| preimage_submit_error(bulletin_allowance_error_reason(err)))?; let key = match bulletin .submit_preimage(cx, submission_deadline, &allowance, &value) @@ -2037,7 +2013,7 @@ impl Preimage for ProductRuntimeHost { ), ) .await - .map_err(|err| preimage_submit_error(err.reason()))?; + .map_err(|err| preimage_submit_error(bulletin_allowance_error_reason(err)))?; bulletin .submit_preimage(cx, submission_deadline, &allowance, &value) .await @@ -2069,6 +2045,18 @@ fn preimage_submit_error(reason: String) -> CallError )) } +fn bulletin_allowance_error_reason(err: AuthorityError) -> String { + match err { + AuthorityError::Rejected => { + "Bulletin allowance allocation was rejected by the signing host".to_string() + } + AuthorityError::Disconnected => { + "Signing host disconnected while allocating Bulletin allowance".to_string() + } + other => other.reason(), + } +} + // --------------------------------------------------------------------------- // Theme // --------------------------------------------------------------------------- @@ -2158,6 +2146,14 @@ mod tests { } } + #[test] + fn preimage_reports_bulletin_allocation_rejection_with_context() { + assert_eq!( + bulletin_allowance_error_reason(AuthorityError::Rejected), + "Bulletin allowance allocation was rejected by the signing host" + ); + } + fn recorded_rpc_methods(sent_rpc: &Mutex>) -> Vec { sent_rpc .lock() @@ -2524,28 +2520,9 @@ mod tests { } #[test] - fn get_account_alias_rejected_when_user_declines() { - let host = - ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); - host.test_session_state().set_session(sso_session_info()); - let cx = CallContext::new(); - let err = futures::executor::block_on( - host.get_account_alias(&cx, account_alias_request("myapp.dot")), - ) - .unwrap_err(); - assert!(matches!( - err, - CallError::Domain(HostAccountGetAliasError::V1( - v01::HostAccountGetAliasError::Rejected - )) - )); - } - - #[test] - fn get_account_alias_returns_sso_alias() { + fn get_account_alias_forwards_without_pairing_host_confirmation() { let session = sso_session_info(); let platform = Arc::new(StubPlatform { - account_alias_confirmed: true, sso_response_script: Some(sso_success_response_script( &session, RemoteMessage { @@ -2608,7 +2585,6 @@ mod tests { fn create_account_proof_returns_sso_proof() { let session = sso_session_info(); let platform = Arc::new(StubPlatform { - create_proof_confirmed: true, sso_response_script: Some(sso_success_response_script( &session, RemoteMessage { @@ -2660,7 +2636,6 @@ mod tests { fn create_account_proof_maps_not_member_error() { let session = sso_session_info(); let platform = Arc::new(StubPlatform { - create_proof_confirmed: true, sso_response_script: Some(sso_success_response_script( &session, RemoteMessage { @@ -2708,12 +2683,17 @@ mod tests { ) .unwrap(); let HostGetLegacyAccountsResponse::V1(inner) = response; - assert_eq!(inner.accounts.len(), 1); + assert_eq!(inner.accounts.len(), 2); assert_eq!(inner.accounts[0].name.as_deref(), Some("alice")); assert_eq!( hex::encode(&inner.accounts[0].public_key), "1c822b488297fde8c60d9cbc5585839f70a69fb2c5c69daa66b6043c75184467" ); + assert_eq!(inner.accounts[1].name.as_deref(), Some("Identity")); + assert_eq!( + inner.accounts[1].public_key, + session_info().identity_account_id.unwrap() + ); } #[test] diff --git a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs index 3b106f81..23ba14eb 100644 --- a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs @@ -177,6 +177,18 @@ impl BulletinRpc { } } + /// Open a raw RPC client over the configured Bulletin chain. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn client( + &self, + label: &'static str, + ) -> Result { + self.chain + .rpc_client(label, &self.genesis_hash) + .await + .map(subxt_rpcs::RpcClient::new) + } + /// Submit `value` as a Bulletin preimage signed by `allowance`, returning /// the preimage key once the transaction is included and its dispatch /// succeeded. diff --git a/rust/crates/truapi-server/src/runtime/identity.rs b/rust/crates/truapi-server/src/runtime/identity.rs index 36e7d9f4..c783c603 100644 --- a/rust/crates/truapi-server/src/runtime/identity.rs +++ b/rust/crates/truapi-server/src/runtime/identity.rs @@ -17,7 +17,7 @@ use crate::host_logic::session::SessionInfo; use futures::{FutureExt, pin_mut}; use tracing::{debug, instrument, warn}; -use truapi::v01::{ +use truapi::latest::{ OperationStartedResult, RemoteChainHeadFollowRequest, RemoteChainHeadStorageRequest, StorageQueryItem, StorageQueryType, }; @@ -51,7 +51,7 @@ pub(super) async fn resolve_session_identity_with_chain( } let preferred_account = session.identity_account_id.unwrap_or(session.public_key); - if !lookup_and_apply( + if lookup_and_apply( chain, people_chain_genesis_hash, preferred_account, @@ -59,6 +59,7 @@ pub(super) async fn resolve_session_identity_with_chain( "identity", ) .await + == LookupOutcome::NoRecord && preferred_account != session.public_key { let public_key = session.public_key; @@ -75,42 +76,60 @@ pub(super) async fn resolve_session_identity_with_chain( session } +/// Maximum lookup attempts per account on transient failure. The first attempt +/// warms the People-chain connection (cached per genesis), so a retry after a +/// cold-start timeout usually resolves immediately. +const IDENTITY_LOOKUP_MAX_ATTEMPTS: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LookupOutcome { + /// A username record was found and applied. + Applied, + /// The account has no consumer record (definitive; do not retry). + NoRecord, + /// The lookup failed transiently after exhausting retries. + Failed, +} + /// Look up `account`'s people-chain identity and apply any usernames to -/// `session`; returns whether a username record was found and applied. +/// `session`, retrying transient failures against the warmed connection. async fn lookup_and_apply( chain: &ChainRuntime, people_chain_genesis_hash: [u8; 32], account: [u8; 32], session: &mut SessionInfo, label: &str, -) -> bool { - match lookup_people_identity(chain, people_chain_genesis_hash, account).await { - Ok(Some(identity)) => { - debug!( - account = %hex::encode(account), - lite_username = identity.lite_username.as_deref().unwrap_or(""), - full_username = identity.full_username.as_deref().unwrap_or(""), - "People-chain {label} lookup found username" - ); - session.apply_usernames(identity.lite_username, identity.full_username); - true - } - Ok(None) => { - debug!( - account = %hex::encode(account), - "People-chain {label} lookup found no consumer record" - ); - false - } - Err(reason) => { - warn!( - account = %hex::encode(account), - %reason, - "People-chain {label} lookup failed" - ); - false +) -> LookupOutcome { + for attempt in 1..=IDENTITY_LOOKUP_MAX_ATTEMPTS { + match lookup_people_identity(chain, people_chain_genesis_hash, account).await { + Ok(Some(identity)) => { + debug!( + account = %hex::encode(account), + lite_username = identity.lite_username.as_deref().unwrap_or(""), + full_username = identity.full_username.as_deref().unwrap_or(""), + "People-chain {label} lookup found username" + ); + session.apply_usernames(identity.lite_username, identity.full_username); + return LookupOutcome::Applied; + } + Ok(None) => { + debug!( + account = %hex::encode(account), + "People-chain {label} lookup found no consumer record" + ); + return LookupOutcome::NoRecord; + } + Err(reason) => { + warn!( + account = %hex::encode(account), + attempt, + %reason, + "People-chain {label} lookup failed" + ); + } } } + LookupOutcome::Failed } #[instrument(skip_all, fields(runtime.method = "session.identity.lookup"))] diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index dbd5c142..c97cfc11 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -224,7 +224,7 @@ impl PairingHost { if !session_matches_key(&session_state, key) { return Err(SsoRemoteResponseError::LocalDisconnected); } - statement_store_rpc::submit(&submit_client, statement) + statement_store_rpc::submit_sso(&submit_client, statement, "pairing-host request") .await .map_err(|err| { SsoRemoteResponseError::Failure(format!("SSO statement submit failed: {err}")) diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index afe09e97..95b69931 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -5,13 +5,22 @@ //! embedding host at unlock through [`LocalActivation::activate_local_session`] //! (the host owns its persistence, e.g. the OS keychain) and kept in memory //! for the session, zeroized on disconnect. +//! +//! Implemented: local session lifecycle, raw-bytes signing, extrinsic-payload +//! signing, v4 transaction construction (payload fields and extensions arrive +//! pre-encoded, so no chain metadata is needed), RFC-0007 product entropy, and +//! bandersnatch ring-VRF product-account aliases (native only), and +//! product-scoped Statement Store and Bulletin allowance keys. mod local_activation; mod ring_vrf; +mod sso_responder; use std::sync::{Arc, Mutex}; pub(crate) use local_activation::LocalActivation; +pub use sso_responder::ResponderExit; +pub(crate) use sso_responder::respond_to_pairing; use super::authority::{ AccountAliasAuthorityRequest, AuthorityError, AuthoritySession, BulletinAllowanceKey, @@ -24,10 +33,11 @@ use crate::host_logic::entropy::derive_product_entropy; use crate::host_logic::extrinsic::{Sr25519Signer, build_signed_extrinsic_v4}; use crate::host_logic::product_account::{ ProductAccountError, SR25519_SIGNING_CONTEXT, derive_product_keypair, - derive_root_keypair_from_entropy, + derive_root_keypair_from_entropy, derive_sr25519_hard_path, }; use crate::host_logic::session::SessionState; -use crate::host_logic::sso::messages::RingVrfError; +use crate::host_logic::sso::messages::{OnExistingAllowancePolicy, RingVrfError}; +use crate::host_logic::transaction::extrinsic_payload_preimage; use crate::runtime::auth_state::AuthStateMachine; use ring_vrf::{ ChainRingResolver, MemberCandidate, PersonKey, RingResolver, alias_from_entropy, context_bytes, @@ -36,9 +46,10 @@ use ring_vrf::{ use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, v01}; -#[cfg(test)] -use truapi_platform::Platform; -use truapi_platform::{ProductContext, normalize_product_identifier}; +use truapi_platform::{ + AccountAliasReview, CreateProofReview, Platform, ProductContext, UserConfirmationReview, + normalize_product_identifier, +}; use zeroize::Zeroizing; const BYTES_WRAP_PREFIX: &[u8] = b""; @@ -46,6 +57,8 @@ const BYTES_WRAP_SUFFIX: &[u8] = b""; /// Wallet-local account authority for a signing host. pub(crate) struct SigningHost { + services: Arc, + platform: Arc, session_state: Arc, auth_state: AuthStateMachine, ring_resolver: Arc, @@ -58,6 +71,8 @@ impl SigningHost { let platform = services.platform.clone(); let ring_resolver = ChainRingResolver::new(services.chain.clone()); Arc::new(Self { + services, + platform: platform.clone(), session_state: SessionState::new(), auth_state: AuthStateMachine::new(platform), ring_resolver, @@ -70,7 +85,15 @@ impl SigningHost { platform: Arc, ring_resolver: Arc, ) -> Arc { + let services = RuntimeServices::new( + platform.clone(), + [0; 32], + [0xbb; 32], + crate::test_support::test_spawner(), + ); Arc::new(Self { + services, + platform: platform.clone(), session_state: SessionState::new(), auth_state: AuthStateMachine::new(platform), ring_resolver, @@ -140,6 +163,24 @@ impl SigningHost { }, ]) } + + async fn confirm_ring_vrf_if_cross_product( + &self, + calling_product_id: &str, + target_product_id: &str, + review: UserConfirmationReview, + ) -> Result<(), RingVrfError> { + if calling_product_id == target_product_id { + return Ok(()); + } + match self.platform.confirm_user_action(review).await { + Ok(true) => Ok(()), + Ok(false) => Err(RingVrfError::Rejected), + Err(err) => Err(RingVrfError::Unknown { + reason: format!("confirmation failed: {}", err.reason), + }), + } + } } #[async_trait::async_trait] @@ -183,14 +224,20 @@ impl ProductAuthority for SigningHost { async fn sign_payload( &self, _cx: &CallContext, - _session: &AuthoritySession, - _request: SignPayloadAuthorityRequest, + session: &AuthoritySession, + request: SignPayloadAuthorityRequest, ) -> Result { - Err(AuthorityError::Unavailable { - reason: "signing host: extrinsic-payload signing needs chain-metadata payload \ - assembly (not yet implemented)" - .to_string(), - }) + require_current_session(&self.session_state, session)?; + let (keypair, payload) = match request { + SignPayloadAuthorityRequest::Product(request) => { + (self.product_keypair(&request.account)?, request.payload) + } + SignPayloadAuthorityRequest::LegacyAccount { + product_account, + request, + } => (self.product_keypair(&product_account)?, request.payload), + }; + sign_extrinsic_payload(&keypair, payload) } async fn sign_raw( @@ -199,15 +246,26 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: SignRawAuthorityRequest, ) -> Result { - let SignRawAuthorityRequest::Product(request) = request else { - return Err(AuthorityError::Unavailable { - reason: "signing host: legacy-account raw signing is not yet implemented" - .to_string(), - }); + let (keypair, payload) = match request { + SignRawAuthorityRequest::Product(request) => { + (self.product_keypair(&request.account)?, request.payload) + } + SignRawAuthorityRequest::LegacyAccount { account, request } => { + let entropy = self.root_entropy()?; + let keypair = derive_sr25519_hard_path(&entropy, &["wallet", "sso"]) + .map_err(product_authority_error)?; + if keypair.public.to_bytes() != account { + return Err(AuthorityError::Unavailable { + reason: "signing host: the requested legacy account is not available in \ + this CLI wallet" + .to_string(), + }); + } + (keypair, request.payload) + } }; require_current_session(&self.session_state, session)?; - let keypair = self.product_keypair(&request.account)?; - let message = raw_payload_bytes(request.payload)?; + let message = raw_payload_bytes(payload)?; let signature = keypair .secret .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) @@ -269,6 +327,16 @@ impl ProductAuthority for SigningHost { request: AccountAliasAuthorityRequest, ) -> Result { require_current_session(&self.session_state, session)?; + self.confirm_ring_vrf_if_cross_product( + &request.calling_product_id, + &request.context.product_id, + UserConfirmationReview::AccountAlias(AccountAliasReview { + calling_product_id: request.calling_product_id.clone(), + context: request.context.clone(), + ring_location: request.ring_location.clone(), + }), + ) + .await?; let collection = self.ring_resolver.validate(&request.ring_location).await?; let context = context_bytes(&request.context); let entropy = self.person_entropy(session, key_for_collection(&collection))?; @@ -285,6 +353,18 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: CreateProofAuthorityRequest, ) -> Result { + require_current_session(&self.session_state, session)?; + self.confirm_ring_vrf_if_cross_product( + &request.calling_product_id, + &request.context.product_id, + UserConfirmationReview::CreateProof(CreateProofReview { + calling_product_id: request.calling_product_id.clone(), + context: request.context.clone(), + ring_location: request.ring_location.clone(), + message: request.message.clone(), + }), + ) + .await?; let candidates = self.member_candidates(session)?; let resolved = self .ring_resolver @@ -309,50 +389,95 @@ impl ProductAuthority for SigningHost { async fn allocate_resources( &self, _cx: &CallContext, - _session: &AuthoritySession, - _product_id: String, - _request: v01::HostRequestResourceAllocationRequest, + session: &AuthoritySession, + product_id: String, + request: v01::HostRequestResourceAllocationRequest, ) -> Result { - Err(AuthorityError::Unavailable { - reason: "signing host: on-chain resource allocation not yet implemented".to_string(), - }) + require_current_session(&self.session_state, session)?; + let mut outcomes = Vec::with_capacity(request.resources.len()); + for resource in request.resources { + let outcome = match resource { + v01::AllocatableResource::StatementStoreAllowance => { + sso_responder::allocate_statement_store_allowance( + &self.services, + self, + &product_id, + ) + .await + .map(|_| v01::AllocationOutcome::Allocated) + } + v01::AllocatableResource::BulletinAllowance => { + sso_responder::allocate_bulletin_allowance( + &self.services, + self, + &product_id, + OnExistingAllowancePolicy::Ignore, + ) + .await + .map(|_| v01::AllocationOutcome::Allocated) + } + v01::AllocatableResource::SmartContractAllowance(_) + | v01::AllocatableResource::AutoSigning => Ok(v01::AllocationOutcome::NotAvailable), + }; + match outcome { + Ok(outcome) => outcomes.push(outcome), + Err(reason) => { + tracing::warn!(%product_id, %reason, "direct resource allocation item failed"); + outcomes.push(v01::AllocationOutcome::Rejected); + } + } + } + Ok(v01::HostRequestResourceAllocationResponse { outcomes }) } async fn statement_store_allowance_key( &self, _cx: &CallContext, session: &AuthoritySession, - _product_id: String, + product_id: String, ) -> Result { require_current_session(&self.session_state, session)?; - Err(AuthorityError::Unavailable { - reason: "signing host: statement-store allowance allocation not yet implemented" - .to_string(), - }) + let secret = + sso_responder::allocate_statement_store_allowance(&self.services, self, &product_id) + .await + .map_err(allocation_authority_error)?; + StatementStoreAllowanceKey::from_secret_bytes(secret) } async fn bulletin_allowance_key( &self, _cx: &CallContext, session: &AuthoritySession, - _product_id: String, + product_id: String, ) -> Result { require_current_session(&self.session_state, session)?; - Err(AuthorityError::Unavailable { - reason: "signing host: bulletin allowance allocation not yet implemented".to_string(), - }) + let secret = sso_responder::allocate_bulletin_allowance( + &self.services, + self, + &product_id, + OnExistingAllowancePolicy::Ignore, + ) + .await + .map_err(allocation_authority_error)?; + BulletinAllowanceKey::from_secret_bytes(secret) } async fn refresh_bulletin_allowance_key( &self, _cx: &CallContext, session: &AuthoritySession, - _product_id: String, + product_id: String, ) -> Result { require_current_session(&self.session_state, session)?; - Err(AuthorityError::Unavailable { - reason: "signing host: bulletin allowance allocation not yet implemented".to_string(), - }) + let secret = sso_responder::allocate_bulletin_allowance( + &self.services, + self, + &product_id, + OnExistingAllowancePolicy::Increase, + ) + .await + .map_err(allocation_authority_error)?; + BulletinAllowanceKey::from_secret_bytes(secret) } async fn sign_statement_store_product_payload( @@ -386,12 +511,39 @@ impl ProductAuthority for SigningHost { } } +fn sign_extrinsic_payload( + keypair: &schnorrkel::Keypair, + payload: v01::HostSignPayloadData, +) -> Result { + if payload.version != 4 { + return Err(AuthorityError::NotSupported { + reason: format!( + "signing host: unsupported extrinsic payload version {}; only version 4 is supported", + payload.version + ), + }); + } + let preimage = extrinsic_payload_preimage(&payload); + let signature = keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &preimage, &keypair.public) + .to_bytes(); + Ok(v01::HostSignPayloadResponse { + signature: signature.to_vec(), + signed_transaction: None, + }) +} + fn product_authority_error(err: ProductAccountError) -> AuthorityError { AuthorityError::Unavailable { reason: err.to_string(), } } +fn allocation_authority_error(reason: String) -> AuthorityError { + AuthorityError::Unavailable { reason } +} + /// Assemble and sign a transaction locally from caller-supplied, pre-encoded /// parts. Only Extrinsic V4 (`tx_ext_version == 0`) is supported; the caller's /// extension bytes carry the whole chain binding, so no metadata is consulted. @@ -457,19 +609,21 @@ mod tests { use super::super::authority::{ AccountAliasAuthorityRequest, AuthorityError, CreateProofAuthorityRequest, - CreateTransactionAuthorityRequest, SignRawAuthorityRequest, + CreateTransactionAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; use super::super::{ProductAuthority, ProductRuntimeHost, RuntimeServices, SigningHostRole}; use super::ring_vrf::{ MemberCandidate, PersonKey, ResolvedRing, RingResolver, member_from_entropy, person_entropy, }; use super::{ - BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, RingVrfError, raw_payload_bytes, + BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, RingVrfError, + SR25519_SIGNING_CONTEXT, raw_payload_bytes, }; use crate::host_logic::extrinsic::tests::split_v4; use crate::host_logic::product_account::{ - derive_product_keypair, derive_root_keypair_from_entropy, + derive_product_keypair, derive_root_keypair_from_entropy, derive_sr25519_hard_path, }; + use crate::host_logic::transaction::extrinsic_payload_preimage; use crate::test_support::{StubPlatform, test_spawner}; use truapi::api::{Account, Entropy, Signing}; use truapi::versioned::account::{HostAccountGetError, HostAccountGetRequest}; @@ -557,24 +711,27 @@ mod tests { ) } - #[test] - fn ring_alias_and_proof_share_the_selected_person_key() { + fn full_person_ring_resolver() -> Arc { let full_entropy = person_entropy(&ENTROPY, PersonKey::Full); let full_member = member_from_entropy(&full_entropy).expect("full-person member"); - let selected = MemberCandidate { - key: PersonKey::Full, - member: full_member, - }; - let resolver = Arc::new(StubRingResolver { + Arc::new(StubRingResolver { collection: *b"pop:polkadot.network/people ", ring: ResolvedRing { - selected, + selected: MemberCandidate { + key: PersonKey::Full, + member: full_member, + }, ring_index: 7, ring_revision: 11, domain_size: RingDomainSize::Domain11, members: vec![full_member], }, - }); + }) + } + + #[test] + fn ring_alias_and_proof_share_the_selected_person_key() { + let resolver = full_person_ring_resolver(); let platform: Arc = Arc::new(StubPlatform::default()); let authority = SigningHostRole::new_with_ring_resolver(platform, resolver); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) @@ -623,6 +780,48 @@ mod tests { assert_eq!(proof.ring_revision, 11); } + #[test] + fn cross_product_ring_requests_require_signing_host_confirmation() { + let platform: Arc = Arc::new(StubPlatform::default()); + let authority = + SigningHostRole::new_with_ring_resolver(platform, full_person_ring_resolver()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = authority.current_session().expect("active session"); + let cx = CallContext::new(); + let context = v01::ProductProofContext { + product_id: "other.dot".to_string(), + suffix: b"account".to_vec(), + }; + let ring_location = v01::RingLocation { + chain_id: [0x22; 32], + junctions: vec![v01::RingLocationJunction::PalletInstance(42)], + }; + + let alias = futures::executor::block_on(authority.account_alias( + &cx, + &session, + AccountAliasAuthorityRequest { + calling_product_id: "myapp.dot".to_string(), + context: context.clone(), + ring_location: ring_location.clone(), + }, + )); + assert_eq!(alias, Err(RingVrfError::Rejected)); + + let proof = futures::executor::block_on(authority.create_proof( + &cx, + &session, + CreateProofAuthorityRequest { + calling_product_id: "myapp.dot".to_string(), + context, + ring_location, + message: b"prove me".to_vec(), + }, + )); + assert_eq!(proof, Err(RingVrfError::Rejected)); + } + #[test] fn activate_then_sign_raw_verifies_against_derived_product_key() { let (services, activation) = signing_runtime(); @@ -657,6 +856,95 @@ mod tests { ); } + #[test] + fn sign_payload_product_and_legacy_use_the_substrate_preimage() { + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = authority.current_session().expect("active session"); + let cx = CallContext::new(); + let payload = crate::test_support::sign_payload_data(); + let preimage = extrinsic_payload_preimage(&payload); + + let product_response = futures::executor::block_on(authority.sign_payload( + &cx, + &session, + SignPayloadAuthorityRequest::Product(v01::HostSignPayloadRequest { + account: product_account(0), + payload: payload.clone(), + }), + )) + .expect("product payload signing succeeds"); + + let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let signature = schnorrkel::Signature::from_bytes(&product_response.signature).unwrap(); + assert!( + keypair + .public + .verify_simple(SR25519_SIGNING_CONTEXT, &preimage, &signature) + .is_ok() + ); + + let legacy_response = futures::executor::block_on(authority.sign_payload( + &cx, + &session, + SignPayloadAuthorityRequest::LegacyAccount { + product_account: product_account(0), + request: v01::HostSignPayloadWithLegacyAccountRequest { + signer: format!("0x{}", hex::encode(keypair.public.to_bytes())), + payload, + }, + }, + )) + .expect("legacy payload signing succeeds"); + let signature = schnorrkel::Signature::from_bytes(&legacy_response.signature).unwrap(); + assert!( + keypair + .public + .verify_simple(SR25519_SIGNING_CONTEXT, &preimage, &signature) + .is_ok() + ); + } + + #[test] + fn sign_raw_legacy_accepts_only_the_wallet_identity_key() { + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = authority.current_session().expect("active session"); + let cx = CallContext::new(); + let identity = derive_sr25519_hard_path(&ENTROPY, &["wallet", "sso"]).unwrap(); + let request = |account| SignRawAuthorityRequest::LegacyAccount { + account, + request: v01::HostSignRawWithLegacyAccountRequest { + signer: String::new(), + payload: v01::RawPayload::Bytes { + bytes: b"hello".to_vec(), + }, + }, + }; + + let response = futures::executor::block_on(authority.sign_raw( + &cx, + &session, + request(identity.public.to_bytes()), + )) + .expect("identity raw signing succeeds"); + let signature = schnorrkel::Signature::from_bytes(&response.signature).unwrap(); + assert!( + identity + .public + .verify_simple(SR25519_SIGNING_CONTEXT, b"hello", &signature) + .is_ok() + ); + + let error = + futures::executor::block_on(authority.sign_raw(&cx, &session, request([0xff; 32]))) + .expect_err("unknown legacy account is rejected"); + assert!(matches!(error, AuthorityError::Unavailable { .. })); + } + #[test] fn sign_raw_requires_active_session() { let (services, authority) = signing_runtime(); @@ -1035,20 +1323,40 @@ mod tests { } #[test] - fn deferred_operations_return_unavailable() { + fn direct_allocation_handles_empty_and_optional_resource_batches() { let (_services, authority) = signing_runtime(); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation"); let session = authority.current_session().expect("connected"); let cx = CallContext::new(); - let alloc = futures::executor::block_on(authority.allocate_resources( + let empty = futures::executor::block_on(authority.allocate_resources( &cx, &session, "myapp.dot".to_string(), v01::HostRequestResourceAllocationRequest { resources: vec![] }, )) - .expect_err("allocation deferred"); - assert!(matches!(alloc, AuthorityError::Unavailable { .. })); + .expect("empty allocation succeeds"); + assert!(empty.outcomes.is_empty()); + + let optional = futures::executor::block_on(authority.allocate_resources( + &cx, + &session, + "myapp.dot".to_string(), + v01::HostRequestResourceAllocationRequest { + resources: vec![ + v01::AllocatableResource::SmartContractAllowance(0), + v01::AllocatableResource::AutoSigning, + ], + }, + )) + .expect("optional allocation succeeds"); + assert_eq!( + optional.outcomes, + vec![ + v01::AllocationOutcome::NotAvailable, + v01::AllocationOutcome::NotAvailable, + ] + ); } } diff --git a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs index 9da344b0..c8ba2611 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs @@ -16,11 +16,28 @@ pub(crate) trait LocalActivation: Send + Sync { /// Activate a local session from raw BIP-39 entropy, deriving the root /// public key and marking the session connected. async fn activate_local_session(&self, secret: Vec) -> Result<(), AuthorityError>; + + /// Activate a local session and attach known identity metadata from the + /// host's signer/account store. + async fn activate_local_session_with_identity( + &self, + secret: Vec, + lite_username: Option, + ) -> Result<(), AuthorityError>; } #[async_trait::async_trait] impl LocalActivation for SigningHost { async fn activate_local_session(&self, secret: Vec) -> Result<(), AuthorityError> { + self.activate_local_session_with_identity(secret, None) + .await + } + + async fn activate_local_session_with_identity( + &self, + secret: Vec, + lite_username: Option, + ) -> Result<(), AuthorityError> { let secret = Zeroizing::new(secret); let root = derive_root_keypair_from_entropy(&secret).map_err(product_authority_error)?; let public_key = root.public.to_bytes(); @@ -33,7 +50,7 @@ impl LocalActivation for SigningHost { sso: None, root_entropy_source: None, identity_account_id: None, - lite_username: None, + lite_username, full_username: None, }; self.session_state.set_session(session.clone()); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs b/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs index cf2bb939..a5976955 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs @@ -416,6 +416,18 @@ mod tests { ); } + #[test] + fn context_matches_ios_host_vector() { + let context = ProductProofContext { + product_id: "voting.dot".to_string(), + suffix: vec![0, 1, 2, 3], + }; + assert_eq!( + hex::encode(context_bytes(&context)), + "03fba4e4f9ce1b2eb228e79b8aabef71213cfc53bec6dcae9d24a075a2d5a89e" + ); + } + #[test] fn collection_selects_corresponding_person_key() { assert_eq!(key_for_collection(&FULL_PERSON_COLLECTION), PersonKey::Full); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs new file mode 100644 index 00000000..fd11583f --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -0,0 +1,1351 @@ +//! Signing-host responder half of the host-spec §B pairing protocol. +//! +//! Answers a pairing host's handshake proposal (QR/deeplink) with an +//! encrypted `Success` statement, then serves the encrypted SSO session: +//! acks every inbound request statement, dispatches the batched +//! [`v1::RemoteMessage`] requests onto the local signing authority, and posts +//! the response statements the pairing host is waiting for. Runs until the +//! peer sends `Disconnected`, the local session ends, or the transport fails. +//! +//! Sensitive operations consult [`truapi_platform::UserConfirmation`], the +//! same seam browser hosts use for their confirmation modals; a headless host +//! implements it with its approval policy. + +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Instant; + +use parity_scale_codec::Encode; +use tracing::{debug, info, instrument, trace, warn}; +use truapi::{CallContext, latest as api}; +use truapi_platform::{ + CreateTransactionReview, SignPayloadReview, SignRawReview, UserConfirmationReview, +}; + +use super::SigningHost; +use crate::host_logic::entropy::root_entropy_source; +#[cfg(not(target_arch = "wasm32"))] +use crate::host_logic::product_account::ProductAccountError; +use crate::host_logic::product_account::{ + derive_root_keypair_from_entropy, derive_sr25519_hard_path, product_public_key_to_address, +}; +use crate::host_logic::session::SsoSessionInfo; +use crate::host_logic::sso::messages::{ + self, CreateTransactionPayload, IncomingSsoRequest, OnExistingAllowancePolicy, RemoteMessage, + RemoteMessageData, ResourceAllocationResponse, RingVrfAliasResponse, RingVrfError, + RingVrfProofResponse, SignRawLegacyResponse, SigningPayloadResponseData, SigningRequest, + SigningResponse, SsoAllocatableResource, SsoAllocatedResource, SsoAllocationOutcome, + StatementStoreProductSignResponse, build_outgoing_request_statement, + build_signed_session_response_statement, decode_incoming_sso_request, v1, +}; +use crate::host_logic::sso::pairing::{ + ResponderIdentity, VersionedHandshakeProposal, bootstrap_topic, decode_pairing_deeplink, + derive_p256_keypair_from_entropy, encrypt_v2_handshake_response, + establish_responder_session_info, v2, +}; +use crate::host_logic::statement_store::{ + build_signed_statement, parse_new_statements_result, + validate_unsigned_statement_signing_payload, +}; +use crate::runtime::authority::{ + AccountAliasAuthorityRequest, CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, + ProductAuthority, SignPayloadAuthorityRequest, SignRawAuthorityRequest, +}; +use crate::runtime::services::RuntimeServices; +use crate::runtime::sso_remote::fresh_statement_expiry; +use crate::runtime::statement_store_rpc; + +/// Domain label for the responder's persistent P-256 encryption key. +const SSO_ENCRYPTION_KEY_LABEL: &[u8] = b"sso-encryption"; +/// Domain label for the identity chat key shared in the handshake payload. +const CHAT_KEY_LABEL: &[u8] = b"chat-encryption"; +/// Leave the product runtime one minute to receive and process the SSO response +/// before its 300-second remote-authority deadline expires. +#[cfg(not(target_arch = "wasm32"))] +const BULLETIN_AUTHORIZATION_WAIT: std::time::Duration = std::time::Duration::from_secs(240); + +/// Terminal outcome of one responder serve loop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResponderExit { + /// The pairing host announced `Disconnected`. + PeerDisconnected, + /// The statement subscription ended without a disconnect message. + SubscriptionEnded, +} + +/// Answer `deeplink` and serve the resulting SSO session until it ends. +#[instrument(skip_all, fields(runtime.method = "sso_responder.respond_to_pairing"))] +pub(crate) async fn respond_to_pairing( + services: Arc, + signing_host: Arc, + deeplink: &str, +) -> Result { + let VersionedHandshakeProposal::V2(proposal) = decode_pairing_deeplink(deeplink)?; + let entropy = signing_host + .root_entropy() + .map_err(|err| format!("signing host has no active local session: {err}"))?; + // Product accounts derive from the canonical root key. The SSO statement + // identity keeps its dedicated hard-derived key. + let root = derive_root_keypair_from_entropy(&entropy) + .map_err(|err| format!("root account derivation failed: {err}"))?; + let statement = derive_sr25519_hard_path(&entropy, &["wallet", "sso"]) + .map_err(|err| format!("//wallet//sso derivation failed: {err}"))?; + let (encryption_secret_key, encryption_public_key) = + derive_p256_keypair_from_entropy(&entropy, SSO_ENCRYPTION_KEY_LABEL) + .map_err(|err| format!("responder P-256 derivation failed: {err}"))?; + let (identity_chat_private_key, _) = derive_p256_keypair_from_entropy(&entropy, CHAT_KEY_LABEL) + .map_err(|err| format!("responder chat-key derivation failed: {err}"))?; + let identity = ResponderIdentity { + statement_secret: statement.secret.to_bytes(), + statement_public_key: statement.public.to_bytes(), + encryption_secret_key, + encryption_public_key, + }; + let session = establish_responder_session_info( + &identity, + proposal.device.statement_account_id, + proposal.device.encryption_public_key, + )?; + + let success = v2::EncryptedResponse::Success(Box::new(v2::Success { + identity_account_id: identity.statement_public_key, + root_account_id: root.public.to_bytes(), + identity_chat_private_key, + sso_enc_pub_key: identity.encryption_public_key, + device_enc_pub_key: identity.encryption_public_key, + root_entropy_source: root_entropy_source(&entropy), + })); + let handshake = encrypt_v2_handshake_response(proposal.device.encryption_public_key, &success)?; + let topic = bootstrap_topic( + proposal.device.statement_account_id, + proposal.device.encryption_public_key, + ); + let statement = build_signed_statement( + &session, + topic, + topic, + handshake.encode(), + fresh_statement_expiry(), + )?; + services + .statement_store + .submit(statement, "sso-responder handshake") + .await?; + info!("answered pairing handshake, serving SSO session"); + + serve_session(services, signing_host, session).await +} + +/// Serve inbound session statements until the session ends. +#[instrument(skip_all, fields(runtime.method = "sso_responder.serve_session"))] +async fn serve_session( + services: Arc, + signing_host: Arc, + session: SsoSessionInfo, +) -> Result { + let rpc_client = services + .statement_store + .client("sso-responder session") + .await?; + let mut subscription = + statement_store_rpc::subscribe_match_all(&rpc_client, &[session.session_id_peer]) + .await + .map_err(|err| format!("sso-responder subscribe failed: {err}"))?; + let mut served_request_ids = HashSet::new(); + + while let Some(item) = subscription.next().await { + let value = item.map_err(|err| format!("sso-responder subscription failed: {err}"))?; + let page = parse_new_statements_result("sso-responder".to_string(), &value) + .map_err(|err| err.to_string())?; + for statement in page.statements { + let incoming = match decode_incoming_sso_request(&session, &statement) { + Ok(Some(incoming)) => incoming, + Ok(None) => continue, + Err(reason) => { + let prefix = hex::encode(&statement[..statement.len().min(16)]); + warn!( + %reason, + statement_bytes = statement.len(), + statement_prefix = %prefix, + "ignoring undecodable SSO session statement" + ); + continue; + } + }; + for message in &incoming.messages { + let cli_summary = format!( + "Incoming SSO request · {}\nstatement_request_id={}\nremote_message_id={}", + remote_message_name(&message.data), + incoming.request_id, + message.message_id + ); + tracing::event!( + target: "truapi_server::sso_transcript", + tracing::Level::INFO, + cli_summary = cli_summary.as_str(), + cli_event = "request_received", + request = remote_message_name(&message.data), + statement_request_id = %incoming.request_id, + remote_message_id = %message.message_id, + remote_message = remote_message_name(&message.data), + ); + debug!( + statement_request_id = %incoming.request_id, + remote_message_id = %message.message_id, + remote_message = ?message.data, + "decoded SSO request" + ); + trace!( + statement_request_id = %incoming.request_id, + remote_message_id = %message.message_id, + remote_message = ?message.data, + "received SSO message" + ); + } + if !served_request_ids.insert(incoming.request_id.clone()) { + continue; + } + if let Some(exit) = serve_request(&services, &signing_host, &session, incoming).await? { + return Ok(exit); + } + } + } + Ok(ResponderExit::SubscriptionEnded) +} + +/// Ack one inbound request statement and answer its batched messages. +async fn serve_request( + services: &Arc, + signing_host: &Arc, + session: &SsoSessionInfo, + incoming: IncomingSsoRequest, +) -> Result, String> { + let ack = build_signed_session_response_statement( + session, + incoming.request_id.clone(), + 0, + fresh_statement_expiry(), + )?; + services + .statement_store + .submit_sso(ack, "sso-responder ack") + .await?; + + for message in incoming.messages { + let RemoteMessageData::V1(request) = message.data; + if matches!(request, v1::RemoteMessage::Disconnected) { + info!("pairing host disconnected the SSO session"); + return Ok(Some(ResponderExit::PeerDisconnected)); + } + let request_name = remote_v1_message_name(&request); + let responding_to = message.message_id.clone(); + let started = Instant::now(); + let Some(answer) = + answer_remote_message(services, signing_host, message.message_id, request).await + else { + continue; + }; + let response = answer.response; + let response_message_id = response.message_id.clone(); + let response_result = answer + .response_result + .unwrap_or_else(|| remote_response_result(&response.data)); + debug!( + statement_request_id = %incoming.request_id, + responding_to = %responding_to, + %response_message_id, + response = remote_message_name(&response.data), + outcome = response_result.outcome, + reason = response_result.reason.as_deref().unwrap_or_default(), + "prepared SSO response" + ); + trace!( + statement_request_id = %incoming.request_id, + responding_to = %responding_to, + %response_message_id, + response = ?response.data, + "prepared SSO response payload" + ); + let statement_request_id = format!("resp:{}", response.message_id); + let statement = build_outgoing_request_statement( + session, + statement_request_id, + vec![response], + fresh_statement_expiry(), + )?; + let publish_result = services + .statement_store + .submit_sso(statement, "sso-responder response") + .await; + let elapsed_ms = started.elapsed().as_millis(); + match publish_result { + Ok(()) => { + let cli_summary = response_cli_summary( + "SSO response sent", + request_name, + &incoming.request_id, + &responding_to, + &response_message_id, + &response_result, + elapsed_ms, + ); + tracing::event!( + target: "truapi_server::sso_transcript", + tracing::Level::INFO, + cli_summary = cli_summary.as_str(), + cli_event = "response_sent", + request = request_name, + statement_request_id = %incoming.request_id, + responding_to = %responding_to, + %response_message_id, + outcome = response_result.outcome, + reason = response_result.reason.as_deref().unwrap_or_default(), + elapsed_ms = elapsed_ms as u64, + ); + } + Err(reason) => { + let failure = ResponseResult { + outcome: "publish_failed", + reason: Some(reason.clone()), + }; + let cli_summary = response_cli_summary( + "SSO response failed", + request_name, + &incoming.request_id, + &responding_to, + &response_message_id, + &failure, + elapsed_ms, + ); + tracing::event!( + target: "truapi_server::sso_transcript", + tracing::Level::WARN, + cli_summary = cli_summary.as_str(), + cli_event = "response_failed", + request = request_name, + statement_request_id = %incoming.request_id, + responding_to = %responding_to, + %response_message_id, + outcome = failure.outcome, + reason = %reason, + elapsed_ms = elapsed_ms as u64, + ); + return Err(reason); + } + } + } + Ok(None) +} + +fn remote_message_name(message: &RemoteMessageData) -> &'static str { + match message { + RemoteMessageData::V1(message) => remote_v1_message_name(message), + } +} + +fn remote_v1_message_name(message: &v1::RemoteMessage) -> &'static str { + match message { + v1::RemoteMessage::Disconnected => "disconnected", + v1::RemoteMessage::SignRequest(_) => "sign_request", + v1::RemoteMessage::SignResponse(_) => "sign_response", + v1::RemoteMessage::RingVrfAliasRequest(_) => "get_account_alias", + v1::RemoteMessage::RingVrfAliasResponse(_) => "get_account_alias_response", + v1::RemoteMessage::ResourceAllocationRequest(_) => "resource_allocation", + v1::RemoteMessage::ResourceAllocationResponse(_) => "resource_allocation_response", + v1::RemoteMessage::CreateTransactionRequest(_) => "create_transaction", + v1::RemoteMessage::CreateTransactionResponse(_) => "create_transaction_response", + v1::RemoteMessage::CreateTransactionLegacyRequest(_) => "create_transaction_legacy", + v1::RemoteMessage::SignRawLegacyRequest(_) => "sign_raw_legacy", + v1::RemoteMessage::SignRawLegacyResponse(_) => "sign_raw_legacy_response", + v1::RemoteMessage::RingVrfProofRequest(_) => "create_account_proof", + v1::RemoteMessage::RingVrfProofResponse(_) => "create_account_proof_response", + v1::RemoteMessage::StatementStoreProductSignRequest(_) => "statement_store_product_sign", + v1::RemoteMessage::StatementStoreProductSignResponse(_) => { + "statement_store_product_sign_response" + } + } +} + +struct ResponseResult { + outcome: &'static str, + reason: Option, +} + +struct AnsweredRemoteMessage { + response: RemoteMessage, + response_result: Option, +} + +struct ResourceAllocationAnswer { + payload: Result, String>, + item_failures: Vec, +} + +fn remote_response_result(message: &RemoteMessageData) -> ResponseResult { + let RemoteMessageData::V1(message) = message; + if let v1::RemoteMessage::ResourceAllocationResponse(response) = message { + return resource_allocation_payload_result(&response.payload, &[]); + } + let error = match message { + v1::RemoteMessage::SignResponse(response) => response.payload.as_ref().err().cloned(), + v1::RemoteMessage::RingVrfAliasResponse(response) => { + response.payload.as_ref().err().map(ring_vrf_error_reason) + } + v1::RemoteMessage::RingVrfProofResponse(response) => { + response.payload.as_ref().err().map(ring_vrf_error_reason) + } + v1::RemoteMessage::ResourceAllocationResponse(_) => unreachable!(), + v1::RemoteMessage::CreateTransactionResponse(response) => { + response.signed_transaction.as_ref().err().cloned() + } + v1::RemoteMessage::SignRawLegacyResponse(response) => { + response.signature.as_ref().err().cloned() + } + v1::RemoteMessage::StatementStoreProductSignResponse(response) => { + response.signature.as_ref().err().cloned() + } + _ => None, + }; + ResponseResult { + outcome: if error.is_some() { "error" } else { "ok" }, + reason: error, + } +} + +fn resource_allocation_payload_result( + payload: &Result, String>, + item_failures: &[String], +) -> ResponseResult { + let outcomes = match payload { + Ok(outcomes) => outcomes, + Err(reason) => { + return ResponseResult { + outcome: "error", + reason: Some(reason.clone()), + }; + } + }; + if outcomes.is_empty() { + return ResponseResult { + outcome: "ok", + reason: None, + }; + } + + let allocated = outcomes + .iter() + .filter(|outcome| matches!(outcome, SsoAllocationOutcome::Allocated(_))) + .count(); + let rejected = outcomes + .iter() + .filter(|outcome| matches!(outcome, SsoAllocationOutcome::Rejected)) + .count(); + let unavailable = outcomes + .iter() + .filter(|outcome| matches!(outcome, SsoAllocationOutcome::NotAvailable)) + .count(); + let total = outcomes.len(); + + if allocated == total { + return ResponseResult { + outcome: "ok", + reason: None, + }; + } + if allocated > 0 { + let mut reason = format!("{allocated} of {total} requested resources allocated"); + if rejected > 0 { + reason.push_str(&format!("; {rejected} rejected")); + } + if unavailable > 0 { + reason.push_str(&format!("; {unavailable} unavailable")); + } + return allocation_result_with_failures( + ResponseResult { + outcome: "partial", + reason: Some(reason), + }, + item_failures, + ); + } + if rejected > 0 { + let reason = if rejected == total { + if total == 1 { + "Requested resource was rejected".to_string() + } else { + format!("All {total} requested resources were rejected") + } + } else { + format!("No resources allocated; {rejected} rejected; {unavailable} unavailable") + }; + return allocation_result_with_failures( + ResponseResult { + outcome: "rejected", + reason: Some(reason), + }, + item_failures, + ); + } + + ResponseResult { + outcome: "not_available", + reason: Some(if total == 1 { + "Requested resource is not available".to_string() + } else { + format!("None of the {total} requested resources are available") + }), + } +} + +fn allocation_result_with_failures( + mut result: ResponseResult, + item_failures: &[String], +) -> ResponseResult { + if !item_failures.is_empty() { + let details = item_failures.join("; ").replace(['\r', '\n'], " "); + result.reason = Some(match result.reason { + Some(summary) => format!("{summary}: {details}"), + None => details, + }); + } + result +} + +fn ring_vrf_error_reason(error: &RingVrfError) -> String { + match error { + RingVrfError::RingNotFound => "RingNotFound".to_string(), + RingVrfError::NotMember => "NotMember".to_string(), + RingVrfError::Rejected => "Rejected".to_string(), + RingVrfError::Unknown { reason } => format!("Unknown: {reason}"), + } +} + +fn response_cli_summary( + heading: &str, + request_name: &str, + statement_request_id: &str, + responding_to: &str, + response_message_id: &str, + result: &ResponseResult, + elapsed_ms: u128, +) -> String { + let mut summary = format!( + "{heading} · {request_name} · {}\nstatement_request_id={statement_request_id}\nresponding_to={responding_to}\nresponse_message_id={response_message_id}\nelapsed_ms={elapsed_ms}", + result.outcome + ); + if let Some(reason) = &result.reason { + summary.push_str("\nreason="); + summary.push_str(&reason.replace(['\r', '\n'], " ")); + } + summary +} + +/// Answer one application-level request message; `None` for message kinds +/// that take no response (responses echoed by the peer, unknown variants). +async fn answer_remote_message( + services: &Arc, + signing_host: &Arc, + message_id: String, + request: v1::RemoteMessage, +) -> Option { + let response_id = format!("{message_id}:response"); + let mut response_result = None; + let data = match request { + v1::RemoteMessage::SignRequest(request) => v1::RemoteMessage::SignResponse( + sign_response(services, signing_host, &message_id, *request).await, + ), + v1::RemoteMessage::RingVrfAliasRequest(request) => { + let payload = account_alias_response(signing_host, request).await; + v1::RemoteMessage::RingVrfAliasResponse(RingVrfAliasResponse { + responding_to: message_id, + payload, + }) + } + v1::RemoteMessage::RingVrfProofRequest(request) => { + let payload = create_proof_response(signing_host, request).await; + v1::RemoteMessage::RingVrfProofResponse(RingVrfProofResponse { + responding_to: message_id, + payload, + }) + } + v1::RemoteMessage::ResourceAllocationRequest(request) => { + let answer = resource_allocation_response(services, signing_host, request).await; + if let Err(reason) = &answer.payload { + warn!(%reason, "resource allocation request failed"); + } + response_result = Some(resource_allocation_payload_result( + &answer.payload, + &answer.item_failures, + )); + v1::RemoteMessage::ResourceAllocationResponse(ResourceAllocationResponse { + responding_to: message_id, + payload: answer.payload, + }) + } + v1::RemoteMessage::CreateTransactionRequest(request) => { + let CreateTransactionPayload::V1(payload) = request.payload; + let signed_transaction = create_transaction_response( + services, + signing_host, + CreateTransactionReview::Product(payload.clone()), + CreateTransactionAuthorityRequest::Product(payload), + ) + .await; + v1::RemoteMessage::CreateTransactionResponse(messages::CreateTransactionResponse { + responding_to: message_id, + signed_transaction, + }) + } + v1::RemoteMessage::CreateTransactionLegacyRequest(_) => { + v1::RemoteMessage::CreateTransactionResponse(messages::CreateTransactionResponse { + responding_to: message_id, + signed_transaction: Err( + "signing host: legacy-account transactions are not supported".to_string(), + ), + }) + } + v1::RemoteMessage::SignRawLegacyRequest(request) => { + let signature = sign_raw_legacy_response(services, signing_host, request).await; + v1::RemoteMessage::SignRawLegacyResponse(SignRawLegacyResponse { + responding_to: message_id, + signature, + }) + } + v1::RemoteMessage::StatementStoreProductSignRequest(request) => { + let signature = + statement_store_product_sign_response(services, signing_host, request).await; + v1::RemoteMessage::StatementStoreProductSignResponse( + StatementStoreProductSignResponse { + responding_to: message_id, + signature, + }, + ) + } + v1::RemoteMessage::Disconnected + | v1::RemoteMessage::SignResponse(_) + | v1::RemoteMessage::RingVrfAliasResponse(_) + | v1::RemoteMessage::RingVrfProofResponse(_) + | v1::RemoteMessage::ResourceAllocationResponse(_) + | v1::RemoteMessage::CreateTransactionResponse(_) + | v1::RemoteMessage::SignRawLegacyResponse(_) + | v1::RemoteMessage::StatementStoreProductSignResponse(_) => return None, + }; + Some(AnsweredRemoteMessage { + response: RemoteMessage { + message_id: response_id, + data: RemoteMessageData::V1(data), + }, + response_result, + }) +} + +async fn resource_allocation_response( + services: &Arc, + signing_host: &Arc, + request: messages::ResourceAllocationRequest, +) -> ResourceAllocationAnswer { + if let Err(reason) = confirm( + services, + UserConfirmationReview::ResourceAllocation(api::HostRequestResourceAllocationRequest { + resources: request + .resources + .iter() + .map(public_allocatable_resource) + .collect(), + }), + ) + .await + { + return ResourceAllocationAnswer { + payload: Err(reason), + item_failures: Vec::new(), + }; + } + + let mut outcomes = Vec::with_capacity(request.resources.len()); + let mut item_failures = Vec::new(); + for resource in request.resources { + let outcome = match resource { + SsoAllocatableResource::StatementStoreAllowance => allocate_statement_store_allowance( + services, + signing_host, + &request.calling_product_id, + ) + .await + .map(|slot_account_key| { + SsoAllocationOutcome::Allocated(SsoAllocatedResource::StatementStoreAllowance { + slot_account_key, + }) + }), + SsoAllocatableResource::BulletinAllowance => allocate_bulletin_allowance( + services, + signing_host, + &request.calling_product_id, + request.on_existing, + ) + .await + .map(|slot_account_key| { + SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { + slot_account_key, + }) + }), + SsoAllocatableResource::SmartContractAllowance(_) + | SsoAllocatableResource::AutoSigning => Ok(SsoAllocationOutcome::NotAvailable), + }; + match outcome { + Ok(outcome) => outcomes.push(outcome), + Err(reason) => { + warn!(%reason, "resource allocation item failed"); + item_failures.push(reason); + outcomes.push(SsoAllocationOutcome::Rejected); + } + } + } + ResourceAllocationAnswer { + payload: Ok(outcomes), + item_failures, + } +} + +fn public_allocatable_resource(resource: &SsoAllocatableResource) -> api::AllocatableResource { + match resource { + SsoAllocatableResource::StatementStoreAllowance => { + api::AllocatableResource::StatementStoreAllowance + } + SsoAllocatableResource::BulletinAllowance => api::AllocatableResource::BulletinAllowance, + SsoAllocatableResource::SmartContractAllowance(index) => { + api::AllocatableResource::SmartContractAllowance(*index) + } + SsoAllocatableResource::AutoSigning => api::AllocatableResource::AutoSigning, + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) async fn allocate_statement_store_allowance( + services: &Arc, + signing_host: &SigningHost, + product_id: &str, +) -> Result, String> { + use crate::runtime::statement_allowance::{ + self, fetch_chain_state, fetch_metadata, find_including_ring, register_statement_account, + }; + + let entropy = signing_host.root_entropy().map_err(|err| err.reason())?; + let allowance = + derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id]) + .map_err(product_account_error)?; + let target = allowance.public.to_bytes(); + let bandersnatch = statement_allowance::bandersnatch_entropy(&entropy); + let rpc = statement_allowance::rpc::RpcClient::new( + services + .statement_store + .client("statement-store allowance") + .await?, + ); + let metadata = fetch_metadata(&rpc).await?; + let chain_state = fetch_chain_state(&rpc).await?; + let current = statement_allowance::ring::read_current_ring_index(&rpc).await?; + let ring = find_including_ring(&rpc, &metadata, bandersnatch, current) + .await? + .ok_or_else(|| { + "signing account is not a LitePeople ring member; cannot grant statement-store allowance" + .to_string() + })?; + let period = statement_allowance::slot::current_period(current_unix_secs()?); + let outcome = register_statement_account( + &rpc, + &metadata, + &chain_state, + bandersnatch, + &target, + period, + &ring, + ) + .await?; + match outcome { + statement_allowance::RegistrationOutcome::Registered { + block_hash, + seq, + ring_index, + } => { + info!( + %product_id, + %block_hash, + seq, + ring_index, + "registered statement-store allowance" + ); + } + statement_allowance::RegistrationOutcome::AlreadyAllocated { seq } => { + info!( + %product_id, + seq, + "statement-store allowance already allocated" + ); + } + } + Ok(allowance.secret.to_bytes().to_vec()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) async fn allocate_bulletin_allowance( + services: &Arc, + signing_host: &SigningHost, + product_id: &str, + policy: OnExistingAllowancePolicy, +) -> Result, String> { + use crate::runtime::statement_allowance::{ + self, claim_long_term_storage, fetch_bulletin_allowance, fetch_chain_state, fetch_metadata, + find_including_ring, wait_bulletin_authorization, + }; + + let entropy = signing_host.root_entropy().map_err(|err| err.reason())?; + let allowance = derive_sr25519_hard_path(&entropy, &["allowance", "bulletin", product_id]) + .map_err(product_account_error)?; + let target = allowance.public.to_bytes(); + + let bulletin_rpc = statement_allowance::rpc::RpcClient::new( + services + .bulletin + .client("bulletin allowance") + .await + .map_err(|err| err.reason())?, + ); + let current_allowance = fetch_bulletin_allowance(&bulletin_rpc, &target).await?; + if matches!(policy, OnExistingAllowancePolicy::Ignore) + && current_allowance.is_some_and(|allowance| allowance.available()) + { + return Ok(allowance.secret.to_bytes().to_vec()); + } + + let people_rpc = statement_allowance::rpc::RpcClient::new( + services + .statement_store + .client("bulletin allowance claim") + .await?, + ); + let metadata = fetch_metadata(&people_rpc).await?; + let chain_state = fetch_chain_state(&people_rpc).await?; + let bandersnatch = statement_allowance::bandersnatch_entropy(&entropy); + let current = statement_allowance::ring::read_current_ring_index(&people_rpc).await?; + let ring = find_including_ring(&people_rpc, &metadata, bandersnatch, current) + .await? + .ok_or_else(|| { + "signing account is not a LitePeople ring member; cannot grant Bulletin allowance" + .to_string() + })?; + let period_duration = statement_allowance::slot::long_term_storage_period_duration(&metadata)?; + let period = statement_allowance::slot::current_long_term_storage_period( + current_unix_secs()?, + period_duration, + )?; + let outcome = claim_long_term_storage( + &people_rpc, + &metadata, + &chain_state, + bandersnatch, + &target, + period, + &ring, + ) + .await?; + let statement_allowance::LongTermStorageOutcome::Claimed { + block_hash, + counter, + ring_index, + } = outcome; + info!( + %product_id, + %block_hash, + counter, + ring_index, + "claimed Bulletin long-term storage allowance" + ); + + let authorization = wait_bulletin_authorization( + &bulletin_rpc, + &target, + current_allowance, + BULLETIN_AUTHORIZATION_WAIT, + ) + .await?; + info!( + %product_id, + remained_size = authorization.remained_size, + remained_transactions = authorization.remained_transactions, + "Bulletin authorization visible" + ); + Ok(allowance.secret.to_bytes().to_vec()) +} + +#[cfg(target_arch = "wasm32")] +pub(super) async fn allocate_statement_store_allowance( + _services: &Arc, + _signing_host: &SigningHost, + _product_id: &str, +) -> Result, String> { + Err("signing host: statement-store allowance allocation is native-only".to_string()) +} + +#[cfg(target_arch = "wasm32")] +pub(super) async fn allocate_bulletin_allowance( + _services: &Arc, + _signing_host: &SigningHost, + _product_id: &str, + _policy: OnExistingAllowancePolicy, +) -> Result, String> { + Err("signing host: Bulletin allowance allocation is native-only".to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn current_unix_secs() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| "system clock before UNIX epoch".to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn product_account_error(err: ProductAccountError) -> String { + err.to_string() +} + +/// Confirm and serve a payload or raw signing request. +async fn sign_response( + services: &Arc, + signing_host: &Arc, + message_id: &str, + request: SigningRequest, +) -> SigningResponse { + let payload = serve_sign_request(services, signing_host, request).await; + if let Err(reason) = &payload { + warn!(%reason, "sign request failed"); + } + SigningResponse { + responding_to: message_id.to_string(), + payload, + } +} + +async fn serve_sign_request( + services: &Arc, + signing_host: &Arc, + request: SigningRequest, +) -> Result { + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + let cx = CallContext::new(); + let response = match request { + SigningRequest::Payload(request) => { + let request: api::HostSignPayloadRequest = (*request).into(); + confirm( + services, + UserConfirmationReview::SignPayload(SignPayloadReview::Product(request.clone())), + ) + .await?; + signing_host + .sign_payload(&cx, &session, SignPayloadAuthorityRequest::Product(request)) + .await + } + SigningRequest::Raw(request) => { + let request: api::HostSignRawRequest = request.into(); + confirm( + services, + UserConfirmationReview::SignRaw(SignRawReview::Product(request.clone())), + ) + .await?; + signing_host + .sign_raw(&cx, &session, SignRawAuthorityRequest::Product(request)) + .await + } + } + .map_err(|err| err.reason())?; + Ok(SigningPayloadResponseData { + signature: response.signature, + signed_transaction: response.signed_transaction, + }) +} + +async fn sign_raw_legacy_response( + services: &Arc, + signing_host: &Arc, + request: messages::SignRawLegacyRequest, +) -> Result, String> { + let public_request = api::HostSignRawWithLegacyAccountRequest { + signer: product_public_key_to_address(request.account), + payload: request.data.into(), + }; + confirm( + services, + UserConfirmationReview::SignRaw(SignRawReview::LegacyAccount(public_request.clone())), + ) + .await?; + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + signing_host + .sign_raw( + &CallContext::new(), + &session, + SignRawAuthorityRequest::LegacyAccount { + account: request.account, + request: public_request, + }, + ) + .await + .map(|response| response.signature) + .map_err(|err| err.reason()) +} + +async fn statement_store_product_sign_response( + services: &Arc, + signing_host: &Arc, + request: messages::StatementStoreProductSignRequest, +) -> Result, String> { + validate_unsigned_statement_signing_payload(&request.payload)?; + confirm( + services, + UserConfirmationReview::SignRaw(SignRawReview::Product(api::HostSignRawRequest { + account: request.product_account_id.clone(), + payload: api::RawPayload::Bytes { + bytes: request.payload.clone(), + }, + })), + ) + .await?; + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + let cx = CallContext::new(); + signing_host + .sign_statement_store_product_payload( + &cx, + &session, + request.product_account_id, + request.payload, + ) + .await + .map(|signature| signature.to_vec()) + .map_err(|err| err.reason()) +} + +/// Confirm and serve a transaction-creation request. +async fn create_transaction_response( + services: &Arc, + signing_host: &Arc, + review: CreateTransactionReview, + request: CreateTransactionAuthorityRequest, +) -> Result, String> { + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + confirm(services, UserConfirmationReview::CreateTransaction(review)).await?; + let cx = CallContext::new(); + signing_host + .create_transaction(&cx, &session, request) + .await + .map(|response| response.transaction) + .map_err(|err| err.reason()) +} + +async fn account_alias_response( + signing_host: &Arc, + request: messages::RingVrfAliasRequest, +) -> Result { + let session = signing_host + .current_session() + .ok_or_else(disconnected_ring_vrf)?; + let cx = CallContext::new(); + signing_host + .account_alias( + &cx, + &session, + AccountAliasAuthorityRequest { + calling_product_id: request.calling_product_id, + context: request.context, + ring_location: request.ring_location, + }, + ) + .await +} + +async fn create_proof_response( + signing_host: &Arc, + request: messages::RingVrfProofRequest, +) -> Result { + let session = signing_host + .current_session() + .ok_or_else(disconnected_ring_vrf)?; + let cx = CallContext::new(); + signing_host + .create_proof( + &cx, + &session, + CreateProofAuthorityRequest { + calling_product_id: request.calling_product_id, + context: request.context, + ring_location: request.ring_location, + message: request.message, + }, + ) + .await +} + +fn disconnected_ring_vrf() -> RingVrfError { + RingVrfError::Unknown { + reason: "signing host session is not active".to_string(), + } +} + +/// Run the platform confirmation seam; rejection and failure both refuse the +/// operation with an opaque reason (host-spec B.7). +async fn confirm( + services: &Arc, + review: UserConfirmationReview, +) -> Result<(), String> { + match services.platform.confirm_user_action(review).await { + Ok(true) => Ok(()), + Ok(false) => Err("Rejected".to_string()), + Err(err) => Err(format!("confirmation failed: {}", err.reason)), + } +} + +#[cfg(test)] +mod tests { + use super::super::LocalActivation; + use super::*; + use crate::host_logic::statement_store::{StatementField, unsigned_statement_signing_payload}; + use crate::runtime::services::RuntimeServices; + use crate::test_support::{StubPlatform, test_spawner}; + use std::sync::Arc; + use truapi_platform::{HostInfo, Platform, PlatformInfo, SigningHostConfig}; + + const ENTROPY: [u8; 16] = [0xab; 16]; + + fn product_account(product_id: &str) -> api::ProductAccountId { + api::ProductAccountId { + dot_ns_identifier: product_id.to_string(), + derivation_index: 0, + } + } + + fn signing_fixture(platform: Arc) -> (Arc, Arc) { + let platform: Arc = platform; + let config = SigningHostConfig::new( + HostInfo { + name: "Polkadot Mobile".to_string(), + icon: None, + version: None, + }, + PlatformInfo::default(), + [0; 32], + [0xbb; 32], + ) + .expect("signing host config is valid"); + let services = RuntimeServices::new( + platform.clone(), + config.people_chain_genesis_hash, + config.bulletin_chain_genesis_hash, + test_spawner(), + ); + let signing_host = SigningHost::new(services.clone()); + futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + (services, signing_host) + } + + fn statement_sign_request(payload: Vec) -> v1::RemoteMessage { + v1::RemoteMessage::StatementStoreProductSignRequest( + messages::StatementStoreProductSignRequest { + product_account_id: product_account("myapp.dot"), + payload, + }, + ) + } + + fn statement_payload() -> Vec { + unsigned_statement_signing_payload(vec![ + StatementField::Expiry(42), + StatementField::Topic1([1; 32]), + StatementField::Data(vec![0xde, 0xad]), + ]) + .expect("valid statement payload") + } + + fn response_payload(answer: AnsweredRemoteMessage) -> v1::RemoteMessage { + let RemoteMessageData::V1(data) = answer.response.data; + data + } + + #[test] + fn statement_store_product_sign_rejects_non_statement_payload() { + let (services, signing_host) = signing_fixture(Arc::new(StubPlatform { + sign_raw_confirmed: true, + ..StubPlatform::default() + })); + + let response = futures::executor::block_on(answer_remote_message( + &services, + &signing_host, + "request-1".to_string(), + statement_sign_request(vec![0, 0, 1, 2, 3]), + )) + .expect("response is emitted"); + + let v1::RemoteMessage::StatementStoreProductSignResponse(response) = + response_payload(response) + else { + panic!("expected statement sign response"); + }; + assert_eq!(response.responding_to, "request-1"); + assert!( + response + .signature + .unwrap_err() + .contains("invalid statement signing payload") + ); + } + + #[test] + fn statement_store_product_sign_requires_confirmation() { + let (services, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); + + let response = futures::executor::block_on(answer_remote_message( + &services, + &signing_host, + "request-1".to_string(), + statement_sign_request(statement_payload()), + )) + .expect("response is emitted"); + + let v1::RemoteMessage::StatementStoreProductSignResponse(response) = + response_payload(response) + else { + panic!("expected statement sign response"); + }; + assert_eq!(response.signature.unwrap_err(), "Rejected"); + } + + #[test] + fn account_alias_requires_confirmation_for_cross_product_request() { + let (services, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); + + let response = futures::executor::block_on(answer_remote_message( + &services, + &signing_host, + "alias-1".to_string(), + v1::RemoteMessage::RingVrfAliasRequest(messages::RingVrfAliasRequest { + calling_product_id: "myapp.dot".to_string(), + context: api::ProductProofContext { + product_id: "other.dot".to_string(), + suffix: vec![], + }, + ring_location: api::RingLocation { + chain_id: [0; 32], + junctions: vec![], + }, + }), + )) + .expect("response is emitted"); + + let v1::RemoteMessage::RingVrfAliasResponse(response) = response_payload(response) else { + panic!("expected alias response"); + }; + assert_eq!(response.payload.unwrap_err(), RingVrfError::Rejected); + } + + #[test] + fn response_summary_reports_protocol_errors_without_multiline_output() { + let response = RemoteMessageData::V1(v1::RemoteMessage::RingVrfAliasResponse( + RingVrfAliasResponse { + responding_to: "alias-1".to_string(), + payload: Err(RingVrfError::Unknown { + reason: "chain RPC\ntimed out".to_string(), + }), + }, + )); + + let result = remote_response_result(&response); + let summary = response_cli_summary( + "SSO response sent", + "get_account_alias", + "statement-1", + "alias-1", + "alias-1:response", + &result, + 42, + ); + + assert_eq!(result.outcome, "error"); + assert_eq!( + summary, + "SSO response sent · get_account_alias · error\n\ + statement_request_id=statement-1\n\ + responding_to=alias-1\n\ + response_message_id=alias-1:response\n\ + elapsed_ms=42\n\ + reason=Unknown: chain RPC timed out" + ); + } + + #[test] + fn resource_allocation_summary_reflects_per_resource_outcomes() { + let result = resource_allocation_payload_result( + &Ok(vec![SsoAllocationOutcome::Rejected]), + &["timed out waiting for Bulletin authorization".to_string()], + ); + assert_eq!(result.outcome, "rejected"); + assert_eq!( + result.reason.as_deref(), + Some("Requested resource was rejected: timed out waiting for Bulletin authorization") + ); + + let result = resource_allocation_payload_result( + &Ok(vec![ + SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { + slot_account_key: vec![1; 64], + }), + SsoAllocationOutcome::Rejected, + SsoAllocationOutcome::NotAvailable, + ]), + &[], + ); + assert_eq!(result.outcome, "partial"); + assert_eq!( + result.reason.as_deref(), + Some("1 of 3 requested resources allocated; 1 rejected; 1 unavailable") + ); + + let result = + resource_allocation_payload_result(&Ok(vec![SsoAllocationOutcome::NotAvailable]), &[]); + assert_eq!(result.outcome, "not_available"); + assert_eq!( + result.reason.as_deref(), + Some("Requested resource is not available") + ); + } + + #[test] + fn resource_allocation_requires_confirmation_before_allocation() { + let (services, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); + + let response = futures::executor::block_on(answer_remote_message( + &services, + &signing_host, + "alloc-1".to_string(), + v1::RemoteMessage::ResourceAllocationRequest(messages::ResourceAllocationRequest { + calling_product_id: "myapp.dot".to_string(), + resources: vec![SsoAllocatableResource::StatementStoreAllowance], + on_existing: messages::OnExistingAllowancePolicy::Ignore, + }), + )) + .expect("response is emitted"); + + let v1::RemoteMessage::ResourceAllocationResponse(response) = response_payload(response) + else { + panic!("expected resource allocation response"); + }; + assert_eq!(response.payload.unwrap_err(), "Rejected"); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs new file mode 100644 index 00000000..e1c845d5 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -0,0 +1,466 @@ +//! On-chain statement-store allowance registration (`set_statement_store_account`). +//! +//! Mirrors how an iOS/web client obtains statement-store allowance from the real +//! People chain: build the `Resources.set_statement_store_account` call, prove +//! LitePeople ring membership with a bandersnatch ring-VRF, and submit the +//! resulting unsigned General (v5) extrinsic. Native only (needs the +//! `verifiable` prover and live chain reads). + +pub mod dynamic; +pub mod extension; +pub mod extrinsic; +pub mod proof; +pub mod ring; +pub mod rpc; +pub mod slot; + +use std::time::{Duration, Instant}; + +use futures::FutureExt; +use parity_scale_codec::Decode; +use serde_json::{Value, json}; +use sp_crypto_hashing::twox_128; +use tracing::{info, warn}; + +use extension::{ChainState, Metadata}; +use ring::RingParams; +use rpc::RpcClient; +use slot::SlotSelection; + +/// Bandersnatch entropy for a bip39 entropy: `blake2b256(bip39_entropy)`. +pub fn bandersnatch_entropy(bip39_entropy: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .hash(bip39_entropy) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +/// Fetch and decode the runtime metadata (`state_getMetadata`). +pub async fn fetch_metadata(rpc: &RpcClient) -> Result { + let value = rpc + .call("state_getMetadata", json!([])) + .await + .map_err(|e| e.to_string())?; + let hex_str = value + .as_str() + .ok_or_else(|| "state_getMetadata returned non-string".to_string())?; + let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(hex_str)) + .map_err(|e| format!("metadata hex: {e}"))?; + // `state_getMetadata` may return either the raw `RuntimeMetadataPrefixed` + // (starts with the `meta` magic) or an OpaqueMetadata wrapper + // (`Vec` = compact(len) ‖ bytes). Strip the wrapper only when present. + const META_MAGIC: [u8; 4] = *b"meta"; + if bytes.get(..4) == Some(&META_MAGIC) { + Metadata::decode(&bytes) + } else { + let inner = + Vec::::decode(&mut &bytes[..]).map_err(|e| format!("opaque metadata: {e}"))?; + Metadata::decode(&inner) + } +} + +/// Fetch the chain state needed to fill the signed extensions. +pub async fn fetch_chain_state(rpc: &RpcClient) -> Result { + let genesis_hex = rpc + .call("chain_getBlockHash", json!([0])) + .await + .map_err(|e| e.to_string())?; + let genesis_str = genesis_hex + .as_str() + .ok_or_else(|| "chain_getBlockHash returned non-string".to_string())?; + let genesis = hex::decode(genesis_str.strip_prefix("0x").unwrap_or(genesis_str)) + .map_err(|e| format!("genesis hex: {e}"))?; + let genesis_hash: [u8; 32] = genesis + .try_into() + .map_err(|_| "genesis hash is not 32 bytes".to_string())?; + + let runtime = rpc + .call("state_getRuntimeVersion", json!([])) + .await + .map_err(|e| e.to_string())?; + let spec_version = json_u32(&runtime, "specVersion")?; + let transaction_version = json_u32(&runtime, "transactionVersion")?; + + Ok(ChainState { + spec_version, + transaction_version, + genesis_hash, + nonce: 0, + }) +} + +/// Read a u32 field from a JSON object. +fn json_u32(value: &Value, field: &str) -> Result { + value + .get(field) + .and_then(Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .ok_or_else(|| format!("missing/invalid {field}")) +} + +/// Result of a statement-store allowance registration attempt. +pub enum RegistrationOutcome { + /// The extrinsic reached a block; the target now holds slot `seq`. + Registered { + /// Block hash the extrinsic landed in. + block_hash: String, + /// Claimed slot sequence. + seq: u32, + /// Ring index the proof was built against. + ring_index: u32, + }, + /// The target already held a slot this period; nothing submitted. + AlreadyAllocated { + /// Existing slot sequence. + seq: u32, + }, +} + +/// Result of a long-term storage claim attempt. +pub enum LongTermStorageOutcome { + /// The extrinsic reached a block; the target should receive Bulletin + /// authorization once XCM/chain propagation completes. + Claimed { + /// Block hash the extrinsic landed in. + block_hash: String, + /// Claimed counter within the long-term storage period. + counter: u8, + /// Ring index the proof was built against. + ring_index: u32, + }, +} + +/// Bulletin authorization state for one account. +#[derive(Debug, Clone, Copy)] +pub struct BulletinAllowanceInfo { + pub remained_size: u64, + pub remained_transactions: u32, + pub expires_in: u32, + pub fetched_at: u32, +} + +impl BulletinAllowanceInfo { + pub fn available(self) -> bool { + self.remained_size > 0 + && self.remained_transactions > 0 + && self.fetched_at < self.expires_in + } +} + +/// Find the newest ring (scanning up to `lookback` back from the current index) +/// that includes our member key. Reads the ring exponent once and stops at the +/// first match. +pub async fn find_including_ring( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + lookback: u32, +) -> Result, String> { + let member = proof::member_key(entropy); + let exponent = ring::read_ring_exponent(rpc, metadata).await?; + let current = ring::read_current_ring_index(rpc).await?; + let oldest = current.saturating_sub(lookback); + for ring_index in (oldest..=current).rev() { + let members = ring::read_ring_members_at(rpc, ring_index).await?; + if members.contains(&member) { + return Ok(Some(RingParams { + members, + exponent, + ring_index, + })); + } + } + Ok(None) +} + +/// Register statement-store allowance for `target`, proving membership in the +/// already-located `ring`, at UTC-day `period`. +pub async fn register_statement_account( + rpc: &RpcClient, + metadata: &Metadata, + chain_state: &ChainState, + entropy: [u8; 32], + target: &[u8; 32], + period: u32, + ring: &RingParams, +) -> Result { + let mut skipped_duplicate_slots = Vec::new(); + loop { + let seq = match slot::scan_slot_excluding( + rpc, + metadata, + entropy, + period, + target, + &skipped_duplicate_slots, + ) + .await? + { + SlotSelection::AlreadyAllocated(seq) => { + return Ok(RegistrationOutcome::AlreadyAllocated { seq }); + } + SlotSelection::Free(seq) => seq, + }; + + let context = slot::derive_slot_context(period, seq); + let call = extrinsic::build_set_statement_store_account_call(period, seq, target); + let message = extension::build_proof_message(metadata, &call, chain_state)?; + let domain = proof::domain_for_ring_exponent(ring.exponent)?; + let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; + let as_resources_extra = extrinsic::build_as_resources_extra(&ring_proof, ring.ring_index); + let extrinsic = + extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; + + match rpc.submit_and_watch(&extrinsic).await { + Ok(block_hash) => { + return Ok(RegistrationOutcome::Registered { + block_hash, + seq, + ring_index: ring.ring_index, + }); + } + Err(err) if duplicate_submit_error(&err) => { + skipped_duplicate_slots.push(seq); + } + Err(err) => return Err(err.to_string()), + } + } +} + +/// Claim long-term Bulletin storage authorization for `target`, proving +/// membership in the already-located `ring`, at People-chain `period`. +pub async fn claim_long_term_storage( + rpc: &RpcClient, + metadata: &Metadata, + chain_state: &ChainState, + entropy: [u8; 32], + target: &[u8; 32], + period: u32, + ring: &RingParams, +) -> Result { + let revision = ring::read_ring_revision(rpc, metadata, ring.ring_index).await?; + let mut skipped_duplicate_counters = Vec::new(); + loop { + let counter = slot::scan_long_term_storage_counter_excluding( + rpc, + metadata, + entropy, + period, + &skipped_duplicate_counters, + ) + .await?; + + let context = slot::derive_long_term_storage_context(period, counter); + let call = extrinsic::build_claim_long_term_storage_call(period, counter, target); + let message = extension::build_proof_message(metadata, &call, chain_state)?; + let domain = proof::domain_for_ring_exponent(ring.exponent)?; + let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; + let as_resources_extra = + extrinsic::build_long_term_storage_extra(&ring_proof, ring.ring_index, revision); + let extrinsic = + extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; + info!( + period, + counter, + ring_index = ring.ring_index, + revision, + "submitting Bulletin long-term-storage claim" + ); + + match rpc.submit_and_watch(&extrinsic).await { + Ok(block_hash) => { + return Ok(LongTermStorageOutcome::Claimed { + block_hash, + counter, + ring_index: ring.ring_index, + }); + } + Err(err) if duplicate_submit_error(&err) => { + skipped_duplicate_counters.push(counter); + } + Err(err) => { + warn!( + period, + counter, + ring_index = ring.ring_index, + revision, + %err, + "Bulletin long-term-storage claim failed" + ); + return Err(err.to_string()); + } + } + } +} + +/// Fetch Bulletin `TransactionStorage.Authorizations[Account(target)]`. +pub async fn fetch_bulletin_allowance( + rpc: &RpcClient, + target: &[u8; 32], +) -> Result, String> { + let Some(bytes) = rpc + .get_storage(&bulletin_authorization_key(target)) + .await + .map_err(|e| e.to_string())? + else { + return Ok(None); + }; + let fetched_at = fetch_block_number(rpc).await?; + decode_bulletin_allowance(&bytes, fetched_at).map(Some) +} + +/// Wait until Bulletin authorization is available and fresher than `current`. +pub async fn wait_bulletin_authorization( + rpc: &RpcClient, + target: &[u8; 32], + current: Option, + timeout: Duration, +) -> Result { + let started = Instant::now(); + let baseline = current.filter(|info| info.available()); + loop { + let Some(info) = fetch_bulletin_allowance(rpc, target).await? else { + wait_before_next_bulletin_authorization_poll(started, timeout).await?; + continue; + }; + if authorization_refreshed(info, baseline) { + return Ok(info); + } + wait_before_next_bulletin_authorization_poll(started, timeout).await?; + } +} + +async fn wait_before_next_bulletin_authorization_poll( + started: Instant, + timeout: Duration, +) -> Result<(), String> { + let Some(remaining) = timeout.checked_sub(started.elapsed()) else { + return Err("timed out waiting for Bulletin authorization".to_string()); + }; + let delay = futures_timer::Delay::new(remaining.min(Duration::from_secs(2))).fuse(); + futures::pin_mut!(delay); + delay.await; + Ok(()) +} + +fn authorization_refreshed( + info: BulletinAllowanceInfo, + baseline: Option, +) -> bool { + if !info.available() { + return false; + } + match baseline { + None => true, + Some(current) => { + info.remained_transactions > current.remained_transactions + || info.remained_size > current.remained_size + || info.expires_in > current.expires_in + } + } +} + +/// `TransactionStorage.Authorizations[AuthorizationScope::Account(target)]`. +fn bulletin_authorization_key(target: &[u8; 32]) -> Vec { + let mut scope = Vec::with_capacity(1 + 32); + scope.push(0x00); + scope.extend_from_slice(target); + [ + twox_128(b"TransactionStorage").as_slice(), + twox_128(b"Authorizations").as_slice(), + &ring::blake2_128_concat(&scope), + ] + .concat() +} + +fn decode_bulletin_allowance( + bytes: &[u8], + fetched_at: u32, +) -> Result { + let mut input = bytes; + let transactions = + u32::decode(&mut input).map_err(|err| format!("authorization transactions: {err}"))?; + let transactions_allowance = u32::decode(&mut input) + .map_err(|err| format!("authorization transactions_allowance: {err}"))?; + let bytes_used = + u64::decode(&mut input).map_err(|err| format!("authorization bytes: {err}"))?; + let _bytes_permanent = + u64::decode(&mut input).map_err(|err| format!("authorization bytes_permanent: {err}"))?; + let bytes_allowance = + u64::decode(&mut input).map_err(|err| format!("authorization bytes_allowance: {err}"))?; + let expires_in = + u32::decode(&mut input).map_err(|err| format!("authorization expiration: {err}"))?; + Ok(BulletinAllowanceInfo { + remained_size: bytes_allowance.saturating_sub(bytes_used), + remained_transactions: transactions_allowance.saturating_sub(transactions), + expires_in, + fetched_at, + }) +} + +async fn fetch_block_number(rpc: &RpcClient) -> Result { + let header = rpc + .call("chain_getHeader", json!([])) + .await + .map_err(|err| err.to_string())?; + let number = header + .get("number") + .and_then(Value::as_str) + .ok_or_else(|| "chain_getHeader returned no number".to_string())?; + u32::from_str_radix(number.trim_start_matches("0x"), 16) + .map_err(|err| format!("chain_getHeader number: {err}")) +} + +fn duplicate_submit_error(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("priority is too low") + || message.contains("already imported") + || message.contains("temporarily banned") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn allowance( + remained_size: u64, + remained_transactions: u32, + expires_in: u32, + ) -> BulletinAllowanceInfo { + BulletinAllowanceInfo { + remained_size, + remained_transactions, + expires_in, + fetched_at: 10, + } + } + + #[test] + fn bulletin_refresh_accepts_available_state_when_baseline_was_unusable() { + let exhausted_by_size = allowance(0, 4, 100); + let refreshed_same_transactions = allowance(4096, 4, 100); + + assert!(!exhausted_by_size.available()); + assert!(authorization_refreshed( + refreshed_same_transactions, + Some(exhausted_by_size).filter(|info| info.available()), + )); + } + + #[test] + fn bulletin_refresh_accepts_size_only_increase() { + let baseline = allowance(128, 4, 100); + let refreshed = allowance(4096, 4, 100); + + assert!(authorization_refreshed(refreshed, Some(baseline))); + } + + #[test] + fn bulletin_refresh_rejects_unchanged_available_state() { + let baseline = allowance(128, 4, 100); + + assert!(!authorization_refreshed(baseline, Some(baseline))); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs new file mode 100644 index 00000000..42e1df77 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs @@ -0,0 +1,164 @@ +//! Minimal metadata-driven SCALE walker. +//! +//! Just enough to read one field out of a storage struct without a full dynamic +//! codec: `skip` advances a cursor past one value of a given type, +//! `read_field_variant_name` walks a composite to a named field and returns its +//! enum variant name (used for `CollectionInfo.ring_size` -> `R2e9`/`R2e10`/`R2e14`), +//! and `read_field_u32` reads simple numeric fields such as `RingRoot.revision`. + +use parity_scale_codec::{Compact, Decode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + +/// Advance `input` past exactly one SCALE-encoded value of `type_id`. +pub fn skip(registry: &PortableRegistry, type_id: u32, input: &mut &[u8]) -> Result<(), String> { + let ty = registry + .resolve(type_id) + .ok_or_else(|| format!("unknown type id {type_id}"))?; + match &ty.type_def { + TypeDef::Composite(c) => { + for field in &c.fields { + skip(registry, field.ty.id, input)?; + } + } + TypeDef::Tuple(t) => { + for field in &t.fields { + skip(registry, field.id, input)?; + } + } + TypeDef::Array(a) => { + for _ in 0..a.len { + skip(registry, a.type_param.id, input)?; + } + } + TypeDef::Sequence(s) => { + let len = read_compact(input)?; + for _ in 0..len { + skip(registry, s.type_param.id, input)?; + } + } + TypeDef::Variant(v) => { + let index = read_u8(input)?; + let variant = v + .variants + .iter() + .find(|var| var.index == index) + .ok_or_else(|| format!("unknown variant index {index}"))?; + for field in &variant.fields { + skip(registry, field.ty.id, input)?; + } + } + TypeDef::Compact(_) => { + read_compact(input)?; + } + TypeDef::BitSequence(_) => { + let bits = read_compact(input)?; + advance(input, bits.div_ceil(8))?; + } + TypeDef::Primitive(p) => { + let len = match p { + TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => 1, + TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => 2, + TypeDefPrimitive::Char | TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => 4, + TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => 8, + TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => 16, + TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => 32, + // Length-prefixed UTF-8: compact byte length then the bytes. + TypeDefPrimitive::Str => read_compact(input)?, + }; + advance(input, len)?; + } + } + Ok(()) +} + +/// Walk composite `struct_type_id` to `field_name` and return the enum variant +/// name selected there (the field must be a fieldless/simple enum). +pub fn read_field_variant_name( + registry: &PortableRegistry, + struct_type_id: u32, + field_name: &str, + bytes: &[u8], +) -> Result { + let ty = registry + .resolve(struct_type_id) + .ok_or_else(|| format!("unknown type id {struct_type_id}"))?; + let TypeDef::Composite(composite) = &ty.type_def else { + return Err(format!("type {struct_type_id} is not a composite")); + }; + + let mut input = bytes; + for field in &composite.fields { + if field.name.as_deref() == Some(field_name) { + let field_ty = registry + .resolve(field.ty.id) + .ok_or_else(|| format!("unknown field type id {}", field.ty.id))?; + let TypeDef::Variant(variant) = &field_ty.type_def else { + return Err(format!("field `{field_name}` is not an enum")); + }; + let index = read_u8(&mut input)?; + return variant + .variants + .iter() + .find(|var| var.index == index) + .map(|var| var.name.clone()) + .ok_or_else(|| format!("unknown variant index {index} for `{field_name}`")); + } + skip(registry, field.ty.id, &mut input)?; + } + Err(format!("field `{field_name}` not found")) +} + +/// Walk composite `struct_type_id` to `field_name` and decode the field as a +/// SCALE `u32`. +pub fn read_field_u32( + registry: &PortableRegistry, + struct_type_id: u32, + field_name: &str, + bytes: &[u8], +) -> Result { + let ty = registry + .resolve(struct_type_id) + .ok_or_else(|| format!("unknown type id {struct_type_id}"))?; + let TypeDef::Composite(composite) = &ty.type_def else { + return Err(format!("type {struct_type_id} is not a composite")); + }; + + let mut input = bytes; + for field in &composite.fields { + if field.name.as_deref() == Some(field_name) { + let field_ty = registry + .resolve(field.ty.id) + .ok_or_else(|| format!("unknown field type id {}", field.ty.id))?; + if !matches!(field_ty.type_def, TypeDef::Primitive(TypeDefPrimitive::U32)) { + return Err(format!("field `{field_name}` is not a u32")); + } + return u32::decode(&mut input).map_err(|err| format!("field `{field_name}`: {err}")); + } + skip(registry, field.ty.id, &mut input)?; + } + Err(format!("field `{field_name}` not found")) +} + +/// Decode a SCALE compact-encoded length, advancing `input`. +fn read_compact(input: &mut &[u8]) -> Result { + let Compact(value) = Compact::::decode(input).map_err(|err| format!("compact: {err}"))?; + usize::try_from(value).map_err(|_| "compact length overflow".to_string()) +} + +/// Read one byte, advancing `input`. +fn read_u8(input: &mut &[u8]) -> Result { + let (&first, rest) = input + .split_first() + .ok_or_else(|| "unexpected end".to_string())?; + *input = rest; + Ok(first) +} + +/// Advance `input` by `n` bytes. +fn advance(input: &mut &[u8], n: usize) -> Result<(), String> { + if input.len() < n { + return Err(format!("need {n} bytes, have {}", input.len())); + } + *input = &input[n..]; + Ok(()) +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs new file mode 100644 index 00000000..64bf6153 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs @@ -0,0 +1,419 @@ +//! Signed-extension encoding for the unsigned General (v5) `AsResources` +//! extrinsic, driven by live chain metadata. +//! +//! The extension **order** and per-extension type ids come from the runtime +//! metadata (`state_getMetadata`, V14/V15/V16); the per-extension `extra` / +//! `additional_signed` bytes come from a name-keyed encoder mirroring +//! signing-bot `src/core/create-transaction.ts` `encodeSignedExtensions`, with a +//! generic default for the personhood extensions (all `Option`/void). +//! +//! Two concatenations are derived from the same encoded list: +//! - the ring-VRF proof message (`build_proof_message`) over the extensions +//! strictly *after* `AsResources` (host-spec inherited implication), and +//! - the full extrinsic body's `Σ extra` (see `extrinsic.rs`), over *all* +//! extensions with `AsResources` carrying `Some(AsResourcesInfo)`. + +use std::collections::HashMap; + +use frame_metadata::RuntimeMetadata; +use frame_metadata::RuntimeMetadataPrefixed; +use parity_scale_codec::{Compact, Decode, Encode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + +/// Signed-extension identifier that carries the `AsResources` authorization. +pub const AS_RESOURCES: &str = "AsResources"; + +/// Chain state needed to fill the standard signed extensions. +#[derive(Debug, Clone, Copy)] +pub struct ChainState { + /// Runtime `specVersion` (CheckSpecVersion implicit). + pub spec_version: u32, + /// Runtime `transactionVersion` (CheckTxVersion implicit). + pub transaction_version: u32, + /// Genesis block hash (CheckGenesis / CheckMortality implicit). + pub genesis_hash: [u8; 32], + /// Account nonce (CheckNonce extra); ignored by the unsigned path. + pub nonce: u32, +} + +/// A signed extension's identifier plus the type ids of its `extra` and +/// `additional_signed` fields, in metadata order. +struct ExtensionDef { + identifier: String, + extra_type: u32, + additional_signed_type: u32, +} + +/// A signed extension encoded to its `extra` and `additional_signed` bytes. +pub struct EncodedExtension { + /// SCALE-encoded `extra` (goes into the extrinsic body). + pub extra: Vec, + /// SCALE-encoded `additional_signed` (the implicit, part of the signed data). + pub additional_signed: Vec, +} + +/// Decoded metadata: the ordered signed-extension defs, the type registry, and +/// each storage entry's value type id (`(pallet, entry) -> type id`). +pub struct Metadata { + extensions: Vec, + registry: PortableRegistry, + storage_values: HashMap<(String, String), u32>, + constants: HashMap<(String, String), Vec>, +} + +/// Collect extensions, type registry, storage value types, and pallet constants +/// from decoded metadata; `$set` is the version's `StorageEntryType`. +macro_rules! collect_metadata { + ($m:expr, $set:path) => {{ + let extensions = $m + .extrinsic + .signed_extensions + .iter() + .map(|e| ExtensionDef { + identifier: e.identifier.clone(), + extra_type: e.ty.id, + additional_signed_type: e.additional_signed.id, + }) + .collect(); + let mut storage_values = HashMap::new(); + let mut constants = HashMap::new(); + for pallet in &$m.pallets { + for constant in &pallet.constants { + constants.insert( + (pallet.name.clone(), constant.name.clone()), + constant.value.clone(), + ); + } + let Some(storage) = &pallet.storage else { + continue; + }; + for entry in &storage.entries { + use $set as EntryType; + let value_type = match &entry.ty { + EntryType::Plain(ty) => ty.id, + EntryType::Map { value, .. } => value.id, + }; + storage_values.insert((pallet.name.clone(), entry.name.clone()), value_type); + } + } + (extensions, $m.types, storage_values, constants) + }}; +} + +macro_rules! collect_metadata_v16 { + ($m:expr) => {{ + let extension_indexes = $m + .extrinsic + .transaction_extensions_by_version + .get(&5) + .map(|indexes| { + indexes + .iter() + .map(|Compact(index)| *index as usize) + .collect::>() + }) + .unwrap_or_else(|| (0..$m.extrinsic.transaction_extensions.len()).collect()); + let extensions = extension_indexes + .into_iter() + .filter_map(|index| $m.extrinsic.transaction_extensions.get(index)) + .map(|e| ExtensionDef { + identifier: e.identifier.clone(), + extra_type: e.ty.id, + additional_signed_type: e.implicit.id, + }) + .collect(); + let mut storage_values = HashMap::new(); + let mut constants = HashMap::new(); + for pallet in &$m.pallets { + for constant in &pallet.constants { + constants.insert( + (pallet.name.clone(), constant.name.clone()), + constant.value.clone(), + ); + } + let Some(storage) = &pallet.storage else { + continue; + }; + for entry in &storage.entries { + use frame_metadata::v16::StorageEntryType as EntryType; + let value_type = match &entry.ty { + EntryType::Plain(ty) => ty.id, + EntryType::Map { value, .. } => value.id, + }; + storage_values.insert((pallet.name.clone(), entry.name.clone()), value_type); + } + } + (extensions, $m.types, storage_values, constants) + }}; +} + +impl Metadata { + /// Decode `state_getMetadata` bytes (a `RuntimeMetadataPrefixed`, V14 or + /// V15) into the ordered signed-extension defs, type registry, and storage + /// value types. + pub fn decode(bytes: &[u8]) -> Result { + let prefixed = RuntimeMetadataPrefixed::decode(&mut &bytes[..]) + .map_err(|err| format!("metadata decode failed: {err}"))?; + let (extensions, registry, storage_values, constants) = match prefixed.1 { + RuntimeMetadata::V14(m) => collect_metadata!(m, frame_metadata::v14::StorageEntryType), + RuntimeMetadata::V15(m) => collect_metadata!(m, frame_metadata::v15::StorageEntryType), + RuntimeMetadata::V16(m) => collect_metadata_v16!(m), + other => return Err(format!("unsupported metadata version {}", other.version())), + }; + Ok(Self { + extensions, + registry, + storage_values, + constants, + }) + } + + /// The type registry, for dynamic decoding of storage values. + pub fn registry(&self) -> &PortableRegistry { + &self.registry + } + + /// The value type id of storage entry `pallet::entry`, if present. + pub fn storage_value_type(&self, pallet: &str, entry: &str) -> Option { + self.storage_values + .get(&(pallet.to_string(), entry.to_string())) + .copied() + } + + /// The SCALE-encoded value bytes of pallet constant `pallet::name`. + pub fn constant(&self, pallet: &str, name: &str) -> Option<&[u8]> { + self.constants + .get(&(pallet.to_string(), name.to_string())) + .map(Vec::as_slice) + } + + /// Encode every signed extension in metadata order. + pub fn encode_signed_extensions(&self, state: &ChainState) -> Vec { + self.extensions + .iter() + .map(|ext| { + let (extra, additional_signed) = self.encode_one(ext, state); + EncodedExtension { + extra, + additional_signed, + } + }) + .collect() + } + + /// The signed-extension identifiers, in metadata order. + #[cfg(test)] + pub fn extension_ids(&self) -> Vec<&str> { + self.extensions + .iter() + .map(|e| e.identifier.as_str()) + .collect() + } + + /// Encode a single extension's `(extra, additional_signed)`, mirroring the + /// signing-bot switch; unknown personhood extensions fall back to the + /// metadata type default (`Option` -> None, void -> empty). + fn encode_one(&self, ext: &ExtensionDef, state: &ChainState) -> (Vec, Vec) { + match ext.identifier.as_str() { + "CheckNonce" => (Compact(state.nonce).encode(), Vec::new()), + "CheckSpecVersion" => (Vec::new(), state.spec_version.to_le_bytes().to_vec()), + "CheckTxVersion" => (Vec::new(), state.transaction_version.to_le_bytes().to_vec()), + "CheckGenesis" => (Vec::new(), state.genesis_hash.to_vec()), + // extra = Era::Immortal (0x00); implicit = genesis hash. + "CheckMortality" => (vec![0x00], state.genesis_hash.to_vec()), + // extra = first variant `Disabled` (void) = 0x00. + "VerifyMultiSignature" => (vec![0x00], Vec::new()), + // extra = { tip: compact(0), asset_id: None } = 0x00 0x00. + "ChargeAssetTxPayment" => (vec![0x00, 0x00], Vec::new()), + // extra = bool false = 0x00. + "RestrictOrigins" => (vec![0x00], Vec::new()), + _ => ( + self.encode_default(ext.extra_type), + self.encode_default(ext.additional_signed_type), + ), + } + } + + /// Encode the "disabled" default value for a metadata type: `Option` -> None + /// (`0x00`), void/empty tuple -> empty, enums -> first variant, primitives + /// -> zero. Matches signing-bot `defaultValueForType`. + fn encode_default(&self, type_id: u32) -> Vec { + let Some(ty) = self.registry.resolve(type_id) else { + return Vec::new(); + }; + match &ty.type_def { + TypeDef::Composite(c) => c + .fields + .iter() + .flat_map(|f| self.encode_default(f.ty.id)) + .collect(), + TypeDef::Tuple(t) => t + .fields + .iter() + .flat_map(|f| self.encode_default(f.id)) + .collect(), + TypeDef::Variant(v) => { + // Option encodes None as 0x00. + if ty.path.segments.last().map(String::as_str) == Some("Option") { + return vec![0x00]; + } + match v.variants.iter().min_by_key(|var| var.index) { + None => Vec::new(), + Some(first) => { + let mut out = vec![first.index]; + for field in &first.fields { + out.extend(self.encode_default(field.ty.id)); + } + out + } + } + } + TypeDef::Array(a) => { + let elem = self.encode_default(a.type_param.id); + elem.repeat(a.len as usize) + } + // Sequences / strings / bit-sequences encode an empty run as compact(0). + TypeDef::Sequence(_) | TypeDef::BitSequence(_) => vec![0x00], + TypeDef::Compact(_) => vec![0x00], + TypeDef::Primitive(p) => match p { + TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => vec![0], + TypeDefPrimitive::Char | TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => { + vec![0; 4] + } + TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => vec![0; 2], + TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => vec![0; 8], + TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => vec![0; 16], + TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => vec![0; 32], + // Length-prefixed string: empty = compact(0). + TypeDefPrimitive::Str => vec![0x00], + }, + } + } + + /// Index of `AsResources` in the extension list, if present. + pub fn as_resources_index(&self) -> Option { + self.extensions + .iter() + .position(|e| e.identifier == AS_RESOURCES) + } +} + +/// Build the ring-VRF proof message for an `AsResources`-authorized call: +/// `blake2b256(0x00 ‖ call ‖ Σ tail.extra ‖ Σ tail.additional_signed)`, where +/// the tail is the extensions ordered strictly after `AsResources`. The leading +/// `0x00` is the General-transaction extension-version byte. +pub fn build_proof_message( + metadata: &Metadata, + call_data: &[u8], + state: &ChainState, +) -> Result<[u8; 32], String> { + let all = metadata.encode_signed_extensions(state); + let tail_start = metadata + .as_resources_index() + .map(|i| i + 1) + .ok_or_else(|| format!("{AS_RESOURCES} extension not found in metadata"))?; + let tail = &all[tail_start..]; + + let mut payload = Vec::with_capacity(1 + call_data.len()); + payload.push(0x00); + payload.extend_from_slice(call_data); + for ext in tail { + payload.extend_from_slice(&ext.extra); + } + for ext in tail { + payload.extend_from_slice(&ext.additional_signed); + } + Ok(blake2b256(&payload)) +} + +/// BLAKE2b-256 of `message`. +pub fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .hash(message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fixture metadata captured from paseo-next-v2 (raw `RuntimeMetadataPrefixed`). + const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + + /// The known-answer chain state frozen alongside the fixture. + fn fixture_state() -> ChainState { + ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + } + } + + /// `Resources.set_statement_store_account(period=7, seq=0, target=0)`. + fn fixture_call() -> Vec { + let mut call = vec![0x3f, 0x0a]; + call.extend_from_slice(&7u32.to_le_bytes()); + call.extend_from_slice(&0u32.to_le_bytes()); + call.extend_from_slice(&[0u8; 32]); + call + } + + #[test] + fn proof_message_matches_frozen_known_answer() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let msg = build_proof_message(&metadata, &fixture_call(), &fixture_state()).unwrap(); + assert_eq!( + hex::encode(msg), + "1d2e6d8d8f421b0857097c6076115507432d66fea47ebe0c3be282a369f6743c", + ); + } + + #[test] + fn as_resources_tail_is_indices_10_through_20() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let idx = metadata.as_resources_index().unwrap(); + // AsResources sits at index 9; the proof tail is everything after it. + assert_eq!(idx, 9); + let ids = metadata.extension_ids(); + assert_eq!( + ids[idx + 1..].to_vec(), + vec![ + "AuthorizeCall", + "RestrictOrigins", + "CheckNonZeroSender", + "CheckSpecVersion", + "CheckTxVersion", + "CheckGenesis", + "CheckMortality", + "CheckNonce", + "CheckWeight", + "ChargeAssetTxPayment", + "StorageWeightReclaim", + ], + ); + } + + #[test] + fn dropping_the_version_byte_changes_the_hash() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let state = fixture_state(); + let call = fixture_call(); + let all = metadata.encode_signed_extensions(&state); + let tail = &all[metadata.as_resources_index().unwrap() + 1..]; + let mut without = call.clone(); + for e in tail { + without.extend_from_slice(&e.extra); + } + for e in tail { + without.extend_from_slice(&e.additional_signed); + } + assert_ne!( + build_proof_message(&metadata, &call, &state).unwrap(), + blake2b256(&without), + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs new file mode 100644 index 00000000..a427e88a --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs @@ -0,0 +1,204 @@ +//! `Resources.set_statement_store_account` call + unsigned General (v5) +//! extrinsic assembly. Mirrors signing-bot `allocation.ts` / `extrinsic-submit.ts`. + +use parity_scale_codec::{Compact, Encode}; + +use super::extension::{ChainState, Metadata}; + +/// Pallet + call index for `Resources.set_statement_store_account` (63 / 10). +pub const SET_STATEMENT_STORE_ACCOUNT_CALL: [u8; 2] = [0x3f, 0x0a]; +/// Pallet + call index for `Resources.claim_long_term_storage` (63 / 12). +pub const CLAIM_LONG_TERM_STORAGE_CALL: [u8; 2] = [0x3f, 0x0c]; +/// `AsResourcesInfo::RegisterStatementStoreAllowance` variant index. +const REGISTER_STATEMENT_STORE_ALLOWANCE: u8 = 0x02; +/// `AsResourcesInfo::ClaimLongTermStorage` variant index. +const CLAIM_LONG_TERM_STORAGE: u8 = 0x03; +/// `MembershipCollection::LitePeople` variant index. +const MEMBERSHIP_COLLECTION_LITE_PEOPLE: u8 = 0x01; +/// General-transaction preamble byte: `0b01` (General) | version 5. +const GENERAL_V5_PREAMBLE: u8 = 0x45; +/// Current signed-extension version byte. +const EXTENSION_VERSION: u8 = 0x00; +/// `Option::Some` discriminant for the `AsResources` extension `extra`. +const OPTION_SOME: u8 = 0x01; + +/// Encode `Resources.set_statement_store_account(period, seq, target)`: +/// `3f 0a ‖ period_u32LE ‖ seq_u32LE ‖ target[32]`. +pub fn build_set_statement_store_account_call(period: u32, seq: u32, target: &[u8; 32]) -> Vec { + let mut call = Vec::with_capacity(2 + 4 + 4 + 32); + call.extend_from_slice(&SET_STATEMENT_STORE_ACCOUNT_CALL); + call.extend_from_slice(&period.to_le_bytes()); + call.extend_from_slice(&seq.to_le_bytes()); + call.extend_from_slice(target); + call +} + +/// Encode `Resources.claim_long_term_storage(period, counter, account_id)`: +/// `3f 0c ‖ period_u32LE ‖ counter_u8 ‖ account_id[32]`. +pub fn build_claim_long_term_storage_call( + period: u32, + counter: u8, + account_id: &[u8; 32], +) -> Vec { + let mut call = Vec::with_capacity(2 + 4 + 1 + 32); + call.extend_from_slice(&CLAIM_LONG_TERM_STORAGE_CALL); + call.extend_from_slice(&period.to_le_bytes()); + call.push(counter); + call.extend_from_slice(account_id); + call +} + +/// Encode the `AsResources` extension `extra` for a statement-store allowance: +/// `Some(RegisterStatementStoreAllowance { proof, ring_index, LitePeople })`. +pub fn build_as_resources_extra(proof: &[u8], ring_index: u32) -> Vec { + let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 1); + extra.push(OPTION_SOME); + extra.push(REGISTER_STATEMENT_STORE_ALLOWANCE); + extra.extend_from_slice(&Compact(proof.len() as u32).encode()); + extra.extend_from_slice(proof); + extra.extend_from_slice(&ring_index.to_le_bytes()); + extra.push(MEMBERSHIP_COLLECTION_LITE_PEOPLE); + extra +} + +/// Encode the `AsResources` extension `extra` for a long-term storage claim: +/// `Some(ClaimLongTermStorage { proof, ring_index, revision, LitePeople })`. +pub fn build_long_term_storage_extra(proof: &[u8], ring_index: u32, revision: u32) -> Vec { + let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 4 + 1); + extra.push(OPTION_SOME); + extra.push(CLAIM_LONG_TERM_STORAGE); + extra.extend_from_slice(&Compact(proof.len() as u32).encode()); + extra.extend_from_slice(proof); + extra.extend_from_slice(&ring_index.to_le_bytes()); + extra.extend_from_slice(&revision.to_le_bytes()); + extra.push(MEMBERSHIP_COLLECTION_LITE_PEOPLE); + extra +} + +/// Assemble the unsigned General (v5) extrinsic: +/// `compact(len) ‖ 0x45 ‖ 0x00 ‖ Σ(all extra, AsResources = Some(info)) ‖ call`. +pub fn build_unsigned_extrinsic( + metadata: &Metadata, + state: &ChainState, + call_data: &[u8], + as_resources_extra: &[u8], +) -> Result, String> { + let all = metadata.encode_signed_extensions(state); + let as_resources_index = metadata + .as_resources_index() + .ok_or_else(|| "AsResources extension not found in metadata".to_string())?; + + let mut body = vec![GENERAL_V5_PREAMBLE, EXTENSION_VERSION]; + for (i, ext) in all.iter().enumerate() { + if i == as_resources_index { + body.extend_from_slice(as_resources_extra); + } else { + body.extend_from_slice(&ext.extra); + } + } + body.extend_from_slice(call_data); + + let mut extrinsic = Compact(body.len() as u32).encode(); + extrinsic.extend_from_slice(&body); + Ok(extrinsic) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + + fn fixture_state() -> ChainState { + ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + } + } + + #[test] + fn call_layout_is_pallet_call_period_seq_target() { + let call = build_set_statement_store_account_call(7, 0, &[0u8; 32]); + assert_eq!( + call, + [ + vec![0x3f, 0x0a], + 7u32.to_le_bytes().to_vec(), + 0u32.to_le_bytes().to_vec(), + vec![0u8; 32], + ] + .concat() + ); + } + + #[test] + fn long_term_storage_call_layout_is_pallet_call_period_counter_account() { + let call = build_claim_long_term_storage_call(7, 3, &[0u8; 32]); + assert_eq!( + call, + [ + vec![0x3f, 0x0c], + 7u32.to_le_bytes().to_vec(), + vec![3], + vec![0u8; 32], + ] + .concat() + ); + } + + #[test] + fn as_resources_extra_wraps_proof_as_bytes() { + let proof = vec![0xEE; 785]; + let extra = build_as_resources_extra(&proof, 3); + // Some(0x01) ‖ variant(0x02) ‖ compact(785)=0x45,0x0c ‖ 785 bytes ‖ ringIndex LE ‖ LitePeople. + assert_eq!(&extra[..2], &[0x01, 0x02]); + assert_eq!(&extra[2..4], &Compact(785u32).encode()[..]); + assert_eq!(&extra[4..4 + 785], &proof[..]); + assert_eq!(&extra[4 + 785..4 + 785 + 4], &3u32.to_le_bytes()); + assert_eq!(extra[4 + 785 + 4], MEMBERSHIP_COLLECTION_LITE_PEOPLE); + } + + #[test] + fn long_term_storage_extra_wraps_revision() { + let proof = vec![0xEE; 785]; + let extra = build_long_term_storage_extra(&proof, 3, 9); + // Some(0x01) ‖ variant(0x03) ‖ compact(785)=0x45,0x0c ‖ proof + // ‖ ringIndex LE ‖ revision LE ‖ LitePeople. + assert_eq!(&extra[..2], &[0x01, 0x03]); + assert_eq!(&extra[2..4], &Compact(785u32).encode()[..]); + assert_eq!(&extra[4..4 + 785], &proof[..]); + assert_eq!(&extra[4 + 785..4 + 785 + 4], &3u32.to_le_bytes()); + assert_eq!(&extra[4 + 785 + 4..4 + 785 + 8], &9u32.to_le_bytes()); + assert_eq!(extra[4 + 785 + 8], MEMBERSHIP_COLLECTION_LITE_PEOPLE); + } + + #[test] + fn extrinsic_has_general_v5_preamble_and_embeds_call() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let call = build_set_statement_store_account_call(7, 0, &[0u8; 32]); + let extra = build_as_resources_extra(&vec![0xEE; 785], 0); + let xt = build_unsigned_extrinsic(&metadata, &fixture_state(), &call, &extra).unwrap(); + + // Strip the compact length prefix and check the body head + tail. + let body = &xt[compact_prefix_len(&xt)..]; + assert_eq!(&body[..2], &[GENERAL_V5_PREAMBLE, EXTENSION_VERSION]); + assert_eq!(&body[body.len() - call.len()..], &call[..]); + // The Some(info) extra appears verbatim in the body. + assert!( + body.windows(extra.len()).any(|w| w == extra), + "AsResources Some(info) extra should appear in the body", + ); + } + + /// Length of the SCALE compact prefix at the head of `xt`. + fn compact_prefix_len(xt: &[u8]) -> usize { + match xt[0] & 0b11 { + 0b00 => 1, + 0b01 => 2, + 0b10 => 4, + _ => 5, + } + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs new file mode 100644 index 00000000..b9dc567f --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs @@ -0,0 +1,111 @@ +//! Bandersnatch ring-VRF one-shot membership proof (prover side). +//! +//! Wraps `verifiable`'s prover-gated `open` + `create` into the single-shot +//! proof a `RegisterStatementStoreAllowance` needs: prove that our member key is +//! in the LitePeople ring, bound to a slot `context` and the extrinsic proof +//! `message`. Mirrors signing-bot `ring-proof.ts` `oneShotProof`. + +use verifiable::GenerateVerifiable; +use verifiable::ring::RingDomainSize; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +/// A single-context ring-VRF signature is exactly 785 bytes. +pub const RING_VRF_PROOF_LEN: usize = 785; + +/// Map an on-chain `RingExponent` (9 / 10 / 14) to the FFT domain size +/// (power = exponent + 2). +pub fn domain_for_ring_exponent(exponent: u8) -> Result { + match exponent { + 9 => Ok(RingDomainSize::Domain11), + 10 => Ok(RingDomainSize::Domain12), + 14 => Ok(RingDomainSize::Domain16), + other => Err(format!("unsupported ring exponent {other}")), + } +} + +/// The ring member key for a bandersnatch entropy (`blake2b256(bip39_entropy)`). +pub fn member_key(entropy: [u8; 32]) -> [u8; 32] { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + BandersnatchVrfVerifiable::member_from_secret(&secret) +} + +/// Produce the 785-byte ring-VRF membership proof over `members` (already +/// sliced to the ring's included prefix), bound to `context` and `message`. +/// +/// `entropy` is the bandersnatch entropy; its member key must be present in +/// `members` or `open` fails with `NotInRing`. +pub fn ring_vrf_proof( + domain: RingDomainSize, + entropy: [u8; 32], + members: &[[u8; 32]], + context: &[u8], + message: &[u8], +) -> Result, String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let member = BandersnatchVrfVerifiable::member_from_secret(&secret); + let commitment = BandersnatchVrfVerifiable::open(domain, &member, members.iter().copied()) + .map_err(|err| format!("ring-VRF open failed: {err:?}"))?; + let (proof, _alias) = BandersnatchVrfVerifiable::create(commitment, &secret, context, message) + .map_err(|err| format!("ring-VRF create failed: {err:?}"))?; + let bytes = proof.into_inner(); + if bytes.len() != RING_VRF_PROOF_LEN { + return Err(format!( + "ring-VRF proof is {} bytes, expected {RING_VRF_PROOF_LEN}", + bytes.len() + )); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exponent_maps_to_domain() { + assert_eq!( + domain_for_ring_exponent(9).unwrap(), + RingDomainSize::Domain11 + ); + assert_eq!( + domain_for_ring_exponent(10).unwrap(), + RingDomainSize::Domain12 + ); + assert_eq!( + domain_for_ring_exponent(14).unwrap(), + RingDomainSize::Domain16 + ); + assert!(domain_for_ring_exponent(11).is_err()); + } + + #[test] + fn proof_is_785_bytes_for_a_single_member_ring() { + let entropy = [0x11u8; 32]; + let member = member_key(entropy); + let members = vec![member]; + let proof = ring_vrf_proof( + RingDomainSize::Domain11, + entropy, + &members, + b"SSS_SLOT:test-context-padding..", + &[0x42; 32], + ) + .unwrap(); + assert_eq!(proof.len(), RING_VRF_PROOF_LEN); + } + + #[test] + fn open_fails_when_member_absent_from_ring() { + let entropy = [0x11u8; 32]; + let other = member_key([0x22u8; 32]); + let err = ring_vrf_proof( + RingDomainSize::Domain11, + entropy, + &[other], + b"SSS_SLOT:test-context-padding..", + &[0x42; 32], + ) + .unwrap_err(); + assert!(err.contains("open failed"), "unexpected error: {err}"); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs new file mode 100644 index 00000000..e70be951 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs @@ -0,0 +1,201 @@ +//! LitePeople ring parameters from the People chain (`Members` pallet). +//! +//! Reads the on-chain ring so the membership proof is built against the same +//! members the runtime verifies against: the baked-in `included` prefix of the +//! current ring. Mirrors signing-bot `ring-proof.ts`. + +use parity_scale_codec::{Compact, Decode}; +use sp_crypto_hashing::{blake2_128, twox_64, twox_128}; + +use super::dynamic::{read_field_u32, read_field_variant_name}; +use super::extension::Metadata; +use super::rpc::RpcClient; + +/// LitePeople collection identifier: ASCII, exactly 32 bytes. +const LITE_PEOPLE_IDENTIFIER: &[u8; 32] = b"pop:polkadot.network/people-lite"; +/// Ring member public key length. +const MEMBER_LEN: usize = 32; + +/// On-chain LitePeople ring parameters for building a verifying proof. +pub struct RingParams { + /// Ring members, sliced to the baked-in `included` prefix. + pub members: Vec<[u8; 32]>, + /// Ring size exponent (9 / 10 / 14). + pub exponent: u8, + /// Ring index these members belong to. + pub ring_index: u32, +} + +/// `Members.CurrentRingIndex[id]` storage key. +fn current_ring_index_key() -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"CurrentRingIndex").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + ] + .concat() +} + +/// `Members.Collections[id]` storage key. +fn collections_key() -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"Collections").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + ] + .concat() +} + +/// `Members.RingKeysStatus[(id, ring_index)]` storage key. +fn ring_keys_status_key(ring_index: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeysStatus").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + ] + .concat() +} + +/// `Members.Root[(id, ring_index)]` storage key. +fn ring_root_key(ring_index: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"Root").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + ] + .concat() +} + +/// `Members.RingKeys[(id, ring_index, page)]` storage key. +fn ring_keys_key(ring_index: u32, page: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeys").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + &twox_64_concat(&page.to_le_bytes()), + ] + .concat() +} + +/// `Blake2_128Concat(x)` = `blake2_128(x) ‖ x`. +pub(super) fn blake2_128_concat(x: &[u8]) -> Vec { + [blake2_128(x).as_slice(), x].concat() +} + +/// `Twox64Concat(x)` = `twox_64(x) ‖ x`. +fn twox_64_concat(x: &[u8]) -> Vec { + [twox_64(x).as_slice(), x].concat() +} + +/// Map a `RingExponent` variant name to its exponent. +fn ring_exponent_from_name(name: &str) -> Result { + match name { + "R2e9" => Ok(9), + "R2e10" => Ok(10), + "R2e14" => Ok(14), + other => Err(format!("unsupported RingExponent variant `{other}`")), + } +} + +/// Read the current LitePeople ring index (absent => 0). +pub async fn read_current_ring_index(rpc: &RpcClient) -> Result { + match rpc + .get_storage(¤t_ring_index_key()) + .await + .map_err(|e| e.to_string())? + { + Some(bytes) => u32::decode(&mut &bytes[..]).map_err(|e| format!("ring index: {e}")), + None => Ok(0), + } +} + +/// Read the LitePeople ring size exponent from `Collections[LitePeople].ring_size`. +/// This is a chain constant, so read it once and reuse across ring indices. +pub async fn read_ring_exponent(rpc: &RpcClient, metadata: &Metadata) -> Result { + let collection = rpc + .get_storage(&collections_key()) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Members.Collections[LitePeople] missing".to_string())?; + let value_type = metadata + .storage_value_type("Members", "Collections") + .ok_or_else(|| "Members.Collections type not in metadata".to_string())?; + let variant = + read_field_variant_name(metadata.registry(), value_type, "ring_size", &collection)?; + ring_exponent_from_name(&variant) +} + +/// Read the members of `ring_index`, sliced to the baked-in `included` prefix. +pub async fn read_ring_members_at( + rpc: &RpcClient, + ring_index: u32, +) -> Result, String> { + // 1. Page through RingKeys collecting raw 32-byte members. + let mut members = Vec::new(); + for page in 0.. { + let Some(bytes) = rpc + .get_storage(&ring_keys_key(ring_index, page)) + .await + .map_err(|e| e.to_string())? + else { + break; + }; + let mut cursor = &bytes[..]; + let Compact(len) = + Compact::::decode(&mut cursor).map_err(|e| format!("ring keys len: {e}"))?; + if len == 0 { + break; + } + for i in 0..len as usize { + let start = i * MEMBER_LEN; + let member: [u8; 32] = cursor + .get(start..start + MEMBER_LEN) + .ok_or_else(|| "ring keys page truncated".to_string())? + .try_into() + .expect("slice is 32 bytes"); + members.push(member); + } + } + + // 2. Slice to the baked-in `included` prefix (absent status => all included). + if let Some(status) = rpc + .get_storage(&ring_keys_status_key(ring_index)) + .await + .map_err(|e| e.to_string())? + { + // RingStatus = { total: u32 LE, included: u32 LE, .. }. + let included_bytes = status + .get(4..) + .ok_or_else(|| "ring status truncated before included field".to_string())?; + let included = + u32::decode(&mut &included_bytes[..]).map_err(|e| format!("ring status: {e}"))?; + members.truncate(included as usize); + } + + Ok(members) +} + +/// Read `Members.Root[LitePeople][ring_index].revision` (absent => 0). +pub async fn read_ring_revision( + rpc: &RpcClient, + metadata: &Metadata, + ring_index: u32, +) -> Result { + match rpc + .get_storage(&ring_root_key(ring_index)) + .await + .map_err(|e| e.to_string())? + { + Some(bytes) => { + let value_type = metadata + .storage_value_type("Members", "Root") + .ok_or_else(|| "Members.Root type not in metadata".to_string())?; + read_field_u32(metadata.registry(), value_type, "revision", &bytes) + .map_err(|e| format!("ring revision: {e}")) + } + None => Ok(0), + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs new file mode 100644 index 00000000..34e04642 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs @@ -0,0 +1,153 @@ +//! Host-backed JSON-RPC helpers for statement-store allowance registration. + +use core::time::Duration; + +use futures::{FutureExt, pin_mut}; +use serde_json::Value; +use subxt_rpcs::RpcClient as HostRpcClient; +use subxt_rpcs::client::{RpcClient as NativeRpcClient, RpcParams, rpc_params}; + +/// Timeout for an allowance registration extrinsic to reach a block. +const SUBMIT_TIMEOUT: Duration = Duration::from_secs(120); + +/// Thin adapter matching the allowance allocator's minimal RPC surface. +#[derive(Clone)] +pub struct RpcClient { + inner: HostRpcClient, +} + +impl RpcClient { + /// Open a native JSON-RPC connection to `url`. + pub async fn connect(url: &str) -> Result { + let inner = NativeRpcClient::from_insecure_url(url) + .await + .map_err(|err| format!("connect {url}: {err}"))?; + Ok(Self { inner }) + } + + /// Wrap a platform-backed Subxt RPC client. + pub fn new(inner: HostRpcClient) -> Self { + Self { inner } + } + + /// Call `method` with JSON-array `params`, returning the result value. + pub async fn call(&self, method: &str, params: Value) -> Result { + self.inner + .request(method, value_to_params(params)?) + .await + .map_err(rpc_error_message) + } + + /// `state_getStorage(key)` -> raw value bytes, or `None` if absent. + pub async fn get_storage(&self, key: &[u8]) -> Result>, String> { + let key_hex = format!("0x{}", hex::encode(key)); + match self + .inner + .request::("state_getStorage", rpc_params![key_hex]) + .await + .map_err(rpc_error_message)? + { + Value::String(hex_value) => Ok(Some(decode_hex(&hex_value)?)), + _ => Ok(None), + } + } + + /// Submit an extrinsic and wait for `inBlock` or `finalized`; returns the block hash. + pub async fn submit_and_watch(&self, extrinsic: &[u8]) -> Result { + let extrinsic_hex = format!("0x{}", hex::encode(extrinsic)); + let mut subscription = self + .inner + .subscribe::( + "author_submitAndWatchExtrinsic", + rpc_params![extrinsic_hex], + "author_unwatchExtrinsic", + ) + .await + .map_err(rpc_error_message)?; + let timeout = futures_timer::Delay::new(SUBMIT_TIMEOUT).fuse(); + pin_mut!(timeout); + + loop { + let next = subscription.next().fuse(); + pin_mut!(next); + let status = futures::select! { + item = next => item.ok_or_else(|| { + "author_submitAndWatchExtrinsic subscription ended".to_string() + })?.map_err(rpc_error_message)?, + () = timeout => return Err( + "timed out waiting for author_submitAndWatchExtrinsic inclusion".to_string() + ), + }; + tracing::debug!(?status, "allowance extrinsic status"); + match extrinsic_status(&status) { + ExtrinsicStatus::Included(hash) => return Ok(hash), + ExtrinsicStatus::Rejected(reason) => return Err(format!("extrinsic {reason}")), + ExtrinsicStatus::Pending => {} + } + } + } +} + +enum ExtrinsicStatus { + Included(String), + Rejected(String), + Pending, +} + +fn extrinsic_status(status: &Value) -> ExtrinsicStatus { + for key in ["finalized", "inBlock"] { + if let Some(hash) = status.get(key).and_then(Value::as_str) { + return ExtrinsicStatus::Included(hash.to_string()); + } + } + for key in ["invalid", "dropped", "usurped", "finalityTimeout"] { + if status.get(key).is_some() { + return ExtrinsicStatus::Rejected(key.to_string()); + } + } + ExtrinsicStatus::Pending +} + +fn value_to_params(value: Value) -> Result { + let Value::Array(values) = value else { + return Err("RPC params must be a JSON array".to_string()); + }; + let mut params = RpcParams::new(); + for value in values { + params.push(value).map_err(rpc_error_message)?; + } + Ok(params) +} + +fn decode_hex(value: &str) -> Result, String> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)) + .map_err(|err| format!("decode hex storage value: {err}")) +} + +fn rpc_error_message(error: subxt_rpcs::Error) -> String { + match error { + subxt_rpcs::Error::User(error) => error.message, + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{ExtrinsicStatus, extrinsic_status}; + + #[test] + fn in_block_status_completes_submission() { + let status = extrinsic_status(&json!({"inBlock": "0x1234"})); + + assert!(matches!(status, ExtrinsicStatus::Included(hash) if hash == "0x1234")); + } + + #[test] + fn finalized_status_completes_submission() { + let status = extrinsic_status(&json!({"finalized": "0xabcd"})); + + assert!(matches!(status, ExtrinsicStatus::Included(hash) if hash == "0xabcd")); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs new file mode 100644 index 00000000..a9a99b81 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -0,0 +1,247 @@ +//! StatementStore allowance slot selection. +//! +//! An allowance is claimed at `(period, seq)`. The slot is bound to a 32-byte +//! `SSS_SLOT` context; occupancy is read from +//! `Resources.StatementStoreAllowances[period][alias]`, where the alias is +//! derived from OUR bandersnatch entropy in that slot context. Mirrors +//! signing-bot `allowance.ts` / `allowance-slots.ts`. + +use sp_crypto_hashing::twox_128; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use super::extension::Metadata; +use super::ring::blake2_128_concat; +use super::rpc::RpcClient; + +/// StatementStore allowance period: one UTC day, in seconds. +pub const STATEMENT_STORE_PERIOD_SECONDS: u64 = 86_400; +/// Bulletin long-term-storage claim context prefix. +const LONG_TERM_STORAGE_CONTEXT_PREFIX: &[u8] = b"pop:polkadot.net/rsc-lts"; + +/// The current allowance period for `now_seconds`. +pub fn current_period(now_seconds: u64) -> u32 { + (now_seconds / STATEMENT_STORE_PERIOD_SECONDS) as u32 +} + +/// The current long-term-storage period for `now_seconds`. +pub fn current_long_term_storage_period( + now_seconds: u64, + period_duration: u32, +) -> Result { + if period_duration == 0 { + return Err("Resources.LongTermStoragePeriodDuration is zero".to_string()); + } + Ok((now_seconds / u64::from(period_duration)) as u32) +} + +/// Derive the 32-byte StatementStore slot context: +/// `"SSS_SLOT:" ‖ u32be(period) ‖ u32be(seq) ‖ 0x20 fill`. +pub fn derive_slot_context(period: u32, seq: u32) -> [u8; 32] { + let mut ctx = [0x20u8; 32]; + ctx[..9].copy_from_slice(b"SSS_SLOT:"); + ctx[9..13].copy_from_slice(&period.to_be_bytes()); + ctx[13..17].copy_from_slice(&seq.to_be_bytes()); + ctx +} + +/// Derive the 32-byte Bulletin long-term-storage slot context: +/// `"pop:polkadot.net/rsc-lts" ‖ u32be(period) ‖ counter ‖ zero fill`. +pub fn derive_long_term_storage_context(period: u32, counter: u8) -> [u8; 32] { + let mut ctx = [0u8; 32]; + ctx[..LONG_TERM_STORAGE_CONTEXT_PREFIX.len()].copy_from_slice(LONG_TERM_STORAGE_CONTEXT_PREFIX); + let offset = LONG_TERM_STORAGE_CONTEXT_PREFIX.len(); + ctx[offset..offset + 4].copy_from_slice(&period.to_be_bytes()); + ctx[offset + 4] = counter; + ctx +} + +/// The slot alias for our `entropy` at `(period, seq)`. +pub fn slot_alias(entropy: [u8; 32], period: u32, seq: u32) -> Result<[u8; 32], String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let context = derive_slot_context(period, seq); + BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("alias_in_context failed: {err:?}")) +} + +/// The long-term-storage slot alias for our `entropy` at `(period, counter)`. +pub fn long_term_storage_alias( + entropy: [u8; 32], + period: u32, + counter: u8, +) -> Result<[u8; 32], String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let context = derive_long_term_storage_context(period, counter); + BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("alias_in_context failed: {err:?}")) +} + +/// `Resources.StatementStoreAllowances[period][alias]` storage key. +/// key1 = Identity(u32be period); key2 = Blake2_128Concat(alias). +fn statement_store_allowance_key(period: u32, alias: &[u8; 32]) -> Vec { + [ + twox_128(b"Resources").as_slice(), + twox_128(b"StatementStoreAllowances").as_slice(), + &period.to_be_bytes(), + &blake2_128_concat(alias), + ] + .concat() +} + +/// `Resources.SpentLongTermStorageAliases[period][alias]` storage key. +/// key1 = Identity(u32be period); key2 = Blake2_128Concat(alias). +fn spent_long_term_storage_alias_key(period: u32, alias: &[u8; 32]) -> Vec { + [ + twox_128(b"Resources").as_slice(), + twox_128(b"SpentLongTermStorageAliases").as_slice(), + &period.to_be_bytes(), + &blake2_128_concat(alias), + ] + .concat() +} + +/// Max StatementStore slots per period from `Resources.LiteStmtStoreSlotsPerPeriod`. +fn max_slots(metadata: &Metadata) -> Result { + let bytes = metadata + .constant("Resources", "LiteStmtStoreSlotsPerPeriod") + .ok_or_else(|| "Resources.LiteStmtStoreSlotsPerPeriod constant missing".to_string())?; + let mut buf = [0u8; 4]; + let n = bytes.len().min(4); + buf[..n].copy_from_slice(&bytes[..n]); + Ok(u32::from_le_bytes(buf)) +} + +/// Max long-term-storage claims per period from +/// `Resources.LongTermStorageClaimsPerPeriod`. +fn long_term_storage_claims_per_period(metadata: &Metadata) -> Result { + metadata + .constant("Resources", "LongTermStorageClaimsPerPeriod") + .and_then(|bytes| bytes.first().copied()) + .ok_or_else(|| "Resources.LongTermStorageClaimsPerPeriod constant missing".to_string()) +} + +/// Long-term-storage period duration in seconds from +/// `Resources.LongTermStoragePeriodDuration`. +pub fn long_term_storage_period_duration(metadata: &Metadata) -> Result { + let bytes = metadata + .constant("Resources", "LongTermStoragePeriodDuration") + .ok_or_else(|| "Resources.LongTermStoragePeriodDuration constant missing".to_string())?; + let mut buf = [0u8; 4]; + let n = bytes.len().min(4); + buf[..n].copy_from_slice(&bytes[..n]); + Ok(u32::from_le_bytes(buf)) +} + +/// The account id occupying a slot entry, if the storage value is present. +/// Entry = `account_id(32) ‖ seq(u32 LE) ‖ since(u64 LE)`. +fn entry_account_id(bytes: &[u8]) -> Option<[u8; 32]> { + bytes.get(..32).map(|s| s.try_into().expect("32 bytes")) +} + +/// Outcome of scanning for a slot to register `target` in. +pub enum SlotSelection { + /// A free `seq` we should claim. + Free(u32), + /// `target` already holds `seq` this period; no registration needed. + AlreadyAllocated(u32), +} + +/// Scan slots `0..max` for `period`, returning the first non-excluded free seq +/// (or detecting that `target` already holds one). `entropy` is our +/// bandersnatch entropy. +pub async fn scan_slot_excluding( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + period: u32, + target: &[u8; 32], + excluded: &[u32], +) -> Result { + let max = max_slots(metadata)?; + let mut first_free: Option = None; + for seq in 0..max { + let alias = slot_alias(entropy, period, seq)?; + let key = statement_store_allowance_key(period, &alias); + match rpc.get_storage(&key).await.map_err(|e| e.to_string())? { + None => { + if first_free.is_none() && !excluded.contains(&seq) { + first_free = Some(seq); + } + } + Some(bytes) => { + if entry_account_id(&bytes) == Some(*target) { + return Ok(SlotSelection::AlreadyAllocated(seq)); + } + } + } + } + first_free + .map(SlotSelection::Free) + .ok_or_else(|| format!("no free StatementStore slot in period {period} (max {max})")) +} + +/// Scan long-term-storage aliases `0..max` for `period`, returning the first +/// free counter not listed in `excluded`. `entropy` is our bandersnatch entropy. +pub async fn scan_long_term_storage_counter_excluding( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + period: u32, + excluded: &[u8], +) -> Result { + let max = long_term_storage_claims_per_period(metadata)?; + for counter in 0..max { + if excluded.contains(&counter) { + continue; + } + let alias = long_term_storage_alias(entropy, period, counter)?; + let key = spent_long_term_storage_alias_key(period, &alias); + if rpc + .get_storage(&key) + .await + .map_err(|e| e.to_string())? + .is_none() + { + return Ok(counter); + } + } + Err(format!( + "no free long-term-storage slot in period {period} (max {max})" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slot_context_layout() { + let ctx = derive_slot_context(7, 3); + assert_eq!(&ctx[..9], b"SSS_SLOT:"); + assert_eq!(&ctx[9..13], &7u32.to_be_bytes()); + assert_eq!(&ctx[13..17], &3u32.to_be_bytes()); + assert!(ctx[17..].iter().all(|&b| b == 0x20)); + } + + #[test] + fn long_term_storage_context_layout() { + let ctx = derive_long_term_storage_context(7, 3); + assert_eq!(&ctx[..24], b"pop:polkadot.net/rsc-lts"); + assert_eq!(&ctx[24..28], &7u32.to_be_bytes()); + assert_eq!(ctx[28], 3); + assert!(ctx[29..].iter().all(|&b| b == 0)); + } + + #[test] + fn period_is_utc_day_index() { + assert_eq!(current_period(86_400 * 20_000 + 5), 20_000); + } + + #[test] + fn long_term_storage_period_uses_chain_duration() { + assert_eq!( + current_long_term_storage_period(1_209_600 * 20 + 5, 1_209_600).unwrap(), + 20, + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index a8adf8a9..1f597ff1 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -577,7 +577,9 @@ mod tests { #[test] fn statement_store_submit_posts_signed_statement_and_waits_for_ack() { let platform = Arc::new(StubPlatform { - rpc_responses: vec![r#"{"jsonrpc":"2.0","id":"truapi:1","result":"0xok"}"#.to_string()], + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":{"status":"new"}}"#.to_string(), + ], ..Default::default() }); let host = ProductRuntimeHost::new( diff --git a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs index d54ac0c9..3abbcf48 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs @@ -8,9 +8,12 @@ use std::sync::Arc; +use core::time::Duration; use serde_json::{Value, json}; + use subxt_rpcs::RpcClient; use subxt_rpcs::client::{RpcSubscription, rpc_params}; +use tracing::warn; use truapi_platform::{JsonRpcConnection, Platform}; use crate::host_logic::statement_store::{ @@ -20,6 +23,9 @@ use crate::host_logic::statement_store::{ use crate::host_rpc_client::HostRpcClient; use crate::subscription::Spawner; +const SSO_NO_ALLOWANCE_RETRY_ATTEMPTS: usize = 5; +const SSO_NO_ALLOWANCE_RETRY_DELAY: Duration = Duration::from_secs(1); + /// People-chain statement-store RPC client factory. #[derive(Clone)] pub(crate) struct StatementStoreRpc { @@ -62,6 +68,18 @@ impl StatementStoreRpc { submit(&rpc_client, statement).await } + /// Submit an SSO statement, tolerating the short propagation window after + /// an allowance registration is included but not yet visible to the + /// Statement Store RPC backend. + pub(super) async fn submit_sso( + &self, + statement: Vec, + label: &'static str, + ) -> Result<(), String> { + let rpc_client = self.client(label).await?; + submit_sso(&rpc_client, statement, label).await + } + /// Submit a SCALE-encoded statement without waiting for the JSON-RPC ack. pub(super) async fn submit_fire_and_forget( &self, @@ -109,16 +127,54 @@ pub(super) async fn subscribe_match_all( subscribe(rpc_client, TopicFilterKind::MatchAll, topics).await } -/// Submit a SCALE-encoded statement and wait for the JSON-RPC ack. +/// Submit a SCALE-encoded statement and confirm the store accepted it. +/// +/// `statement_submit` returns an RPC error only for internal failures; a +/// rejected or invalid statement (e.g. `NoAllowance`, `BadProof`) comes back as +/// `Ok(SubmitResult)`. Treat only `new`/`known` as success, so allowance/proof +/// rejections surface instead of being silently dropped. pub(super) async fn submit(rpc_client: &RpcClient, statement: Vec) -> Result<(), String> { - rpc_client + let result = rpc_client .request::( SUBMIT_STATEMENT_METHOD, rpc_params![format!("0x{}", hex::encode(&statement))], ) .await - .map(|_| ()) - .map_err(rpc_error_message) + .map_err(rpc_error_message)?; + match result.get("status").and_then(Value::as_str) { + Some("new") | Some("known") => Ok(()), + _ => Err(format!("statement_submit not accepted: {result}")), + } +} + +pub(super) async fn submit_sso( + rpc_client: &RpcClient, + statement: Vec, + label: &'static str, +) -> Result<(), String> { + for attempt in 1..=SSO_NO_ALLOWANCE_RETRY_ATTEMPTS { + match submit(rpc_client, statement.clone()).await { + Ok(()) => return Ok(()), + Err(reason) + if is_transient_no_allowance(&reason) + && attempt < SSO_NO_ALLOWANCE_RETRY_ATTEMPTS => + { + warn!( + label, + attempt, + max_attempts = SSO_NO_ALLOWANCE_RETRY_ATTEMPTS, + "SSO allowance not visible yet; retrying statement submission" + ); + futures_timer::Delay::new(SSO_NO_ALLOWANCE_RETRY_DELAY).await; + } + Err(reason) => return Err(reason), + } + } + unreachable!("the bounded SSO submit loop always returns") +} + +fn is_transient_no_allowance(reason: &str) -> bool { + reason.contains("noAllowance") } /// Statement-store topic filter encoded as JSON-RPC params. @@ -138,3 +194,18 @@ pub(super) fn rpc_error_message(error: subxt_rpcs::Error) -> String { other => other.to_string(), } } + +#[cfg(test)] +mod tests { + use super::is_transient_no_allowance; + + #[test] + fn identifies_no_allowance_submit_rejections_for_retry() { + assert!(is_transient_no_allowance( + r#"statement_submit not accepted: {"reason":"noAllowance","status":"rejected"}"# + )); + assert!(!is_transient_no_allowance( + r#"statement_submit not accepted: {"reason":"badProof","status":"rejected"}"# + )); + } +} diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 3f322a28..d6677218 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -424,10 +424,12 @@ pub(crate) fn subscribe_ack_frame(request_id: &str, subscription_id: &str) -> St } fn statement_submit_ack_frame(request_id: &str) -> String { + // Mirror the real `statement_submit` result shape (`SubmitResult`); `submit` + // treats only `new`/`known` as accepted. serde_json::json!({ "jsonrpc": "2.0", "id": request_id, - "result": "0xok", + "result": { "status": "new" }, }) .to_string() } diff --git a/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata.scale b/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata.scale new file mode 100644 index 0000000000000000000000000000000000000000..28a7ac3f92c946227e1e78d17ea74df9ad432b76 GIT binary patch literal 388952 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Ⓢ#AKM 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

~4^3XlPzM*xv+Rm|U(_2xwpnR=xxj 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 C)}pBZ literal 0 HcmV?d00001 diff --git a/rust/crates/truapi-host-cli/recordings/signing-host-diagnosis.tape b/rust/crates/truapi-host-cli/recordings/signing-host-diagnosis.tape new file mode 100644 index 00000000..35a03172 --- /dev/null +++ b/rust/crates/truapi-host-cli/recordings/signing-host-diagnosis.tape @@ -0,0 +1,37 @@ +Output "rust/crates/truapi-host-cli/recordings/signing-host-diagnosis.gif" + +Require bun +Require target/debug/truapi-host + +Set Shell "bash" +Set FontSize 18 +Set FontFamily "FiraCode Nerd Font" +Set Width 1200 +Set Height 720 +Set Padding 24 +Set Margin 16 +Set MarginFill "#11111b" +Set BorderRadius 10 +Set WindowBar "ColorfulRight" +Set Theme "Catppuccin Mocha" +Set Framerate 24 +Set PlaybackSpeed 6.0 +Set TypingSpeed 8ms + +Hide +Type "export PS1='❯ '" +Enter +Type "unset NO_COLOR" +Enter +Type "export FORCE_COLOR=1" +Enter +Type "DIAGNOSIS_BASE_PATH=${TRUAPI_VHS_SIGNING_BASE_PATH:-$(mktemp -d /tmp/truapi-vhs-signing.XXXXXX)}" +Enter +Type "clear" +Enter +Sleep 1s +Show + +Type "target/debug/truapi-host signing-host --base-path $DIAGNOSIS_BASE_PATH --product-id truapi-playground.dot --script rust/crates/truapi-host-cli/js/scripts/battery.ts --auto-accept; echo; echo 'Signing-host diagnosis complete'" +Enter +Sleep 90s diff --git a/rust/crates/truapi-host-cli/src/accounts.rs b/rust/crates/truapi-host-cli/src/accounts.rs new file mode 100644 index 00000000..84ddb18b --- /dev/null +++ b/rust/crates/truapi-host-cli/src/accounts.rs @@ -0,0 +1,703 @@ +use std::collections::BTreeSet; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; +use bip39::Mnemonic; +use fs2::FileExt; +use serde::{Deserialize, Serialize}; +use tracing::{debug, warn}; +use truapi_server::host_logic::product_account::{ + derive_sr25519_hard_path, product_public_key_to_address, +}; + +use crate::attestation; +use crate::network::NetworkConfig; +use truapi_server::statement_allowance as alloc; + +const ACCOUNT_STORE_FILE: &str = "accounts.json"; +const ACCOUNT_STORE_LOCK_FILE: &str = "accounts.json.lock"; +const DEFAULT_USERNAME_PREFIX: &str = "headless"; + +/// Signer material selected for a signing-host session. +#[derive(Debug, Clone)] +pub struct ResolvedSigner { + /// BIP-39 entropy backing the selected signer account. + pub entropy: Vec, + /// Stored account name when this came from `accounts.json`. + pub account_name: Option, + /// Lite username attested for the signer, when managed by the CLI. + pub lite_username: Option, + /// Whether the account was selected from the CLI-managed auto pool. + pub auto_managed: bool, +} + +/// Inputs for resolving a signing-host account. +#[derive(Debug, Clone)] +pub struct ResolveSignerConfig<'a> { + /// Directory containing the local account store. + pub base_path: &'a Path, + /// Network whose identity backend and People chain should be used. + pub network: NetworkConfig, + /// Explicit mnemonic. When present, the account store is not used. + pub mnemonic: Option, + /// Named stored account. Mutually exclusive with `mnemonic`. + pub account: Option, + /// Prefix for generated Lite usernames in auto mode. + pub lite_username_prefix: Option, +} + +/// Stored signer account record. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountRecord { + /// Stable local account name, for example `auto-1`. + pub name: String, + /// Network id this account belongs to. + pub network: String, + /// BIP-39 mnemonic for this local test signer. + pub mnemonic: String, + /// Lite username registered through the identity backend. + pub lite_username: String, + /// Hex-encoded `//wallet//sso` public key. + pub public_key_hex: String, + /// SS58 address for the `//wallet//sso` public key. + pub address: String, + /// Creation timestamp. + pub created_at_unix: u64, + /// Whether registration and ring readiness completed. + #[serde(default)] + pub attested: bool, + #[serde(default)] + exhausted_statement_periods: BTreeSet, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct AccountStoreData { + version: u32, + accounts: Vec, +} + +/// Local JSON account store for CLI-managed signer accounts. +pub struct AccountStore { + path: PathBuf, + data: AccountStoreData, +} + +struct AccountStoreLock { + file: fs::File, +} + +impl AccountStoreLock { + fn acquire(base_path: &Path) -> Result { + fs::create_dir_all(base_path).with_context(|| format!("create {}", base_path.display()))?; + let path = base_path.join(ACCOUNT_STORE_LOCK_FILE); + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&path) + .with_context(|| format!("open lock {}", path.display()))?; + file.lock_exclusive() + .with_context(|| format!("lock {}", path.display()))?; + Ok(Self { file }) + } +} + +impl Drop for AccountStoreLock { + fn drop(&mut self) { + let _ = self.file.unlock(); + } +} + +impl AccountStore { + pub fn load(base_path: &Path) -> Result { + let path = base_path.join(ACCOUNT_STORE_FILE); + let data = match fs::read_to_string(&path) { + Ok(text) => { + serde_json::from_str(&text).with_context(|| format!("decode {}", path.display()))? + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => AccountStoreData { + version: 1, + accounts: Vec::new(), + }, + Err(err) => return Err(err).with_context(|| format!("read {}", path.display())), + }; + Ok(Self { path, data }) + } + + pub fn save(&self) -> Result<()> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let text = serde_json::to_string_pretty(&self.data)?; + write_secret_file(&self.path, text.as_bytes()) + .with_context(|| format!("write {}", self.path.display())) + } + + pub fn get(&self, network_id: &str, name: &str) -> Option<&AccountRecord> { + self.data + .accounts + .iter() + .find(|record| record.network == network_id && record.name == name) + } + + fn upsert(&mut self, record: AccountRecord) { + if let Some(existing) = self + .data + .accounts + .iter_mut() + .find(|existing| existing.network == record.network && existing.name == record.name) + { + *existing = record; + return; + } + self.data.accounts.push(record); + } + + fn auto_candidate(&self, network_id: &str, period: u32) -> Option { + self.data + .accounts + .iter() + .find(|record| { + record.network == network_id + && record.attested + && !record.exhausted_statement_periods.contains(&period) + }) + .cloned() + } + + fn pending_auto_candidate(&self, network_id: &str) -> Option { + self.data + .accounts + .iter() + .find(|record| record.network == network_id && !record.attested) + .cloned() + } + + fn next_auto_name(&self, network_id: &str) -> String { + let mut index = 1usize; + loop { + let name = format!("auto-{index}"); + if !self + .data + .accounts + .iter() + .any(|record| record.network == network_id && record.name == name) + { + return name; + } + index += 1; + } + } + + pub fn mark_exhausted(&mut self, network_id: &str, name: &str, period: u32) -> Result<()> { + let Some(record) = self + .data + .accounts + .iter_mut() + .find(|record| record.network == network_id && record.name == name) + else { + return Ok(()); + }; + record.exhausted_statement_periods.insert(period); + self.save() + } +} + +pub async fn resolve_signer(config: ResolveSignerConfig<'_>) -> Result { + if let Some(mnemonic) = config.mnemonic { + let entropy = mnemonic_entropy(&mnemonic)?; + return Ok(ResolvedSigner { + entropy, + account_name: None, + lite_username: None, + auto_managed: false, + }); + } + + let _lock = AccountStoreLock::acquire(config.base_path)?; + let mut store = AccountStore::load(config.base_path)?; + if let Some(name) = config.account { + let record = store + .get(config.network.id, &name) + .cloned() + .with_context(|| format!("account {name:?} not found for {}", config.network.id))?; + let record = ensure_record_ready(&mut store, config.network, &record).await?; + return resolved_from_record(record, false); + } + + let period = current_statement_period()?; + if let Some(record) = store.auto_candidate(config.network.id, period) { + let record = ensure_record_ready(&mut store, config.network, &record).await?; + return resolved_from_record(record, true); + } + + if let Some(record) = store.pending_auto_candidate(config.network.id) { + let refreshed = ensure_record_ready(&mut store, config.network, &record).await?; + return resolved_from_record(refreshed, true); + } + + let record = create_auto_account( + &mut store, + config.network, + config + .lite_username_prefix + .as_deref() + .unwrap_or(DEFAULT_USERNAME_PREFIX), + ) + .await?; + resolved_from_record(record, true) +} + +/// Resolve an already-provisioned signer from local state without network +/// attestation or ring-membership checks. +pub fn resolve_cached_signer( + base_path: &Path, + network_id: &str, + account: Option<&str>, +) -> Result> { + let _lock = AccountStoreLock::acquire(base_path)?; + let store = AccountStore::load(base_path)?; + let (record, auto_managed) = if let Some(name) = account { + (store.get(network_id, name).cloned(), false) + } else { + let period = current_statement_period()?; + (store.auto_candidate(network_id, period), true) + }; + let Some(record) = + record.filter(|record| record.attested && resolved_lite_username(&record.lite_username)) + else { + return Ok(None); + }; + resolved_from_record(record, auto_managed).map(Some) +} + +pub fn mark_account_exhausted( + base_path: &Path, + network_id: &str, + name: &str, + period: u32, +) -> Result<()> { + let _lock = AccountStoreLock::acquire(base_path)?; + AccountStore::load(base_path)?.mark_exhausted(network_id, name, period) +} + +pub fn current_statement_period() -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock before UNIX epoch")? + .as_secs(); + Ok(alloc::slot::current_period(now)) +} + +async fn create_auto_account( + store: &mut AccountStore, + network: NetworkConfig, + username_prefix: &str, +) -> Result { + validate_username_prefix(username_prefix)?; + let name = store.next_auto_name(network.id); + let mnemonic = Mnemonic::generate(12) + .context("generate BIP-39 mnemonic")? + .to_string(); + let identity = identity_from_mnemonic(&mnemonic)?; + + for attempt in 0..8 { + let lite_username = generated_username(username_prefix, attempt); + if !attestation::lite_username_available(network.identity_backend_base, &lite_username) + .await + .with_context(|| format!("check lite username {lite_username:?} availability"))? + { + continue; + } + + let mut record = AccountRecord { + name: name.clone(), + network: network.id.to_string(), + mnemonic: mnemonic.clone(), + lite_username, + public_key_hex: format!("0x{}", hex::encode(identity.public_key)), + address: identity.address.clone(), + created_at_unix: now_unix(), + attested: false, + exhausted_statement_periods: BTreeSet::new(), + }; + store.upsert(record.clone()); + store.save()?; + + debug!( + account = %record.name, + network = %record.network, + lite_username = %record.lite_username, + address = %record.address, + "created auto signer account" + ); + + record.lite_username = attest_record(network, &record).await?; + wait_for_ring_membership(network.people_ws, &identity.entropy).await?; + record.attested = true; + store.upsert(record.clone()); + store.save()?; + return Ok(record); + } + + bail!("could not find an available lite username for prefix {username_prefix:?}"); +} + +async fn ensure_record_ready( + store: &mut AccountStore, + network: NetworkConfig, + record: &AccountRecord, +) -> Result { + let identity = identity_from_mnemonic(&record.mnemonic)?; + let mut record = record.clone(); + if !record.attested { + record.lite_username = attest_record(network, &record).await?; + record.attested = true; + } else { + record.lite_username = + attestation::registered_lite_username(network.people_ws, &identity.entropy) + .await + .with_context(|| format!("resolve Lite username for account {}", record.name))?; + } + if store + .get(network.id, &record.name) + .is_none_or(|stored| stored.lite_username != record.lite_username || !stored.attested) + { + store.upsert(record.clone()); + store.save()?; + } + wait_for_ring_membership(network.people_ws, &identity.entropy).await?; + Ok(record) +} + +async fn attest_record(network: NetworkConfig, record: &AccountRecord) -> Result { + let entropy = mnemonic_entropy(&record.mnemonic)?; + let lite_username = attestation::attest(&attestation::AttestConfig { + backend_base: network.identity_backend_base.to_string(), + people_ws: network.people_ws.to_string(), + entropy, + username_base: record.lite_username.clone(), + }) + .await + .with_context(|| format!("attest account {}", record.name))?; + debug!( + account = %record.name, + requested_lite_username = %record.lite_username, + assigned_lite_username = %lite_username, + "signer account attested" + ); + Ok(lite_username) +} + +fn resolved_lite_username(username: &str) -> bool { + username + .rsplit_once('.') + .is_some_and(|(name, discriminator)| !name.is_empty() && !discriminator.is_empty()) +} + +async fn wait_for_ring_membership(people_ws: &str, entropy: &[u8]) -> Result<()> { + const MAX_ATTEMPTS: usize = 10; + const SLEEP: Duration = Duration::from_secs(4); + + let bandersnatch = alloc::bandersnatch_entropy(entropy); + let mut metadata = None; + for attempt in 1..=MAX_ATTEMPTS { + crate::terminal_ui::update_activity( + "signer", + "Setting up signer", + Some(format!( + "Waiting for LitePeople ring membership · attempt {attempt}/{MAX_ATTEMPTS}" + )), + crate::terminal_ui::ActivityState::Running, + ); + let rpc = match alloc::rpc::RpcClient::connect(people_ws).await { + Ok(rpc) => rpc, + Err(err) => { + warn!( + attempt, + max_attempts = MAX_ATTEMPTS, + error = %err, + "could not connect while checking LitePeople ring membership" + ); + sleep_ring_poll(attempt, MAX_ATTEMPTS, SLEEP).await; + continue; + } + }; + if metadata.is_none() { + match alloc::fetch_metadata(&rpc).await { + Ok(fetched) => metadata = Some(fetched), + Err(err) => { + warn!( + attempt, + max_attempts = MAX_ATTEMPTS, + error = %err, + "could not fetch metadata while checking LitePeople ring membership" + ); + sleep_ring_poll(attempt, MAX_ATTEMPTS, SLEEP).await; + continue; + } + } + } + let metadata_ref = metadata.as_ref().expect("metadata is initialized"); + let current = match alloc::ring::read_current_ring_index(&rpc).await { + Ok(current) => current, + Err(err) => { + warn!( + attempt, + max_attempts = MAX_ATTEMPTS, + error = %err, + "could not read current LitePeople ring" + ); + sleep_ring_poll(attempt, MAX_ATTEMPTS, SLEEP).await; + continue; + } + }; + match alloc::find_including_ring(&rpc, metadata_ref, bandersnatch, current).await { + Ok(Some(_)) => { + crate::terminal_ui::update_activity( + "signer", + "Setting up signer", + Some("LitePeople ring membership ready".to_string()), + crate::terminal_ui::ActivityState::Running, + ); + return Ok(()); + } + Ok(None) => {} + Err(err) => { + warn!( + attempt, + max_attempts = MAX_ATTEMPTS, + error = %err, + "could not scan LitePeople rings" + ); + } + } + sleep_ring_poll(attempt, MAX_ATTEMPTS, SLEEP).await; + } + bail!("signer account did not appear in a LitePeople ring"); +} + +async fn sleep_ring_poll(attempt: usize, max_attempts: usize, sleep: Duration) { + if attempt < max_attempts { + debug!( + attempt, + max_attempts, "signer account not in a LitePeople ring yet" + ); + tokio::time::sleep(sleep).await; + } +} + +fn resolved_from_record(record: AccountRecord, auto_managed: bool) -> Result { + let entropy = mnemonic_entropy(&record.mnemonic)?; + Ok(ResolvedSigner { + entropy, + account_name: Some(record.name), + lite_username: Some(record.lite_username), + auto_managed, + }) +} + +struct SignerIdentity { + entropy: Vec, + public_key: [u8; 32], + address: String, +} + +fn identity_from_mnemonic(mnemonic: &str) -> Result { + let entropy = mnemonic_entropy(mnemonic)?; + let candidate = derive_sr25519_hard_path(&entropy, &["wallet", "sso"]) + .map_err(|err| anyhow::anyhow!("//wallet//sso derivation failed: {err}"))?; + let public_key = candidate.public.to_bytes(); + Ok(SignerIdentity { + entropy, + public_key, + address: product_public_key_to_address(public_key), + }) +} + +fn mnemonic_entropy(mnemonic: &str) -> Result> { + Ok(Mnemonic::parse(mnemonic.trim()) + .context("invalid BIP-39 mnemonic")? + .to_entropy()) +} + +fn validate_username_prefix(prefix: &str) -> Result<()> { + if prefix.is_empty() || !prefix.bytes().all(|byte| byte.is_ascii_lowercase()) { + bail!("--lite-username-prefix must contain lowercase ASCII letters only"); + } + Ok(()) +} + +fn generated_username(prefix: &str, attempt: usize) -> String { + let mut username = prefix.to_string(); + let mut seed = now_unix() + ^ u64::from(std::process::id()) + ^ ((attempt as u64) << 32) + ^ (prefix.len() as u64); + while username.len() < prefix.len().max(6) + 6 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + username.push((b'a' + (seed % 26) as u8) as char); + } + username +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn write_secret_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let tmp_path = temp_path(path); + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(&tmp_path)?; + file.write_all(bytes)?; + fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600))?; + file.sync_all()?; + drop(file); + fs::rename(&tmp_path, path)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + sync_parent(path) + } + #[cfg(not(unix))] + { + fs::write(&tmp_path, bytes)?; + let _ = fs::remove_file(path); + fs::rename(&tmp_path, path) + } +} + +fn temp_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(ACCOUNT_STORE_FILE); + path.with_file_name(format!(".{file_name}.{}.tmp", std::process::id())) +} + +#[cfg(unix)] +fn sync_parent(path: &Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + fs::File::open(parent)?.sync_all()?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + const MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + fn record(name: &str, network: &str, attested: bool) -> AccountRecord { + AccountRecord { + name: name.to_string(), + network: network.to_string(), + mnemonic: MNEMONIC.to_string(), + lite_username: format!("{name}lite.01"), + public_key_hex: "0x00".to_string(), + address: "5GrwvaEF5zXb26Fz9rcQpDWSKfwVwqNxyvE9uZunJMtBEw2s".to_string(), + created_at_unix: 1, + attested, + exhausted_statement_periods: BTreeSet::new(), + } + } + + #[test] + fn auto_candidate_skips_pending_and_exhausted_accounts() { + let mut store = AccountStore { + path: PathBuf::from("accounts.json"), + data: AccountStoreData::default(), + }; + let mut exhausted = record("auto-1", "paseo-next-v2", true); + exhausted.exhausted_statement_periods.insert(7); + store.upsert(exhausted); + store.upsert(record("auto-2", "paseo-next-v2", false)); + store.upsert(record("auto-3", "paseo-next-v2", true)); + + assert_eq!( + store + .auto_candidate("paseo-next-v2", 7) + .map(|record| record.name), + Some("auto-3".to_string()) + ); + } + + #[test] + fn pending_auto_candidate_reuses_failed_onboarding_record() { + let mut store = AccountStore { + path: PathBuf::from("accounts.json"), + data: AccountStoreData::default(), + }; + store.upsert(record("auto-1", "paseo-next-v2", false)); + store.upsert(record("auto-2", "other", false)); + + assert_eq!( + store + .pending_auto_candidate("paseo-next-v2") + .map(|record| record.name), + Some("auto-1".to_string()) + ); + } + + #[test] + fn save_roundtrips_account_store() -> Result<()> { + let dir = tempdir()?; + let mut store = AccountStore::load(dir.path())?; + store.upsert(record("auto-1", "paseo-next-v2", true)); + store.save()?; + + let loaded = AccountStore::load(dir.path())?; + + assert_eq!( + loaded + .get("paseo-next-v2", "auto-1") + .map(|record| record.name.as_str()), + Some("auto-1") + ); + assert!(!temp_path(&dir.path().join(ACCOUNT_STORE_FILE)).exists()); + Ok(()) + } + + #[test] + fn cached_signer_resolves_without_network_access() -> Result<()> { + let dir = tempdir()?; + let mut store = AccountStore::load(dir.path())?; + store.upsert(record("auto-1", "paseo-next-v2", true)); + store.save()?; + + let signer = + resolve_cached_signer(dir.path(), "paseo-next-v2", None)?.expect("cached signer"); + + assert_eq!(signer.account_name.as_deref(), Some("auto-1")); + assert_eq!(signer.lite_username.as_deref(), Some("auto-1lite.01")); + assert!(signer.auto_managed); + Ok(()) + } + + #[test] + fn cached_signer_ignores_legacy_username_base() -> Result<()> { + let dir = tempdir()?; + let mut store = AccountStore::load(dir.path())?; + let mut stale = record("auto-1", "paseo-next-v2", true); + stale.lite_username = "headlessabcdef".to_string(); + store.upsert(stale); + store.save()?; + + assert!(resolve_cached_signer(dir.path(), "paseo-next-v2", None)?.is_none()); + Ok(()) + } +} diff --git a/rust/crates/truapi-host-cli/src/attestation.rs b/rust/crates/truapi-host-cli/src/attestation.rs new file mode 100644 index 00000000..1011f7c7 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/attestation.rs @@ -0,0 +1,278 @@ +//! Lite-username attestation against the People-chain identity backend. +//! +//! Ports signing-bot `attestation.ts`: fetch the backend verifier, build the +//! client proofs (`truapi_server::host_logic::attestation`), POST them to +//! `/usernames`, then poll People-chain `Resources.Consumers` until the record +//! lands. Registers the signing host's root account so the paired host can +//! resolve its username via `get_user_id`. + +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use serde_json::{Value, json}; +use subxt_rpcs::client::{RpcClient, rpc_params}; +use tracing::{debug, warn}; +use truapi_server::host_logic::attestation::build_lite_registration; +use truapi_server::host_logic::identity::{ + decode_people_identity, resources_consumers_storage_key, +}; +use truapi_server::host_logic::product_account::{ + derive_root_keypair_from_entropy, derive_sr25519_hard_path, product_public_key_to_address, +}; + +/// Inputs for one attestation run. +pub struct AttestConfig { + /// Identity backend base URL including `/api/v1`. + pub backend_base: String, + /// People-chain WebSocket URL for the `Resources.Consumers` poll. + pub people_ws: String, + /// BIP-39 entropy of the signing host's root account. + pub entropy: Vec, + /// Requested lite username base (6+ lowercase letters, no digits). + pub username_base: String, +} + +/// Check whether a lite username base is available through the identity +/// backend. The username must be the base form without the digit suffix. +pub async fn lite_username_available(backend_base: &str, username_base: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + let url = format!("{backend_base}/usernames/available"); + let body = json!({ "usernames": [username_base] }); + let response = client + .post(&url) + .json(&body) + .send() + .await + .with_context(|| format!("POST {url}"))? + .error_for_status() + .with_context(|| format!("username availability check failed for {username_base}"))?; + let body: Value = response + .json() + .await + .context("decoding availability response")?; + Ok(body + .get(username_base) + .and_then(Value::as_str) + .is_some_and(|status| status == "AVAILABLE")) +} + +/// Register (or confirm) the signing host's lite username and wait until the +/// People-chain `Resources.Consumers` record exists. Returns the Lite username +/// assigned on chain (including its discriminator). +pub async fn attest(config: &AttestConfig) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + + let verifier = fetch_verifier(&client, &config.backend_base).await?; + let registration = build_lite_registration(&config.entropy, verifier, &config.username_base) + .map_err(|reason| anyhow::anyhow!("failed to build registration params: {reason}"))?; + debug!( + candidate = %registration.candidate_account_id, + "attesting lite username '{}'", + config.username_base + ); + + submit_registration( + &client, + &config.backend_base, + &config.username_base, + ®istration, + ) + .await?; + + let storage_key = format!( + "0x{}", + hex::encode(resources_consumers_storage_key( + ®istration.candidate_public_key + )) + ); + let identity = wait_for_consumer_record(&config.people_ws, &storage_key).await?; + debug!("lite username registered and confirmed on-chain"); + identity + .lite_username + .context("registered People-chain identity has no Lite username") +} + +/// Resolve the on-chain Lite username for an already-attested signer. +/// +/// Older CLI account records stored the requested username base rather than +/// the final `name.discriminator` assigned by the People chain. Reading the +/// consumer record repairs those records without re-attesting the account. +pub async fn registered_lite_username(people_ws: &str, entropy: &[u8]) -> Result { + let wallet_sso = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) + .map_err(|err| anyhow::anyhow!("//wallet//sso derivation failed: {err}"))?; + let storage_key = format!( + "0x{}", + hex::encode(resources_consumers_storage_key( + &wallet_sso.public.to_bytes() + )) + ); + let value = query_storage(people_ws, &storage_key) + .await? + .context("attested signer has no Resources.Consumers record")?; + decode_identity_hex(&value)? + .lite_username + .context("registered People-chain identity has no Lite username") +} + +/// Probe the People chain for which derivation of `entropy` (bare root, +/// `//wallet`, `//wallet//sso`) has a `Resources.Consumers` record, printing +/// the account and decoded username. Used to confirm a pre-onboarded account. +pub async fn check_identity(people_ws: &str, entropy: &[u8]) -> Result<()> { + let root = derive_root_keypair_from_entropy(entropy) + .map_err(|err| anyhow::anyhow!("invalid entropy: {err}"))?; + let wallet = derive_sr25519_hard_path(entropy, &["wallet"]) + .map_err(|err| anyhow::anyhow!("//wallet derivation failed: {err}"))?; + let wallet_sso = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) + .map_err(|err| anyhow::anyhow!("//wallet//sso derivation failed: {err}"))?; + + for (label, public) in [ + ("", root.public.to_bytes()), + ("//wallet", wallet.public.to_bytes()), + ("//wallet//sso", wallet_sso.public.to_bytes()), + ] { + let key = format!( + "0x{}", + hex::encode(resources_consumers_storage_key(&public)) + ); + let address = product_public_key_to_address(public); + match query_storage(people_ws, &key).await { + Ok(Some(value)) => { + let decoded = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) + .ok() + .and_then(|bytes| decode_people_identity(&bytes).ok()); + let username = decoded + .and_then(|id| id.full_username.or(id.lite_username)) + .unwrap_or_else(|| "".to_string()); + println!("IDENTITY_FOUND path={label} account={address} username={username}"); + } + Ok(None) => println!("IDENTITY_NONE path={label} account={address}"), + Err(err) => println!("IDENTITY_ERROR path={label} account={address} error={err}"), + } + } + Ok(()) +} + +async fn fetch_verifier(client: &reqwest::Client, backend_base: &str) -> Result<[u8; 32]> { + let url = format!("{backend_base}/attester"); + let body: Value = client + .get(&url) + .send() + .await + .with_context(|| format!("GET {url}"))? + .error_for_status()? + .json() + .await + .context("decoding attester response")?; + let hex_value = body + .get("attester") + .and_then(Value::as_str) + .context("attester response missing 'attester' field")?; + let bytes = hex::decode(hex_value.strip_prefix("0x").unwrap_or(hex_value)) + .context("attester is not valid hex")?; + <[u8; 32]>::try_from(bytes) + .map_err(|bytes| anyhow::anyhow!("attester must be 32 bytes, got {}", bytes.len())) +} + +async fn submit_registration( + client: &reqwest::Client, + backend_base: &str, + username_base: &str, + reg: &truapi_server::host_logic::attestation::LiteRegistration, +) -> Result<()> { + let url = format!("{backend_base}/usernames"); + let body = json!({ + "username": username_base, + "candidateAccountId": reg.candidate_account_id, + "candidateSignature": hex0x(®.candidate_signature), + "ringVrfKey": hex0x(®.ring_vrf_key), + "proofOfOwnership": hex0x(®.proof_of_ownership), + "identifierKey": hex0x(®.identifier_key), + "consumerRegistrationSignature": hex0x(®.consumer_registration_signature), + }); + let response = client + .post(&url) + .json(&body) + .send() + .await + .with_context(|| format!("POST {url}"))?; + let status = response.status(); + if status.is_success() { + let text = response.text().await.unwrap_or_default(); + debug!(%status, body = %text, "POST /usernames accepted"); + return Ok(()); + } + let text = response.text().await.unwrap_or_default(); + // Already-registered is a soft success; the on-chain poll confirms it. + if text.contains("already") || text.contains("AlreadyRegistered") || text.contains("duplicate") + { + warn!(%status, "username already registered; confirming on-chain"); + return Ok(()); + } + bail!("username registration failed ({status}): {text}"); +} + +fn hex0x(bytes: &[u8]) -> String { + format!("0x{}", hex::encode(bytes)) +} + +async fn wait_for_consumer_record( + people_ws: &str, + storage_key: &str, +) -> Result { + // First-time lite registration is backend-async and can lag the HTTP + // response. The record is permanent once written, so later runs resolve on + // the first poll. + const MAX_ATTEMPTS: usize = 10; + for attempt in 1..=MAX_ATTEMPTS { + match query_storage(people_ws, storage_key).await { + Ok(Some(value)) => { + crate::terminal_ui::update_activity( + "signer", + "Setting up signer", + Some("People-chain identity ready".to_string()), + crate::terminal_ui::ActivityState::Running, + ); + return decode_identity_hex(&value); + } + Ok(None) => { + crate::terminal_ui::update_activity( + "signer", + "Setting up signer", + Some(format!( + "Waiting for People-chain identity · attempt {attempt}/{MAX_ATTEMPTS}" + )), + crate::terminal_ui::ActivityState::Running, + ); + debug!("Resources.Consumers poll {attempt}/{MAX_ATTEMPTS}: empty"); + } + Err(err) => warn!(%err, "Resources.Consumers poll attempt {attempt} failed"), + } + if attempt < MAX_ATTEMPTS { + tokio::time::sleep(Duration::from_secs(4)).await; + } + } + bail!("Resources.Consumers record did not appear after attestation") +} + +fn decode_identity_hex(value: &str) -> Result { + let bytes = hex::decode(value.strip_prefix("0x").unwrap_or(value)) + .context("Resources.Consumers value is not valid hex")?; + decode_people_identity(&bytes).map_err(anyhow::Error::msg) +} + +/// One `state_getStorage` request over a fresh RPC connection; returns the value +/// hex when present. +async fn query_storage(people_ws: &str, storage_key: &str) -> Result> { + let rpc = RpcClient::from_insecure_url(people_ws) + .await + .with_context(|| format!("connect {people_ws}"))?; + let value = rpc + .request::("state_getStorage", rpc_params![storage_key]) + .await + .context("rpc state_getStorage")?; + Ok(value.as_str().map(str::to_string)) +} diff --git a/rust/crates/truapi-host-cli/src/chain.rs b/rust/crates/truapi-host-cli/src/chain.rs new file mode 100644 index 00000000..5fbf94eb --- /dev/null +++ b/rust/crates/truapi-host-cli/src/chain.rs @@ -0,0 +1,240 @@ +//! Native WebSocket `ChainProvider` / `JsonRpcConnection`. +//! +//! The headless hosts reach the real People-chain statement store over +//! WebSocket JSON-RPC (the same node an iOS/web client uses). Every `connect` +//! opens a fresh socket; the runtime's `HostRpcClient` sits on top and speaks +//! statement-store RPC. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use futures::stream::BoxStream; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::{broadcast, mpsc}; +use tokio_stream::wrappers::BroadcastStream; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; +use tracing::{debug, warn}; +use truapi::latest as api; +use truapi_platform::{ChainProvider, JsonRpcConnection}; + +use crate::network::ChainEndpoint; + +/// Broadcast backlog for inbound JSON-RPC frames per connection. +const INBOUND_CHANNEL_CAPACITY: usize = 1024; + +/// Chain provider that maps a requested genesis hash to a WebSocket endpoint. +/// +/// The all-zero genesis (the headless SSO sentinel) and any unmapped genesis +/// fall back to the People-chain statement store. Host-required routes such as +/// Bulletin are always enabled; optional product Chain routes remain opt-in. +pub struct WsChainProvider { + fallback_url: String, + by_genesis: HashMap<[u8; 32], String>, +} + +impl WsChainProvider { + pub fn new(fallback_url: impl Into, live_chain_endpoints: &[ChainEndpoint]) -> Self { + let live_chain_routing = std::env::var("E2E_LIVE_CHAIN").as_deref() == Ok("1"); + Self::with_live_chain_routing(fallback_url, live_chain_endpoints, live_chain_routing) + } + + fn with_live_chain_routing( + fallback_url: impl Into, + live_chain_endpoints: &[ChainEndpoint], + live_chain_routing: bool, + ) -> Self { + // People remains the fallback for the SSO sentinel. Bulletin is a + // required host dependency for preimage submission and must never be + // gated by the product-facing Chain/* test switch. + let by_genesis = live_chain_endpoints + .iter() + .filter(|endpoint| endpoint.required_for_host || live_chain_routing) + .map(|endpoint| (endpoint.genesis, endpoint.ws.to_string())) + .collect(); + Self { + fallback_url: fallback_url.into(), + by_genesis, + } + } + + fn url_for(&self, genesis_hash: &[u8; 32]) -> &str { + self.by_genesis + .get(genesis_hash) + .map(String::as_str) + .unwrap_or(&self.fallback_url) + } +} + +#[async_trait] +impl ChainProvider for WsChainProvider { + async fn connect( + &self, + genesis_hash: [u8; 32], + ) -> Result, api::GenericError> { + let url = self.url_for(&genesis_hash); + debug!(genesis = %hex::encode(genesis_hash), %url, "chain connect"); + let connection = WsJsonRpcConnection::connect(url) + .await + .map_err(|reason| api::GenericError { reason })?; + Ok(Box::new(connection)) + } +} + +/// One WebSocket JSON-RPC connection: outbound requests are queued to a writer +/// task, inbound frames are broadcast to every `responses()` stream. +pub struct WsJsonRpcConnection { + outbound: mpsc::UnboundedSender, + inbound: broadcast::Sender, + /// Receiver created before the reader task starts. The first response + /// stream takes it so an immediate RPC response cannot race subscription + /// setup and disappear while the broadcast channel has no receivers. + initial_inbound: Mutex>>, + closed: Arc, +} + +impl WsJsonRpcConnection { + async fn connect(url: &str) -> Result { + let (stream, _response) = connect_async(url) + .await + .map_err(|err| format!("statement-store websocket connect failed: {err}"))?; + let (mut write, mut read) = stream.split(); + let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::(); + let (inbound_tx, initial_inbound) = broadcast::channel(INBOUND_CHANNEL_CAPACITY); + let closed = Arc::new(AtomicBool::new(false)); + + tokio::spawn(async move { + while let Some(message) = outbound_rx.recv().await { + if write.send(message).await.is_err() { + break; + } + } + let _ = write.close().await; + }); + + let reader_inbound = inbound_tx.clone(); + let reader_closed = closed.clone(); + tokio::spawn(async move { + while let Some(message) = read.next().await { + match message { + Ok(Message::Text(text)) => { + let _ = reader_inbound.send(text.to_string()); + } + Ok(Message::Binary(bytes)) => { + if let Ok(text) = String::from_utf8(bytes.to_vec()) { + let _ = reader_inbound.send(text); + } + } + Ok(Message::Close(_)) | Err(_) => break, + Ok(_) => {} + } + } + reader_closed.store(true, Ordering::Release); + }); + + Ok(Self { + outbound: outbound_tx, + inbound: inbound_tx, + initial_inbound: Mutex::new(Some(initial_inbound)), + closed, + }) + } +} + +impl JsonRpcConnection for WsJsonRpcConnection { + fn send(&self, request: String) { + if self.closed.load(Ordering::Acquire) { + return; + } + let _ = self.outbound.send(Message::Text(request)); + } + + fn responses(&self) -> BoxStream<'static, String> { + let receiver = self + .initial_inbound + .lock() + .expect("initial chain response receiver mutex poisoned") + .take() + .unwrap_or_else(|| self.inbound.subscribe()); + BroadcastStream::new(receiver) + .filter_map(|item| async move { + match item { + Ok(response) => Some(response), + Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged( + dropped, + )) => { + warn!(dropped, "chain response subscriber lagged"); + None + } + } + }) + .boxed() + } + + fn close(&self) { + self.closed.store(true, Ordering::Release); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::network::Network; + + #[test] + fn first_response_stream_receives_frames_buffered_during_setup() { + let (outbound, _outbound_rx) = mpsc::unbounded_channel(); + let (inbound, initial_inbound) = broadcast::channel(INBOUND_CHANNEL_CAPACITY); + let connection = WsJsonRpcConnection { + outbound, + inbound: inbound.clone(), + initial_inbound: Mutex::new(Some(initial_inbound)), + closed: Arc::new(AtomicBool::new(false)), + }; + + inbound + .send(r#"{"jsonrpc":"2.0","id":1,"result":"ready"}"#.to_string()) + .expect("initial receiver keeps the frame buffered"); + + let mut responses = connection.responses(); + let frame = futures::executor::block_on(responses.next()).expect("buffered response"); + assert_eq!(frame, r#"{"jsonrpc":"2.0","id":1,"result":"ready"}"#); + } + + #[test] + fn required_bulletin_route_is_enabled_without_optional_live_chains() { + let network = Network::PaseoNextV2.config(); + let provider = WsChainProvider::with_live_chain_routing( + network.people_ws, + network.live_chain_endpoints, + false, + ); + + assert_eq!( + provider.url_for(&network.bulletin_genesis), + network.bulletin_ws + ); + assert_eq!(provider.url_for(&network.people_genesis), network.people_ws); + assert_eq!( + provider.url_for(&network.live_chain_endpoints[0].genesis), + network.people_ws + ); + } + + #[test] + fn optional_live_chain_routes_are_enabled_by_the_test_switch() { + let network = Network::PaseoNextV2.config(); + let provider = WsChainProvider::with_live_chain_routing( + network.people_ws, + network.live_chain_endpoints, + true, + ); + + assert_eq!( + provider.url_for(&network.live_chain_endpoints[0].genesis), + network.live_chain_endpoints[0].ws + ); + } +} diff --git a/rust/crates/truapi-host-cli/src/frame_server.rs b/rust/crates/truapi-host-cli/src/frame_server.rs new file mode 100644 index 00000000..fac873fc --- /dev/null +++ b/rust/crates/truapi-host-cli/src/frame_server.rs @@ -0,0 +1,266 @@ +//! Product-frame WebSocket bridge for the pairing host. +//! +//! Each WebSocket connection is one product: inbound binary frames are pushed +//! into a [`ProductRuntime`] and its outgoing frames are written back as +//! binary messages. One binary WS message carries exactly one SCALE +//! `ProtocolMessage`, matching the browser transport's framing. + +use std::net::SocketAddr; +use std::sync::{Arc, RwLock}; + +use anyhow::{Context, Result}; +use futures_util::{SinkExt, StreamExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{mpsc, watch}; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::tungstenite::Message; +use tracing::{debug, warn}; +use truapi_server::{ + FrameSink, PairingHostRuntime, ProductContext, ProductRuntime, SigningHostRuntime, +}; + +/// Process-local product selection shared by the command loop and frame server. +pub struct ProductSelection { + current: watch::Sender, +} + +impl ProductSelection { + /// Validate and normalize the initial product id. + pub fn new(product_id: String) -> Result> { + let product = ProductContext::new(product_id) + .map_err(|error| anyhow::anyhow!("invalid product id: {error}"))?; + let (current, _) = watch::channel(product); + Ok(Arc::new(Self { current })) + } + + /// Return the normalized current product id. + pub fn current(&self) -> String { + self.current.borrow().product_id.clone() + } + + /// Select a validated product, returning whether the selection changed. + pub fn select(&self, product_id: String) -> Result { + let product = ProductContext::new(product_id) + .map_err(|error| anyhow::anyhow!("invalid product id: {error}"))?; + Ok(self.current.send_if_modified(|current| { + if current == &product { + false + } else { + *current = product; + true + } + })) + } + + fn subscribe(&self) -> watch::Receiver { + self.current.subscribe() + } +} + +pub trait ProductRuntimeFactory: Send + Sync + 'static { + fn product_runtime(&self, product: ProductContext, sink: Arc) -> ProductRuntime; + + /// Subscribe to a signal that invalidates existing product connections. + fn connection_reset(&self) -> Option> { + None + } +} + +impl ProductRuntimeFactory for PairingHostRuntime { + fn product_runtime(&self, product: ProductContext, sink: Arc) -> ProductRuntime { + PairingHostRuntime::product_runtime(self, product, sink) + } +} + +impl ProductRuntimeFactory for SigningHostRuntime { + fn product_runtime(&self, product: ProductContext, sink: Arc) -> ProductRuntime { + SigningHostRuntime::product_runtime(self, product, sink) + } +} + +/// Signing runtime factory whose active session can be replaced without +/// restarting the frame listener. +pub struct SwitchableSigningRuntime { + current: RwLock>, + generation: watch::Sender, +} + +impl SwitchableSigningRuntime { + pub fn new(runtime: Arc) -> Arc { + let (generation, _) = watch::channel(0); + Arc::new(Self { + current: RwLock::new(runtime), + generation, + }) + } + + /// Replace the runtime and disconnect every product using the old one. + pub fn replace(&self, runtime: Arc) { + *self.current.write().expect("runtime lock poisoned") = runtime; + self.generation + .send_modify(|generation| *generation = generation.wrapping_add(1)); + } +} + +impl ProductRuntimeFactory for SwitchableSigningRuntime { + fn product_runtime(&self, product: ProductContext, sink: Arc) -> ProductRuntime { + self.current + .read() + .expect("runtime lock poisoned") + .product_runtime(product, sink) + } + + fn connection_reset(&self) -> Option> { + Some(self.generation.subscribe()) + } +} + +/// Frame sink that writes each outgoing protocol frame as one binary message. +struct WsFrameSink { + outbound: mpsc::UnboundedSender, +} + +impl FrameSink for WsFrameSink { + fn emit_frame(&self, frame: Vec) { + let _ = self.outbound.send(Message::Binary(frame)); + } +} + +/// Bind the product-frame listener on `addr`. +pub async fn bind(addr: SocketAddr) -> Result { + TcpListener::bind(addr) + .await + .with_context(|| format!("frame server failed to bind {addr}")) +} + +/// Accept product-frame connections on `listener` for `product_id` until +/// cancelled. +/// +/// Each connection is driven independently on the Tokio worker pool. The +/// shared dispatcher contract requires `Send` futures, while the WASM adapter +/// may still poll those futures on its single-threaded local executor. +pub async fn accept_loop( + runtime: Arc, + product: Arc, + listener: TcpListener, +) -> Result<()> { + let bound = listener.local_addr()?; + let product_id = product.current(); + debug!(%bound, %product_id, "product frame server listening"); + loop { + let (stream, peer) = match listener.accept().await { + Ok(accepted) => accepted, + Err(err) => { + warn!(%err, "product frame accept failed"); + continue; + } + }; + let runtime = runtime.clone(); + let product = product.clone(); + tokio::spawn(async move { + if let Err(err) = serve_connection(runtime, product, stream).await { + debug!(%peer, %err, "frame connection ended"); + } + }); + } +} + +async fn serve_connection( + runtime: Arc, + selected_product: Arc, + stream: TcpStream, +) -> Result<()> { + // Subscribe before resolving the runtime so a concurrent replacement can + // only cause an extra reconnect, never leave a connection on stale state. + let mut reset = runtime.connection_reset(); + let mut product_updates = selected_product.subscribe(); + let ws = accept_async(stream).await?; + let (mut write, mut read) = ws.split(); + let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::(); + + let writer = tokio::spawn(async move { + while let Some(message) = outbound_rx.recv().await { + if write.send(message).await.is_err() { + break; + } + } + }); + + let product = product_updates.borrow().clone(); + let sink = Arc::new(WsFrameSink { + outbound: outbound_tx.clone(), + }); + let product_runtime = runtime.product_runtime(product, sink); + + loop { + let message = tokio::select! { + _ = connection_reset(&mut reset) => break, + _ = product_updates.changed() => break, + message = read.next() => message, + }; + let Some(message) = message else { + break; + }; + match message { + Ok(Message::Binary(bytes)) => { + if let Err(err) = product_runtime.receive_frame(bytes.to_vec()).await { + debug!(%err, "product runtime rejected frame"); + } + } + Ok(Message::Text(text)) => { + if let Err(err) = product_runtime + .receive_frame(text.as_bytes().to_vec()) + .await + { + debug!(%err, "product runtime rejected text frame"); + } + } + Ok(Message::Close(_)) | Err(_) => break, + Ok(_) => {} + } + } + + product_runtime.dispose(); + drop(outbound_tx); + let _ = writer.await; + Ok(()) +} + +async fn connection_reset(reset: &mut Option>) { + match reset { + Some(reset) => { + let _ = reset.changed().await; + } + None => std::future::pending().await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn product_selection_validates_and_normalizes_ids() -> Result<()> { + let product = ProductSelection::new(" Dotli.DOT ".to_string())?; + + assert_eq!(product.current(), "dotli.dot"); + assert!(product.select("localhost:3000".to_string())?); + assert_eq!(product.current(), "localhost:3000"); + assert!(!product.select("LOCALHOST:3000".to_string())?); + assert!(product.select("example.com".to_string()).is_err()); + Ok(()) + } + + #[tokio::test] + async fn changing_product_notifies_connections() -> Result<()> { + let product = ProductSelection::new("first.dot".to_string())?; + let mut connection = product.subscribe(); + + assert!(product.select("second.dot".to_string())?); + connection.changed().await?; + assert_eq!(connection.borrow().product_id, "second.dot"); + assert!(!product.select("SECOND.DOT".to_string())?); + assert!(!connection.has_changed()?); + Ok(()) + } +} diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs new file mode 100644 index 00000000..16d5de73 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -0,0 +1,2077 @@ +//! Headless TrUAPI hosts for local end-to-end testing. +//! +//! Two roles, one binary, pairing over the real People-chain statement store: +//! - `pairing-host`: a seedless host that presents a pairing deeplink and +//! serves product frames over WebSocket (the surface a product/test driver +//! talks to). +//! - `signing-host`: a wallet-local host that answers a pairing deeplink and +//! auto-signs, replacing the external signing-bot in e2e. +//! +//! Plus `alloc-check`, a diagnostic for on-chain statement-store allowance. + +mod accounts; +mod attestation; +mod chain; +mod frame_server; +mod network; +mod platform; +mod script_runner; +mod sessions; +mod signing_shell; +mod terminal_ui; + +use std::fmt; +use std::future::Future; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::process::ExitStatus; +use std::str::FromStr; +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use clap::{Args, Parser, Subcommand, ValueEnum}; +use futures::future::BoxFuture; +use tracing_subscriber::Layer; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use truapi_platform::{HostInfo, PlatformInfo}; +use truapi_server::statement_allowance as alloc; +use truapi_server::subscription::Spawner; +use truapi_server::{PairingHostConfig, PairingHostRuntime, SigningHostConfig, SigningHostRuntime}; + +use crate::accounts::{ResolveSignerConfig, ResolvedSigner}; +use crate::network::{Network, NetworkConfig}; +use crate::platform::{ApprovalPolicy, CliPlatform, CliStoragePaths}; +use crate::sessions::{DEFAULT_SESSION_NAME, SessionCatalog, SessionProfile}; +use crate::signing_shell::{ + HELP_TEXT, PAIRING_HELP_TEXT, ProductCommand, SessionCommand, ShellCommand, parse_command, +}; +use crate::terminal_ui::{ + ActiveTerminalUi, ActivityState, DriveResult, SystemEvent, TerminalUi, UiHandle, +}; + +/// Default product served by the pairing host's frame endpoint. Product ids +/// must be a `.dot` name or a `localhost` identifier (host-spec product id). +const DEFAULT_PRODUCT_ID: &str = "headless-playground.dot"; +/// Default product-frame address for the pairing host. +const DEFAULT_PAIRING_FRAME_LISTEN: &str = "127.0.0.1:9955"; +/// Default product-frame address for the signing host. +const DEFAULT_SIGNING_FRAME_LISTEN: &str = "127.0.0.1:9956"; +/// Deeplink scheme advertised by the pairing host. +const DEEPLINK_SCHEME: &str = "polkadotapp"; + +#[derive(Parser)] +#[command(name = "truapi-host", about = "Headless TrUAPI hosts for e2e testing")] +struct Cli { + /// Log verbosity. `RUST_LOG` takes precedence when set. + #[arg( + long, + global = true, + value_enum, + env = "TRUAPI_HOST_LOG", + default_value = "info" + )] + log_level: LogLevel, + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum LogLevel { + Error, + Warn, + Info, + Debug, + Trace, +} + +impl LogLevel { + const fn as_filter(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + Self::Debug => "debug", + Self::Trace => "trace", + } + } + + fn scoped_filter(self) -> String { + let level = self.as_filter(); + format!( + "warn,truapi={level},truapi_host={level},truapi_platform={level},truapi_server={level}" + ) + } +} + +impl FromStr for LogLevel { + type Err = String; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "error" => Ok(Self::Error), + "warn" => Ok(Self::Warn), + "info" => Ok(Self::Info), + "debug" => Ok(Self::Debug), + "trace" => Ok(Self::Trace), + _ => Err(format!( + "invalid log level `{value}`; expected error, warn, info, debug, or trace" + )), + } + } +} + +impl fmt::Display for LogLevel { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_filter()) + } +} + +#[derive(Clone)] +struct LogController { + reload: Arc Result<(), String> + Send + Sync>, +} + +impl LogController { + fn set(&self, level: LogLevel) -> Result<()> { + (self.reload)(level).map_err(anyhow::Error::msg) + } +} + +#[derive(Subcommand)] +enum Command { + /// Run a seedless pairing host for product scripts or interactive pairing. + /// + /// With `--script`, exits with the script's status. Without it, stays in an + /// interactive terminal UI where scripts can be run repeatedly. + PairingHost(PairingHostArgs), + /// Run a wallet-local signing host for scripts or pairing deeplinks. + /// + /// Owns signer identity, auto-manages accounts when no mnemonic/account is + /// specified, and can accept pairing deeplinks. With `--script`, exits with + /// the script's status; otherwise stays interactive. + SigningHost(SigningHostArgs), + /// Probe the People chain for a mnemonic's registered identity/username. + IdentityCheck { + /// BIP-39 mnemonic to probe. + #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC")] + mnemonic: String, + /// Network preset to probe. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, + }, + /// Check (and optionally submit) a statement-store allowance registration + /// against the real People chain: ring membership, the chosen slot, and + /// (with `--submit`) the `set_statement_store_account` extrinsic. + AllocCheck { + /// BIP-39 mnemonic proving LitePeople ring membership. + #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC")] + mnemonic: String, + /// Network preset to use for People-chain RPC. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, + /// Target account (hex, 32 bytes) to grant allowance to. Defaults to + /// all-zero (read-only slot scan only). + #[arg(long)] + target: Option, + /// How many rings back from the current index to scan for our member. + #[arg(long, default_value_t = 8)] + lookback: u32, + /// Submit the extrinsic instead of only checking membership + slot. + #[arg(long)] + submit: bool, + }, +} + +#[derive(Args)] +struct PairingHostArgs { + /// Product script to run (JS/TS). If omitted, start the terminal UI. + #[arg(long)] + script: Option, + /// Product id the host serves; scopes storage and product accounts. + #[arg(long = "product-id", default_value = DEFAULT_PRODUCT_ID)] + product_id: String, + /// Address to serve product frames on. + #[arg(long, default_value = DEFAULT_PAIRING_FRAME_LISTEN)] + frame_listen: SocketAddr, + /// Root directory for CLI-managed host state. + #[arg(long = "base-path", env = "TRUAPI_HOST_BASE_PATH")] + base_path: Option, + /// Network preset that supplies all RPC/backend/genesis config. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, + /// Approve every confirmation without prompting on the CLI. + #[arg(long)] + auto_accept: bool, +} + +#[derive(Args)] +struct SigningHostArgs { + /// Product script to run (JS/TS). If omitted, start an interactive shell. + #[arg(long)] + script: Option, + /// Product id used by scripts and product-scoped operations. + #[arg(long = "product-id", default_value = DEFAULT_PRODUCT_ID)] + product_id: String, + /// Pairing deeplink to answer. If omitted, no pairing is accepted + /// automatically; interactive mode lets you paste one later. + #[arg(long)] + deeplink: Option, + /// BIP-39 mnemonic for the wallet root. If omitted, the + /// `HOST_CLI_SIGNER_MNEMONIC` env var is used when set. Any mnemonic + /// bypasses account auto-management. + #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC")] + mnemonic: Option, + /// Named stored account to use. Omit this and `--mnemonic` to auto-select + /// or create a usable account. + #[arg(long)] + account: Option, + /// Persistent signing-host session to restore or create. + #[arg(long)] + session: Option, + /// Prefix for newly-created lite usernames in auto-account mode. + #[arg(long = "lite-username-prefix")] + lite_username_prefix: Option, + /// Root directory for CLI-managed account and host state. + #[arg(long = "base-path", env = "TRUAPI_HOST_BASE_PATH")] + base_path: Option, + /// Network preset that supplies all RPC/backend/genesis config. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, + /// Address to serve product frames on when running scripts. + #[arg(long, default_value = DEFAULT_SIGNING_FRAME_LISTEN)] + frame_listen: SocketAddr, + /// Approve every confirmation without prompting on the CLI. + #[arg(long)] + auto_accept: bool, + /// Execute one slash command without starting the terminal UI. + #[command(subcommand)] + action: Option, +} + +#[derive(Subcommand)] +enum SigningHostAction { + /// Execute one slash command and exit. + Exec { + /// Slash command to execute, such as `/session`. + command: String, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Install a rustls crypto provider so `wss://` chain connections work; + // rustls 0.23 panics without a process-level default provider. + let _ = rustls::crypto::ring::default_provider().install_default(); + + let cli = Cli::parse(); + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(cli.log_level.scoped_filter())); + let (filter, reload) = tracing_subscriber::reload::Layer::new(filter); + let log_controller = LogController { + reload: Arc::new(move |level| { + reload + .reload(tracing_subscriber::EnvFilter::new(level.scoped_filter())) + .map_err(|error| error.to_string()) + }), + }; + let log_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) + .without_time() + .with_target(false) + .with_level(false) + .with_writer(terminal_ui::LogWriter::default) + .with_filter(filter) + .with_filter(tracing_subscriber::filter::filter_fn(|metadata| { + log_target_is_visible(metadata.target()) + })); + tracing_subscriber::registry() + .with(terminal_ui::SsoTranscriptLayer) + .with(log_layer) + .init(); + + match cli.command { + Command::PairingHost(args) => run_pairing_host(args, cli.log_level, log_controller).await, + Command::SigningHost(args) => run_signing_host(args, cli.log_level, log_controller).await, + Command::IdentityCheck { mnemonic, network } => { + let entropy = bip39::Mnemonic::parse(mnemonic.trim()) + .context("invalid BIP-39 mnemonic")? + .to_entropy(); + attestation::check_identity(network.config().people_ws, &entropy).await + } + Command::AllocCheck { + mnemonic, + network, + target, + lookback, + submit, + } => run_alloc_check(mnemonic, network.config(), target, lookback, submit).await, + } +} + +fn log_target_is_visible(target: &str) -> bool { + target != terminal_ui::SSO_TRANSCRIPT_TARGET + && target != "rustls" + && !target.starts_with("rustls::") + && target != "tungstenite::protocol" + && !target.starts_with("tungstenite::protocol::") +} + +/// Check statement-store allowance for a mnemonic: ring membership, the chosen +/// slot, and (with `submit`) the `set_statement_store_account` extrinsic. +async fn run_alloc_check( + mnemonic: String, + network: NetworkConfig, + target: Option, + lookback: u32, + submit: bool, +) -> Result<()> { + let entropy = bip39::Mnemonic::parse(mnemonic.trim()) + .context("invalid BIP-39 mnemonic")? + .to_entropy(); + let bandersnatch = alloc::bandersnatch_entropy(&entropy); + + if submit && target.is_none() { + bail!("--target is required with --submit; the all-zero default is read-only"); + } + + let target = match target { + Some(hex_str) => { + let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(&hex_str)) + .context("invalid --target hex")?; + <[u8; 32]>::try_from(bytes.as_slice()) + .map_err(|_| anyhow::anyhow!("--target must be 32 bytes"))? + } + None => [0u8; 32], + }; + + let rpc = alloc::rpc::RpcClient::connect(network.people_ws) + .await + .map_err(anyhow::Error::msg)?; + let metadata = alloc::fetch_metadata(&rpc) + .await + .map_err(anyhow::Error::msg)?; + let chain_state = alloc::fetch_chain_state(&rpc) + .await + .map_err(anyhow::Error::msg)?; + println!( + "chain: specVersion={} txVersion={} genesis=0x{}", + chain_state.spec_version, + chain_state.transaction_version, + hex::encode(chain_state.genesis_hash), + ); + + let member = alloc::proof::member_key(bandersnatch); + println!("bandersnatch member=0x{}", hex::encode(member)); + let current_ring = alloc::ring::read_current_ring_index(&rpc) + .await + .map_err(anyhow::Error::msg)?; + println!("current ring index={current_ring}"); + let ring = alloc::find_including_ring(&rpc, &metadata, bandersnatch, lookback) + .await + .map_err(anyhow::Error::msg)?; + match &ring { + Some(r) => println!( + "member INCLUDED in ring_index={} exponent={} included_members={}", + r.ring_index, + r.exponent, + r.members.len(), + ), + None => println!("member NOT in the last {lookback} rings (onboarding pending)"), + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system clock before UNIX epoch")? + .as_secs(); + let period = alloc::slot::current_period(now); + println!("period={period} target=0x{}", hex::encode(target)); + + match alloc::slot::scan_slot_excluding( + &rpc, + &metadata, + bandersnatch, + period, + &target, + &[], + true, + ) + .await + { + Ok(alloc::slot::SlotSelection::Free(seq)) => println!("slot scan: free seq={seq}"), + Ok(alloc::slot::SlotSelection::AlreadyAllocated(seq)) => { + println!("slot scan: target already allocated at seq={seq}") + } + Err(err) => println!("slot scan: {err}"), + } + + if submit { + let ring = ring.ok_or_else(|| anyhow::anyhow!("cannot submit: member not in any ring"))?; + match alloc::register_statement_account( + &rpc, + &metadata, + &chain_state, + bandersnatch, + alloc::RegistrationParams { + target: &target, + period, + ring: &ring, + reuse_existing: true, + }, + ) + .await + { + Ok(alloc::RegistrationOutcome::Registered { + block_hash, + seq, + ring_index, + }) => println!("REGISTERED seq={seq} ring_index={ring_index} block={block_hash}"), + Ok(alloc::RegistrationOutcome::AlreadyAllocated { seq }) => { + println!("already allocated at seq={seq}") + } + Err(err) => bail!("registration failed: {err}"), + } + } + + Ok(()) +} + +/// Map the `--auto-accept` flag to an approval policy: auto-accept, or prompt +/// each confirmation on the CLI. +fn approval_policy(auto_accept: bool) -> ApprovalPolicy { + if auto_accept { + ApprovalPolicy::AutoAccept + } else { + ApprovalPolicy::Prompt + } +} + +/// Spawner that runs runtime futures on the tokio runtime, so their WebSocket +/// connects and timers have a reactor. +fn tokio_spawner() -> Spawner { + Arc::new(|fut: BoxFuture<'static, ()>| { + tokio::spawn(fut); + }) +} + +fn host_info(name: &str) -> HostInfo { + HostInfo { + name: name.to_string(), + icon: None, + version: Some(env!("CARGO_PKG_VERSION").to_string()), + } +} + +fn platform_info() -> PlatformInfo { + PlatformInfo { + kind: Some("cli".to_string()), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + } +} + +async fn run_pairing_host( + args: PairingHostArgs, + initial_log_level: LogLevel, + log_controller: LogController, +) -> Result<()> { + let interactive = args.script.is_none(); + if interactive && !terminal_ui::is_interactive_terminal() { + invalid_invocation( + "interactive pairing-host requires a TTY; use pairing-host --script ", + ); + } + let network = args.network.config(); + let base_path = args.base_path.unwrap_or_else(default_base_path); + let product = frame_server::ProductSelection::new(args.product_id)?; + let product_id = product.current(); + let storage_paths = CliStoragePaths::pairing(base_path.join(network.id)); + let (terminal_ui, ui_handle) = if interactive { + let (ui, handle) = + TerminalUi::new_pairing(network.id, product_id.clone(), initial_log_level); + (Some(ui.enter()?), Some(handle)) + } else { + (None, None) + }; + let platform = CliPlatform::new( + network.people_ws, + network.live_chain_endpoints, + Some(storage_paths), + approval_policy(args.auto_accept), + ui_handle, + ); + // SSO and identity both run over the real People chain, so usernames always + // resolve from `Resources.Consumers` (host-spec G). + let config = PairingHostConfig::new( + host_info("Headless Pairing Host"), + platform_info(), + network.people_genesis, + network.bulletin_genesis, + DEEPLINK_SCHEME.to_string(), + ) + .context("invalid pairing host config")?; + let storage_platform = platform.clone(); + let pairing_runtime = Arc::new(PairingHostRuntime::new(platform, config, tokio_spawner())); + + let listener = frame_server::bind(args.frame_listen).await?; + let frame_url = format!("ws://{}", listener.local_addr()?); + terminal_ui::output_event(SystemEvent::FramesListening { + url: frame_url.clone(), + }); + let runtime_for_frames: Arc = pairing_runtime.clone(); + + if let Some(script) = args.script { + let script_product_id = product_id.clone(); + let script_frame_url = frame_url.clone(); + let status = with_frame_server(runtime_for_frames, product, listener, async move { + script_runner::run( + &script_frame_url, + &script_product_id, + &script, + script_runner::ScriptHostRole::PairingHost, + ) + .await + }) + .await?; + let code = status.code().unwrap_or(1); + terminal_ui::output_event(SystemEvent::ScriptExit { code }); + std::process::exit(code); + } + + let terminal_ui = terminal_ui.context("interactive terminal was not initialized")?; + with_frame_server(runtime_for_frames, product.clone(), listener, async move { + pairing_interactive_loop( + frame_url, + product, + pairing_runtime, + storage_platform, + terminal_ui, + log_controller, + ) + .await + }) + .await +} + +async fn run_signing_host( + args: SigningHostArgs, + initial_log_level: LogLevel, + log_controller: LogController, +) -> Result<()> { + if let Err(error) = validate_signing_args(&args) { + invalid_invocation(error); + } + let exec_input = args + .action + .as_ref() + .map(|SigningHostAction::Exec { command }| command.clone()); + let interactive = args.script.is_none() && exec_input.is_none(); + if interactive && !terminal_ui::is_interactive_terminal() { + invalid_invocation( + "interactive signing-host requires a TTY; use `signing-host exec '/script path.ts'` or --script", + ); + } + let exec_command = exec_input + .as_deref() + .map(|input| parse_command(input).unwrap_or_else(|error| invalid_invocation(error))); + let product = frame_server::ProductSelection::new(args.product_id.clone())?; + let product_id = product.current(); + let network = args.network.config(); + let base_path = args.base_path.clone().unwrap_or_else(default_base_path); + let session_catalog = SessionCatalog::new(base_path.clone(), network.id)?; + let initial_session_name = initial_session_name(&args, &session_catalog); + if normalized(args.mnemonic.clone()).is_none() { + session_catalog.set_current(&initial_session_name)?; + } + let initial_session_names = session_catalog.list()?; + let (terminal_ui, ui_handle) = if interactive { + let (ui, handle) = TerminalUi::new( + network.id, + product_id, + initial_session_name.clone(), + initial_session_names, + initial_log_level, + ); + (Some(ui.enter()?), Some(handle)) + } else { + (None, None) + }; + let mut session = start_signing_host( + &args, + session_catalog, + initial_session_name, + network, + ui_handle.clone(), + ) + .await?; + let listener = frame_server::bind(args.frame_listen).await?; + let frame_url = format!("ws://{}", listener.local_addr()?); + terminal_ui::output_event(SystemEvent::FramesListening { + url: frame_url.clone(), + }); + let runtime_for_frames: Arc = + session.runtime_factory.clone(); + + if let Some(script) = args.script { + let product_id = product.current(); + let script_product_id = product_id.clone(); + let script_frame_url = frame_url.clone(); + let initial_deeplink = args.deeplink.clone(); + let status = with_frame_server(runtime_for_frames, product, listener, async move { + let mut responder = None; + if let Some(deeplink) = initial_deeplink { + prepare_pairing_response(&mut session, &deeplink).await?; + let runtime = session.runtime.clone(); + responder = Some(tokio::spawn(async move { + match runtime.respond_to_pairing(&deeplink).await { + Ok(exit) => terminal_ui::output_event(SystemEvent::SigningHostExit { + outcome: format!("{exit:?}"), + }), + Err(err) => terminal_ui::output_event(SystemEvent::SigningHostError { + reason: err.reason, + }), + } + })); + } + ensure_signer(&mut session).await?; + let status = script_runner::run( + &script_frame_url, + &script_product_id, + &script, + script_runner::ScriptHostRole::SigningHost, + ) + .await?; + if let Some(responder) = responder { + responder.abort(); + } + Ok::(status) + }) + .await?; + let code = status.code().unwrap_or(1); + terminal_ui::output_event(SystemEvent::ScriptExit { code }); + std::process::exit(code); + } + + let initial_deeplink = args.deeplink.clone(); + if let Some(command) = exec_command { + return with_frame_server(runtime_for_frames, product.clone(), listener, async move { + let responder = if let Some(deeplink) = initial_deeplink { + prepare_pairing_response(&mut session, &deeplink).await?; + let runtime = session.runtime.clone(); + Some(tokio::spawn(async move { + match runtime.respond_to_pairing(&deeplink).await { + Ok(exit) => terminal_ui::output_event(SystemEvent::SigningHostExit { + outcome: format!("{exit:?}"), + }), + Err(err) => terminal_ui::output_event(SystemEvent::SigningHostError { + reason: err.reason, + }), + } + })) + } else { + None + }; + let result = execute_non_interactive_command( + &mut session, + &frame_url, + &product, + command, + &log_controller, + ) + .await; + if let Some(responder) = responder { + responder.abort(); + } + result + }) + .await; + } + + let terminal_ui = terminal_ui.context("interactive terminal was not initialized")?; + with_frame_server(runtime_for_frames, product.clone(), listener, async move { + signing_interactive_loop( + &mut session, + frame_url, + product, + initial_deeplink, + terminal_ui, + log_controller, + ) + .await + }) + .await +} + +struct SigningHostSession { + runtime: Arc, + runtime_factory: Arc, + responder: Option>, + signer: Option, + cached_user_id: Option, + last_script: Option, + catalog: SessionCatalog, + profile: Option, + network: NetworkConfig, + mnemonic: Option, + default_account: Option, + lite_username_prefix: Option, + approval: ApprovalPolicy, + ui: Option, +} + +fn initial_session_name(args: &SigningHostArgs, catalog: &SessionCatalog) -> String { + if normalized(args.mnemonic.clone()).is_some() { + return "ephemeral".to_string(); + } + normalized(args.session.clone()) + .or_else(|| normalized(args.account.clone()).map(|_| DEFAULT_SESSION_NAME.to_string())) + .unwrap_or_else(|| catalog.current_name()) +} + +async fn start_signing_host( + args: &SigningHostArgs, + catalog: SessionCatalog, + session_name: String, + network: NetworkConfig, + ui: Option, +) -> Result { + let mnemonic = normalized(args.mnemonic.clone()); + let mut profile = if mnemonic.is_some() { + None + } else { + Some(catalog.ensure_profile(&session_name)?) + }; + let default_account = normalized(args.account.clone()); + let mut cached_user_id = profile + .as_ref() + .map(|profile| catalog.cached_user_id(profile)) + .transpose()? + .flatten(); + if let (Some(current), Some(user_id)) = (&profile, &cached_user_id) + && current.name != *user_id + { + profile = Some(catalog.promote_to_user(current, user_id)?); + } + let signer = profile + .as_ref() + .map(|profile| { + accounts::resolve_cached_signer( + &profile.account_base_path, + network.id, + default_account.as_deref(), + ) + }) + .transpose()? + .flatten(); + if let (Some(current), Some(user_id)) = ( + &profile, + signer + .as_ref() + .and_then(|signer| signer.lite_username.as_ref()), + ) && current.name != *user_id + { + profile = Some(catalog.promote_to_user(current, user_id)?); + cached_user_id = Some(user_id.clone()); + } + let storage_profile = profile.as_ref().cloned().unwrap_or_else(|| { + catalog + .profile(DEFAULT_SESSION_NAME) + .expect("default session profile is valid") + }); + let approval = approval_policy(args.auto_accept); + let runtime = build_signing_runtime( + network, + storage_profile.path, + storage_profile.product_storage_dir, + approval, + ui.clone(), + )?; + let runtime_factory = frame_server::SwitchableSigningRuntime::new(runtime.clone()); + let last_script = profile + .as_ref() + .map(|profile| catalog.last_script(profile)) + .transpose()? + .flatten(); + if let Some(cached_signer) = &signer { + runtime + .activate_local_session_with_identity( + cached_signer.entropy.clone(), + cached_signer.lite_username.clone(), + ) + .await + .map_err(|error| { + anyhow::anyhow!("failed to activate cached session: {}", error.reason) + })?; + if let (Some(profile), Some(user_id)) = (&profile, &cached_signer.lite_username) { + catalog.store_user_id(profile, user_id)?; + cached_user_id = Some(user_id.clone()); + if let Some(ui) = &ui { + ui.connection(user_id.clone()); + } + } + terminal_ui::output_event(SystemEvent::SigningHostReady); + } + if let Some(profile) = &profile + && profile.name != session_name + { + catalog.set_current(&profile.name)?; + if let Some(ui) = &ui { + ui.session(profile.name.clone(), catalog.list()?); + } + } + if profile.is_some() + && signer.is_none() + && let Some(ui) = &ui + { + ui.event(SystemEvent::SigningHostNeedsSession); + } + + Ok(SigningHostSession { + runtime, + runtime_factory, + responder: None, + signer, + cached_user_id, + last_script, + catalog, + profile, + network, + mnemonic, + default_account, + lite_username_prefix: normalized(args.lite_username_prefix.clone()), + approval, + ui, + }) +} + +fn build_signing_runtime( + network: NetworkConfig, + storage_path: PathBuf, + product_storage_dir: PathBuf, + approval: ApprovalPolicy, + ui: Option, +) -> Result> { + let platform = CliPlatform::new( + network.people_ws, + network.live_chain_endpoints, + Some(CliStoragePaths::new(storage_path, product_storage_dir)), + approval, + ui, + ); + let config = SigningHostConfig::new( + host_info("Headless Signing Host"), + platform_info(), + network.people_genesis, + network.bulletin_genesis, + ) + .context("invalid signing host config")?; + Ok(Arc::new(SigningHostRuntime::new( + platform, + config, + tokio_spawner(), + ))) +} + +impl Drop for SigningHostSession { + fn drop(&mut self) { + if let Some(responder) = self.responder.take() { + responder.abort(); + } + } +} + +fn validate_signing_args(args: &SigningHostArgs) -> Result<()> { + let mnemonic = normalized(args.mnemonic.clone()); + let account = normalized(args.account.clone()); + let session = normalized(args.session.clone()); + let prefix = normalized(args.lite_username_prefix.clone()); + if args.script.is_some() && args.action.is_some() { + bail!("--script cannot be combined with the exec subcommand"); + } + if mnemonic.is_some() && account.is_some() { + bail!("--account cannot be used when --mnemonic or HOST_CLI_SIGNER_MNEMONIC is set"); + } + if mnemonic.is_some() && session.is_some() { + bail!("--session cannot be used when --mnemonic or HOST_CLI_SIGNER_MNEMONIC is set"); + } + if account.is_some() && session.is_some() { + bail!("--session cannot be combined with --account"); + } + if let Some(session) = session { + sessions::validate_selectable_name(&session).map_err(anyhow::Error::msg)?; + } + if mnemonic.is_some() && prefix.is_some() { + bail!( + "--lite-username-prefix cannot be used when --mnemonic or HOST_CLI_SIGNER_MNEMONIC is set" + ); + } + if account.is_some() && prefix.is_some() { + bail!("--lite-username-prefix only applies when --account is omitted"); + } + Ok(()) +} + +fn normalized(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }) +} + +fn invalid_invocation(error: impl fmt::Display) -> ! { + eprintln!("error: {error}"); + std::process::exit(2); +} + +async fn with_frame_server( + runtime: Arc, + product: Arc, + listener: tokio::net::TcpListener, + body: Fut, +) -> Result +where + Fut: Future>, +{ + let server = tokio::spawn(frame_server::accept_loop(runtime, product, listener)); + let result = body.await; + server.abort(); + result +} + +async fn ensure_signer(session: &mut SigningHostSession) -> Result<()> { + if session.signer.is_some() { + return Ok(()); + } + let profile = session + .profile + .clone() + .unwrap_or(session.catalog.profile(DEFAULT_SESSION_NAME)?); + let account = (profile.name == DEFAULT_SESSION_NAME) + .then(|| session.default_account.clone()) + .flatten(); + let lite_username_prefix = + sessions::lite_username_prefix(&profile.name, session.lite_username_prefix.as_deref()); + session.signer = Some( + accounts::resolve_signer(ResolveSignerConfig { + base_path: &profile.account_base_path, + network: session.network, + mnemonic: session.mnemonic.clone(), + account, + lite_username_prefix, + }) + .await?, + ); + promote_current_profile(session)?; + activate_current_signer(session).await +} + +fn promote_current_profile(session: &mut SigningHostSession) -> Result<()> { + let Some(user_id) = session + .signer + .as_ref() + .and_then(|signer| signer.lite_username.clone()) + else { + return Ok(()); + }; + let Some(current) = session.profile.as_ref() else { + return Ok(()); + }; + if current.name == user_id { + return Ok(()); + } + let promoted = session.catalog.promote_to_user(current, &user_id)?; + let last_script = session.catalog.last_script(&promoted)?; + let runtime = build_signing_runtime( + session.network, + promoted.path.clone(), + promoted.product_storage_dir.clone(), + session.approval, + session.ui.clone(), + )?; + session.runtime_factory.replace(runtime.clone()); + session.runtime = runtime; + session.last_script = last_script; + session.profile = Some(promoted); + session.catalog.set_current(&user_id)?; + if let Some(ui) = &session.ui { + ui.session(user_id, session.catalog.list()?); + } + Ok(()) +} + +fn current_account_base_path(session: &SigningHostSession) -> Result { + Ok(session + .profile + .as_ref() + .map(|profile| profile.account_base_path.clone()) + .unwrap_or( + session + .catalog + .profile(DEFAULT_SESSION_NAME)? + .account_base_path, + )) +} + +async fn activate_current_signer(session: &mut SigningHostSession) -> Result<()> { + let signer = session + .signer + .as_ref() + .context("signer has not been resolved")?; + session + .runtime + .activate_local_session_with_identity(signer.entropy.clone(), signer.lite_username.clone()) + .await + .map_err(|err| anyhow::anyhow!("failed to activate local session: {}", err.reason))?; + if let (Some(profile), Some(user_id)) = (&session.profile, &signer.lite_username) { + session.catalog.store_user_id(profile, user_id)?; + session.cached_user_id = Some(user_id.clone()); + if let Some(ui) = &session.ui { + ui.connection(user_id.clone()); + } + } + terminal_ui::output_event(SystemEvent::SigningHostReady); + Ok(()) +} + +async fn prepare_pairing_response(session: &mut SigningHostSession, deeplink: &str) -> Result<()> { + let mut attempts = 0usize; + loop { + ensure_signer(session).await?; + let (entropy, auto_managed, account_name) = { + let signer = session + .signer + .as_ref() + .context("signer has not been resolved")?; + ( + signer.entropy.clone(), + signer.auto_managed, + signer.account_name.clone(), + ) + }; + match register_pairing_allowances(session.network.people_ws, &entropy, deeplink).await { + Ok(()) => return Ok(()), + Err(err) if auto_managed && is_statement_slot_exhaustion(&err) => { + attempts += 1; + if attempts > 8 { + return Err(err); + } + if let Some(name) = &account_name { + let period = accounts::current_statement_period()?; + let account_base_path = current_account_base_path(session)?; + accounts::mark_account_exhausted( + &account_base_path, + session.network.id, + name, + period, + )?; + terminal_ui::output_event(SystemEvent::SigningHostAccountExhausted { + name: name.clone(), + period, + }); + } + let account_base_path = current_account_base_path(session)?; + let session_name = session + .profile + .as_ref() + .map_or(DEFAULT_SESSION_NAME, |profile| profile.name.as_str()); + let lite_username_prefix = sessions::lite_username_prefix( + session_name, + session.lite_username_prefix.as_deref(), + ); + session.signer = Some( + accounts::resolve_signer(ResolveSignerConfig { + base_path: &account_base_path, + network: session.network, + mnemonic: None, + account: None, + lite_username_prefix, + }) + .await?, + ); + activate_current_signer(session).await?; + } + Err(err) => return Err(err), + } + } +} + +fn is_statement_slot_exhaustion(err: &anyhow::Error) -> bool { + err.to_string().contains("no free StatementStore slot") +} + +async fn respond_to_deeplink(session: &mut SigningHostSession, deeplink: String) -> Result<()> { + prepare_pairing_response(session, &deeplink).await?; + let exit = session + .runtime + .respond_to_pairing(&deeplink) + .await + .map_err(|err| anyhow::anyhow!("pairing failed: {}", err.reason))?; + terminal_ui::output_event(SystemEvent::SigningHostExit { + outcome: format!("{exit:?}"), + }); + Ok(()) +} + +async fn start_deeplink_responder( + session: &mut SigningHostSession, + deeplink: String, +) -> Result<()> { + prepare_pairing_response(session, &deeplink).await?; + if let Some(responder) = session.responder.take() { + responder.abort(); + } + let runtime = session.runtime.clone(); + session.responder = Some(tokio::spawn(async move { + match runtime.respond_to_pairing(&deeplink).await { + Ok(exit) => terminal_ui::output_event(SystemEvent::SigningHostExit { + outcome: format!("{exit:?}"), + }), + Err(error) => { + terminal_ui::output_event(SystemEvent::SigningHostError { + reason: error.reason, + }); + } + } + })); + terminal_ui::output_event(SystemEvent::SigningHostResponderStarted); + Ok(()) +} + +fn session_status_event(session: &SigningHostSession) -> SystemEvent { + let name = session + .profile + .as_ref() + .map_or("ephemeral", |profile| profile.name.as_str()); + let path = session.profile.as_ref().map_or_else( + || "".to_string(), + |profile| profile.path.display().to_string(), + ); + let user_id = session + .signer + .as_ref() + .and_then(|signer| signer.lite_username.as_deref()) + .or(session.cached_user_id.as_deref()) + .unwrap_or(""); + SystemEvent::SessionStatus { + name: name.to_string(), + path, + user_id: user_id.to_string(), + } +} + +fn session_status(session: &SigningHostSession) -> String { + session_status_event(session).human() +} + +fn session_list(session: &SigningHostSession) -> Result { + let current = session + .profile + .as_ref() + .map_or("ephemeral", |profile| profile.name.as_str()); + let mut lines = vec!["Sessions".to_string()]; + for name in session.catalog.list()? { + let marker = if name == current { "*" } else { " " }; + let path = session.catalog.profile(&name)?.path; + lines.push(format!("{marker} {name} {}", path.display())); + } + if lines.len() == 1 && session.profile.is_some() { + lines.push(" ".to_string()); + } + if session.profile.is_none() { + lines.push("* ephemeral ".to_string()); + } + Ok(lines.join("\n")) +} + +async fn switch_session(session: &mut SigningHostSession, name: String) -> Result<()> { + if session.mnemonic.is_some() { + bail!("session switching is unavailable when launched with --mnemonic"); + } + sessions::validate_selectable_name(&name).map_err(anyhow::Error::msg)?; + if session + .profile + .as_ref() + .is_some_and(|profile| profile.name == name) + { + terminal_ui::output_event(session_status_event(session)); + return Ok(()); + } + + let existed = session.catalog.exists(&name); + let old_name = session + .profile + .as_ref() + .map_or(DEFAULT_SESSION_NAME, |profile| profile.name.as_str()) + .to_string(); + if existed { + terminal_ui::output_event(SystemEvent::SessionSwitching { + from: old_name.clone(), + to: name.clone(), + }); + } else { + terminal_ui::output_event(SystemEvent::SessionCreating { name: name.clone() }); + } + + // Resolve and provision the target completely while the old runtime keeps + // serving. Only the final runtime replacement invalidates product sockets. + let provisional_profile = session.catalog.ensure_profile(&name)?; + let lite_username_prefix = + sessions::lite_username_prefix(&name, session.lite_username_prefix.as_deref()); + let signer = accounts::resolve_signer(ResolveSignerConfig { + base_path: &provisional_profile.account_base_path, + network: session.network, + mnemonic: None, + account: if name == DEFAULT_SESSION_NAME { + session.default_account.clone() + } else { + None + }, + lite_username_prefix, + }) + .await?; + let profile = if let Some(user_id) = &signer.lite_username { + session + .catalog + .promote_to_user(&provisional_profile, user_id)? + } else { + provisional_profile + }; + let last_script = session.catalog.last_script(&profile)?; + let runtime = build_signing_runtime( + session.network, + profile.path.clone(), + profile.product_storage_dir.clone(), + session.approval, + session.ui.clone(), + )?; + let available_sessions = session.catalog.list()?; + + session.catalog.set_current(&profile.name)?; + if let Err(error) = runtime + .activate_local_session_with_identity(signer.entropy.clone(), signer.lite_username.clone()) + .await + { + let _ = session.catalog.set_current(&old_name); + bail!("failed to activate session {name:?}: {}", error.reason); + } + + if let Some(responder) = session.responder.take() { + responder.abort(); + } + session.runtime_factory.replace(runtime.clone()); + session.runtime = runtime; + session.cached_user_id = signer.lite_username.clone(); + session.signer = Some(signer); + session.last_script = last_script; + session.profile = Some(profile); + if let Some(ui) = &session.ui { + let current_name = session + .profile + .as_ref() + .map(|profile| profile.name.clone()) + .unwrap_or(name); + ui.session(current_name, available_sessions); + if let Some(user_id) = &session.cached_user_id { + ui.connection(user_id.clone()); + } + } + terminal_ui::output_event(session_status_event(session)); + Ok(()) +} + +/// Grant on-chain statement-store allowance to the two accounts that submit +/// statements during pairing: the signing host's own `//wallet//sso` account +/// and the pairing host's per-pairing device key (from the deeplink). Proves +/// the signing account's LitePeople ring membership once and reuses it. +async fn register_pairing_allowances( + statement_store_url: &str, + entropy: &[u8], + deeplink: &str, +) -> Result<()> { + use truapi_server::host_logic::product_account::derive_sr25519_hard_path; + use truapi_server::host_logic::sso::pairing::{ + VersionedHandshakeProposal, decode_pairing_deeplink, + }; + + let wallet_sso = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) + .map_err(|e| anyhow::anyhow!("//wallet//sso derivation failed: {e}"))? + .public + .to_bytes(); + let VersionedHandshakeProposal::V2(proposal) = + decode_pairing_deeplink(deeplink).map_err(anyhow::Error::msg)?; + let device = proposal.device.statement_account_id; + + let bandersnatch = alloc::bandersnatch_entropy(entropy); + let rpc = alloc::rpc::RpcClient::connect(statement_store_url) + .await + .map_err(anyhow::Error::msg)?; + let metadata = alloc::fetch_metadata(&rpc) + .await + .map_err(anyhow::Error::msg)?; + let chain_state = alloc::fetch_chain_state(&rpc) + .await + .map_err(anyhow::Error::msg)?; + + // The signing account may be in an old ring, so scan back to genesis. + let current = alloc::ring::read_current_ring_index(&rpc) + .await + .map_err(anyhow::Error::msg)?; + let ring = alloc::find_including_ring(&rpc, &metadata, bandersnatch, current) + .await + .map_err(anyhow::Error::msg)? + .ok_or_else(|| { + anyhow::anyhow!( + "signing account is not a LitePeople ring member; cannot grant allowance" + ) + })?; + terminal_ui::output_event(SystemEvent::RingInfo { + ring_index: ring.ring_index, + members: ring.members.len(), + }); + + let period = alloc::slot::current_period( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system clock before UNIX epoch")? + .as_secs(), + ); + + for (label, target) in [("wallet-sso", wallet_sso), ("device", device)] { + terminal_ui::output_event(SystemEvent::AllowanceChecking { + target: label.to_string(), + }); + let outcome = alloc::register_statement_account( + &rpc, + &metadata, + &chain_state, + bandersnatch, + alloc::RegistrationParams { + target: &target, + period, + ring: &ring, + reuse_existing: true, + }, + ) + .await + .map_err(|e| anyhow::anyhow!("allowance registration for {label} failed: {e}"))?; + match outcome { + alloc::RegistrationOutcome::Registered { + block_hash, seq, .. + } => terminal_ui::output_event(SystemEvent::AllowanceReady { + target: label.to_string(), + sequence: seq, + block_hash: Some(block_hash), + already_allocated: false, + }), + alloc::RegistrationOutcome::AlreadyAllocated { seq } => { + terminal_ui::output_event(SystemEvent::AllowanceReady { + target: label.to_string(), + sequence: seq, + block_hash: None, + already_allocated: true, + }); + } + } + } + Ok(()) +} + +async fn pairing_interactive_loop( + frame_url: String, + product: Arc, + runtime: Arc, + storage: Arc, + mut ui: ActiveTerminalUi, + log_controller: LogController, +) -> Result<()> { + let mut pairing_state_path = storage + .state_dir() + .context("pairing host storage is not configured")?; + let mut last_script = sessions::session_last_script(&pairing_state_path)?; + loop { + let Some(input) = ui.next_command().await? else { + return Ok(()); + }; + ui.command(input.clone()); + let command = match parse_command(&input) { + Ok(command) => command, + Err(error) => { + ui.error(error); + continue; + } + }; + match command { + ShellCommand::Help => ui.system(PAIRING_HELP_TEXT), + ShellCommand::Clear => ui.clear(), + ShellCommand::Copy => match ui.copy_transcript() { + Ok(entries) => ui.event(SystemEvent::CopiedTranscript { entries }), + Err(error) => ui.error(format!("failed to copy transcript: {error}")), + }, + ShellCommand::Login => { + let product_id = product.current(); + run_pairing_login(&runtime, &product_id, input, &mut ui).await?; + } + ShellCommand::Logout => match runtime.logout().await { + Ok(()) => ui.success( + "Logged out", + Some( + "The next product login will generate new pairing keys and a fresh link." + .to_string(), + ), + ), + Err(error) => ui.error(format!("logout failed: {}", error.reason)), + }, + ShellCommand::Log(level) => { + if let Err(error) = log_controller.set(level) { + ui.error(format!("failed to set log level: {error}")); + } else { + ui.set_log_level(level); + ui.event(SystemEvent::LogLevelChanged { level }); + } + } + ShellCommand::Product(ProductCommand::Current) => ui.system(product.current()), + ShellCommand::Product(ProductCommand::Switch(product_id)) => { + match product.select(product_id) { + Ok(true) => { + let product_id = product.current(); + ui.set_product(product_id.clone()); + ui.success( + format!("Product set to {product_id}"), + Some("Reconnect product clients to continue.".to_string()), + ); + } + Ok(false) => ui.system(product.current()), + Err(error) => ui.error(error.to_string()), + } + } + ShellCommand::Quit => return Ok(()), + ShellCommand::Script(script) => { + let current_state_path = storage + .state_dir() + .context("pairing host storage is not configured")?; + if current_state_path != pairing_state_path { + pairing_state_path = current_state_path; + last_script = sessions::session_last_script(&pairing_state_path)?; + } + let scratch_script_directory = pairing_state_path.join("scripts"); + let script = match script { + Some(script) => { + remember_script(Some(&pairing_state_path), &mut last_script, script) + } + None => { + let script = + select_script_to_edit(&scratch_script_directory, &mut last_script); + match script { + Ok(script) => match sessions::store_session_last_script( + &pairing_state_path, + &script, + ) { + Ok(()) => edit_script_in(script, &mut ui).await, + Err(error) => Err(error), + }, + Err(error) => Err(error), + } + } + }; + match script { + Ok(script) => { + let product_id = product.current(); + run_pairing_script(&frame_url, &product_id, &script, input, &mut ui) + .await?; + } + Err(error) => ui.error(error.to_string()), + } + } + ShellCommand::Pair(_) | ShellCommand::Session(_) => { + ui.error("command is only available on the signing host"); + } + } + } +} + +async fn run_pairing_login( + runtime: &PairingHostRuntime, + product_id: &str, + label: String, + ui: &mut ActiveTerminalUi, +) -> Result<()> { + let checkpoint = ui.activity_checkpoint(); + match ui + .drive_pairing_login(label, runtime.login(product_id)) + .await? + { + DriveResult::Complete(Ok(truapi::v01::HostRequestLoginResponse::Success)) => {} + DriveResult::Complete(Ok(truapi::v01::HostRequestLoginResponse::AlreadyConnected)) => { + ui.success("Already logged in", None); + } + DriveResult::Complete(Ok(truapi::v01::HostRequestLoginResponse::Rejected)) => { + ui.finish_activities_since(checkpoint, ActivityState::Cancelled, "Login was rejected"); + ui.error("login rejected"); + } + DriveResult::Complete(Err(error)) => { + ui.finish_activities_since( + checkpoint, + ActivityState::Failed, + "Login stopped after an error", + ); + ui.error(format!("login failed: {}", error.reason)); + } + DriveResult::Cancelled => { + runtime.cancel_pairing(); + ui.finish_activities_since(checkpoint, ActivityState::Cancelled, "Login cancelled"); + ui.error("login cancelled"); + } + } + Ok(()) +} + +async fn run_pairing_script( + frame_url: &str, + product_id: &str, + script: &std::path::Path, + label: String, + ui: &mut ActiveTerminalUi, +) -> Result<()> { + let activity_checkpoint = ui.activity_checkpoint(); + let handle = ui.handle(); + let operation = async { + let status = script_runner::run_captured( + frame_url, + product_id, + script, + handle, + script_runner::ScriptHostRole::PairingHost, + ) + .await?; + terminal_ui::output_event(SystemEvent::ScriptExit { + code: status.code().unwrap_or(1), + }); + Ok::<(), anyhow::Error>(()) + }; + match ui.drive(label, operation).await? { + DriveResult::Complete(Ok(())) => {} + DriveResult::Complete(Err(error)) => { + ui.finish_activities_since( + activity_checkpoint, + ActivityState::Failed, + "Stopped after an error", + ); + ui.error(error.to_string()); + } + DriveResult::Cancelled => { + ui.finish_activities_since(activity_checkpoint, ActivityState::Cancelled, "Cancelled"); + ui.error("command cancelled"); + } + } + Ok(()) +} + +async fn signing_interactive_loop( + session: &mut SigningHostSession, + frame_url: String, + product: Arc, + initial_deeplink: Option, + mut ui: ActiveTerminalUi, + log_controller: LogController, +) -> Result<()> { + if let Some(deeplink) = initial_deeplink { + let input = format!("/pair {deeplink}"); + ui.command(input.clone()); + let product_id = product.current(); + run_interactive_operation( + session, + &frame_url, + &product_id, + ShellCommand::Pair(deeplink), + input, + &mut ui, + ) + .await?; + } + + loop { + let Some(input) = ui.next_command().await? else { + return Ok(()); + }; + ui.command(input.clone()); + let command = match parse_command(&input) { + Ok(command) => command, + Err(error) => { + ui.error(error); + continue; + } + }; + match command { + ShellCommand::Help => ui.system(HELP_TEXT), + ShellCommand::Clear => ui.clear(), + ShellCommand::Copy => match ui.copy_transcript() { + Ok(entries) => ui.event(SystemEvent::CopiedTranscript { entries }), + Err(error) => ui.error(format!("failed to copy transcript: {error}")), + }, + ShellCommand::Log(level) => { + if let Err(error) = log_controller.set(level) { + ui.error(format!("failed to set log level: {error}")); + } else { + ui.set_log_level(level); + ui.event(SystemEvent::LogLevelChanged { level }); + } + } + ShellCommand::Product(ProductCommand::Current) => ui.system(product.current()), + ShellCommand::Product(ProductCommand::Switch(product_id)) => { + match product.select(product_id) { + Ok(true) => { + let product_id = product.current(); + ui.set_product(product_id.clone()); + ui.success( + format!("Product set to {product_id}"), + Some("Reconnect product clients to continue.".to_string()), + ); + } + Ok(false) => ui.system(product.current()), + Err(error) => ui.error(error.to_string()), + } + } + ShellCommand::Session(SessionCommand::Current) => { + ui.event(session_status_event(session)); + if session.profile.is_some() && session.signer.is_none() { + ui.event(SystemEvent::SigningHostNeedsSession); + } + } + ShellCommand::Session(SessionCommand::List) => match session_list(session) { + Ok(sessions) => ui.system(sessions), + Err(error) => ui.error(format!("failed to list sessions: {error}")), + }, + ShellCommand::Quit => return Ok(()), + ShellCommand::Script(None) => match edit_session_script(session, &mut ui).await { + Ok(script) => { + let product_id = product.current(); + run_interactive_operation( + session, + &frame_url, + &product_id, + ShellCommand::Script(Some(script)), + input, + &mut ui, + ) + .await?; + } + Err(error) => ui.error(error.to_string()), + }, + command => { + let product_id = product.current(); + run_interactive_operation( + session, + &frame_url, + &product_id, + command, + input, + &mut ui, + ) + .await?; + } + } + } +} + +async fn run_interactive_operation( + session: &mut SigningHostSession, + frame_url: &str, + product_id: &str, + command: ShellCommand, + label: String, + ui: &mut ActiveTerminalUi, +) -> Result<()> { + let activity_checkpoint = ui.activity_checkpoint(); + let handle = ui.handle(); + let operation = execute_interactive_operation(session, frame_url, product_id, command, handle); + match ui.drive(label, operation).await? { + DriveResult::Complete(Ok(())) => {} + DriveResult::Complete(Err(error)) => { + ui.finish_activities_since( + activity_checkpoint, + ActivityState::Failed, + "Stopped after an error", + ); + ui.error(error.to_string()); + } + DriveResult::Cancelled => { + ui.finish_activities_since(activity_checkpoint, ActivityState::Cancelled, "Cancelled"); + ui.error("command cancelled"); + } + } + Ok(()) +} + +async fn execute_interactive_operation( + session: &mut SigningHostSession, + frame_url: &str, + product_id: &str, + command: ShellCommand, + ui: UiHandle, +) -> Result<()> { + match command { + ShellCommand::Pair(deeplink) => start_deeplink_responder(session, deeplink).await?, + ShellCommand::Script(Some(script)) => { + let session_path = session.profile.as_ref().map(|profile| profile.path.clone()); + let script = + remember_script(session_path.as_deref(), &mut session.last_script, script)?; + ensure_signer(session).await?; + let status = script_runner::run_captured( + frame_url, + product_id, + &script, + ui, + script_runner::ScriptHostRole::SigningHost, + ) + .await?; + terminal_ui::output_event(SystemEvent::ScriptExit { + code: status.code().unwrap_or(1), + }); + } + ShellCommand::Script(None) => bail!("new scripts must be edited by the terminal UI"), + ShellCommand::Session(SessionCommand::Switch(name)) => { + switch_session(session, name).await?; + } + ShellCommand::Login => bail!("/login is only available on the pairing host"), + ShellCommand::Logout => bail!("/logout is only available on the pairing host"), + ShellCommand::Product(_) => bail!("command must be handled by the terminal UI"), + ShellCommand::Help + | ShellCommand::Clear + | ShellCommand::Copy + | ShellCommand::Log(_) + | ShellCommand::Session(SessionCommand::Current | SessionCommand::List) + | ShellCommand::Quit => { + bail!("command must be handled by the terminal UI") + } + } + Ok(()) +} + +async fn execute_non_interactive_command( + session: &mut SigningHostSession, + frame_url: &str, + product: &frame_server::ProductSelection, + command: ShellCommand, + log_controller: &LogController, +) -> Result<()> { + match command { + ShellCommand::Pair(deeplink) => respond_to_deeplink(session, deeplink).await?, + ShellCommand::Script(script) => { + let script = match script { + Some(script) => { + let session_path = session.profile.as_ref().map(|profile| profile.path.clone()); + remember_script(session_path.as_deref(), &mut session.last_script, script)? + } + None => edit_session_script_plain(session).await?, + }; + ensure_signer(session).await?; + let product_id = product.current(); + let status = script_runner::run( + frame_url, + &product_id, + &script, + script_runner::ScriptHostRole::SigningHost, + ) + .await?; + let code = status.code().unwrap_or(1); + terminal_ui::output_event(SystemEvent::ScriptExit { code }); + if !status.success() { + bail!("script exited with code {code}"); + } + } + ShellCommand::Help => println!("{HELP_TEXT}"), + ShellCommand::Clear | ShellCommand::Quit => {} + ShellCommand::Copy => bail!("/copy is only available in the terminal UI"), + ShellCommand::Login => bail!("/login is only available on the pairing host"), + ShellCommand::Logout => bail!("/logout is only available on the pairing host"), + ShellCommand::Log(level) => { + log_controller.set(level)?; + terminal_ui::output_event(SystemEvent::LogLevelChanged { level }); + } + ShellCommand::Product(ProductCommand::Current) => { + println!("{}", product.current()); + } + ShellCommand::Product(ProductCommand::Switch(product_id)) => { + if product.select(product_id)? { + terminal_ui::output_success( + format!("Product set to {}", product.current()), + Some("Reconnect product clients to continue.".to_string()), + ); + } else { + println!("{}", product.current()); + } + } + ShellCommand::Session(SessionCommand::Current) => { + println!("{}", session_status(session)); + if session.profile.is_some() && session.signer.is_none() { + terminal_ui::output_event(SystemEvent::SigningHostNeedsSession); + } + } + ShellCommand::Session(SessionCommand::List) => { + println!("{}", session_list(session)?); + } + ShellCommand::Session(SessionCommand::Switch(name)) => { + switch_session(session, name).await?; + } + } + Ok(()) +} + +fn scratch_script_directory(session: &SigningHostSession) -> PathBuf { + session.profile.as_ref().map_or_else( + || std::env::temp_dir().join("truapi-host").join("scripts"), + |profile| profile.path.join("scripts"), + ) +} + +fn select_script_to_edit( + scratch_script_directory: &std::path::Path, + last_script: &mut Option, +) -> Result { + if let Some(script) = last_script.as_ref().filter(|script| script.is_file()) { + return Ok(script.clone()); + } + let script = script_runner::create_scratch_script(scratch_script_directory)?; + *last_script = Some(script.clone()); + Ok(script) +} + +fn remember_script( + session_path: Option<&std::path::Path>, + last_script: &mut Option, + script: PathBuf, +) -> Result { + let script = if script.is_absolute() { + script + } else { + std::env::current_dir() + .context("resolve current directory for script")? + .join(script) + }; + if let Some(session_path) = session_path { + sessions::store_session_last_script(session_path, &script)?; + } + *last_script = Some(script.clone()); + Ok(script) +} + +fn session_script_to_edit(session: &mut SigningHostSession) -> Result { + let directory = scratch_script_directory(session); + let script = select_script_to_edit(&directory, &mut session.last_script)?; + if let Some(profile) = &session.profile { + session.catalog.store_last_script(profile, &script)?; + } + Ok(script) +} + +async fn edit_session_script( + session: &mut SigningHostSession, + ui: &mut ActiveTerminalUi, +) -> Result { + let script = session_script_to_edit(session)?; + edit_script_in(script, ui).await +} + +async fn edit_script_in(script: PathBuf, ui: &mut ActiveTerminalUi) -> Result { + ui.system(format!("Opening {} in your editor", script.display())); + ui.suspend()?; + let edit_result = script_runner::edit(&script).await; + let resume_result = ui.resume(); + if let Err(error) = resume_result { + return Err(error).context("restore terminal UI after editor"); + } + let status = edit_result?; + if !status.success() { + bail!( + "editor exited with {}; script retained at {}", + status.code().unwrap_or(1), + script.display() + ); + } + ui.success("Script saved", Some(script.display().to_string())); + Ok(script) +} + +async fn edit_session_script_plain(session: &mut SigningHostSession) -> Result { + if !terminal_ui::is_interactive_terminal() { + bail!("/script without a path requires an interactive terminal"); + } + let script = session_script_to_edit(session)?; + eprintln!("EDITING_SCRIPT {}", script.display()); + let status = script_runner::edit(&script).await?; + if !status.success() { + bail!( + "editor exited with {}; script retained at {}", + status.code().unwrap_or(1), + script.display() + ); + } + eprintln!("SAVED_SCRIPT {}", script.display()); + Ok(script) +} + +fn default_base_path() -> PathBuf { + if let Some(path) = std::env::var_os("XDG_STATE_HOME") { + return PathBuf::from(path).join("truapi-host"); + } + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home).join(".local/state/truapi-host"); + } + PathBuf::from(".truapi-host") +} + +#[cfg(test)] +mod cli_tests { + use super::*; + + #[test] + fn trace_log_level_is_available_before_or_after_the_subcommand() { + let before = Cli::try_parse_from(["truapi-host", "--log-level", "trace", "signing-host"]) + .expect("global log level before subcommand should parse"); + let after = Cli::try_parse_from(["truapi-host", "signing-host", "--log-level", "trace"]) + .expect("global log level after subcommand should parse"); + + assert_eq!(before.log_level, LogLevel::Trace); + assert_eq!(after.log_level, LogLevel::Trace); + assert_eq!(LogLevel::Trace.as_filter(), "trace"); + assert_eq!( + LogLevel::Trace.scoped_filter(), + "warn,truapi=trace,truapi_host=trace,truapi_platform=trace,truapi_server=trace" + ); + } + + #[test] + fn noisy_transport_targets_are_always_excluded_from_cli_logs() { + assert!(!log_target_is_visible(terminal_ui::SSO_TRANSCRIPT_TARGET)); + assert!(!log_target_is_visible("rustls")); + assert!(!log_target_is_visible("rustls::client::tls13")); + assert!(!log_target_is_visible("tungstenite::protocol")); + assert!(!log_target_is_visible( + "tungstenite::protocol::frame::socket" + )); + assert!(log_target_is_visible("tungstenite::handshake")); + assert!(log_target_is_visible("truapi_server::runtime")); + } + + #[test] + fn signing_host_exec_accepts_one_slash_command() { + let cli = Cli::try_parse_from([ + "truapi-host", + "signing-host", + "--auto-accept", + "exec", + "/help", + ]) + .expect("exec slash command should parse"); + let Command::SigningHost(args) = cli.command else { + panic!("expected signing-host command"); + }; + let Some(SigningHostAction::Exec { command }) = args.action else { + panic!("expected exec action"); + }; + assert_eq!(command, "/help"); + } + + #[test] + fn signing_host_rejects_script_and_exec_together() { + let cli = Cli::try_parse_from([ + "truapi-host", + "signing-host", + "--script", + "smoke.ts", + "exec", + "/help", + ]) + .expect("clap should parse parent options before exec"); + let Command::SigningHost(args) = cli.command else { + panic!("expected signing-host command"); + }; + assert!( + validate_signing_args(&args) + .unwrap_err() + .to_string() + .contains("--script cannot be combined") + ); + } + + #[test] + fn signing_host_accepts_a_startup_session() { + let cli = Cli::try_parse_from([ + "truapi-host", + "signing-host", + "--session", + "alice", + "exec", + "/session", + ]) + .expect("startup session should parse"); + let Command::SigningHost(args) = cli.command else { + panic!("expected signing-host command"); + }; + + assert_eq!(args.session.as_deref(), Some("alice")); + assert!(validate_signing_args(&args).is_ok()); + } + + #[test] + fn bare_script_selection_reuses_the_last_existing_script() -> Result<()> { + let temporary = tempfile::tempdir()?; + let mut last_script = None; + + let first = select_script_to_edit(temporary.path(), &mut last_script)?; + let second = select_script_to_edit(temporary.path(), &mut last_script)?; + assert_eq!(second, first); + + std::fs::remove_file(&first)?; + let replacement = select_script_to_edit(temporary.path(), &mut last_script)?; + assert_ne!(replacement, first); + assert!(replacement.is_file()); + Ok(()) + } + + #[test] + fn explicit_script_becomes_the_next_bare_script_selection() -> Result<()> { + let temporary = tempfile::tempdir()?; + let scripts = temporary.path().join("scripts"); + std::fs::create_dir_all(&scripts)?; + let mut last_script = Some(script_runner::create_scratch_script(&scripts)?); + let explicit = temporary.path().join("product-script.ts"); + std::fs::write(&explicit, "console.log('product');")?; + + let remembered = + remember_script(Some(temporary.path()), &mut last_script, explicit.clone())?; + let selected = select_script_to_edit(&scripts, &mut last_script)?; + + assert_eq!(remembered, explicit); + assert_eq!(selected, explicit); + assert_eq!( + sessions::session_last_script(temporary.path())?.as_deref(), + Some(explicit.as_path()) + ); + Ok(()) + } + + #[test] + fn signing_host_rejects_managed_session_with_mnemonic() { + let cli = Cli::try_parse_from([ + "truapi-host", + "signing-host", + "--mnemonic", + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "--session", + "alice", + "exec", + "/session", + ]) + .expect("clap should parse conflicting signer options"); + let Command::SigningHost(args) = cli.command else { + panic!("expected signing-host command"); + }; + + assert!( + validate_signing_args(&args) + .unwrap_err() + .to_string() + .contains("--session cannot be used") + ); + } +} diff --git a/rust/crates/truapi-host-cli/src/network.rs b/rust/crates/truapi-host-cli/src/network.rs new file mode 100644 index 00000000..5d8a40d0 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/network.rs @@ -0,0 +1,95 @@ +use clap::ValueEnum; + +/// Supported live network presets for the headless hosts. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] +pub enum Network { + #[value(name = "paseo-next-v2")] + #[default] + PaseoNextV2, +} + +impl Network { + pub fn config(self) -> NetworkConfig { + match self { + Self::PaseoNextV2 => NetworkConfig { + id: "paseo-next-v2", + identity_backend_base: "https://identity-backend-next.parity-testnet.parity.io/api/v1", + people_ws: "wss://paseo-people-next-system-rpc.polkadot.io", + bulletin_ws: "wss://paseo-bulletin-next-rpc.polkadot.io", + people_genesis: hex_literal_genesis( + "c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5", + ), + bulletin_genesis: hex_literal_genesis( + "8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22", + ), + live_chain_endpoints: PASEO_NEXT_V2_CHAIN_ENDPOINTS, + }, + } + } +} + +const PASEO_NEXT_V2_CHAIN_ENDPOINTS: &[ChainEndpoint] = &[ + ChainEndpoint { + genesis: hex_literal_genesis( + "bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f", + ), + ws: "wss://paseo-asset-hub-next-rpc.polkadot.io", + required_for_host: false, + }, + ChainEndpoint { + genesis: hex_literal_genesis( + "c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5", + ), + ws: "wss://paseo-people-next-system-rpc.polkadot.io", + required_for_host: true, + }, + ChainEndpoint { + genesis: hex_literal_genesis( + "8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22", + ), + ws: "wss://paseo-bulletin-next-rpc.polkadot.io", + required_for_host: true, + }, +]; + +/// Resolved RPC/backend/genesis values for one network preset. +#[derive(Debug, Clone, Copy)] +pub struct NetworkConfig { + pub id: &'static str, + pub identity_backend_base: &'static str, + pub people_ws: &'static str, + #[allow(dead_code)] + pub bulletin_ws: &'static str, + pub people_genesis: [u8; 32], + pub bulletin_genesis: [u8; 32], + pub live_chain_endpoints: &'static [ChainEndpoint], +} + +#[derive(Debug, Clone, Copy)] +pub struct ChainEndpoint { + pub genesis: [u8; 32], + pub ws: &'static str, + /// Whether host internals require this route even when optional product + /// Chain calls are disabled. + pub required_for_host: bool, +} + +/// Decode a 64-char hex genesis at compile time. +const fn hex_literal_genesis(hex: &str) -> [u8; 32] { + let bytes = hex.as_bytes(); + let mut out = [0u8; 32]; + let mut i = 0; + while i < 32 { + out[i] = hex_nibble(bytes[i * 2]) << 4 | hex_nibble(bytes[i * 2 + 1]); + i += 1; + } + out +} + +const fn hex_nibble(c: u8) -> u8 { + match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + _ => panic!("invalid hex digit in genesis literal"), + } +} diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs new file mode 100644 index 00000000..65c6034d --- /dev/null +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -0,0 +1,1425 @@ +//! `Platform` implementation for the headless hosts. +//! +//! In-memory product and core storage, a WebSocket chain provider pointed at +//! the real People-chain statement store, and a [`UserConfirmation`] that +//! either auto-accepts or prompts on the CLI (the web/iOS "sign?" modal). +//! Auth-state transitions are published on a channel so the CLI can print the +//! pairing deeplink and observe connection status. + +use std::collections::HashMap; +use std::fs; +use std::io::{IsTerminal, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use futures::stream::{self, BoxStream}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::Mutex as AsyncMutex; +use truapi::latest as api; +use truapi_platform::{ + AuthState, ChainProvider, CoreStorage, CoreStorageKey, Features, JsonRpcConnection, Navigation, + Notifications, Permissions, PreimageHost, ProductStorage, ProductStorageKey, SessionUiInfo, + ThemeHost, UserConfirmation, UserConfirmationReview, +}; + +use crate::chain::WsChainProvider; +use crate::terminal_ui::{SystemEvent, UiHandle}; + +static NEXT_STORAGE_TEMP_ID: AtomicU32 = AtomicU32::new(0); + +/// How the host answers confirmation prompts (the web/iOS "sign?" modals). +#[derive(Clone, Copy)] +pub enum ApprovalPolicy { + /// Approve every sensitive action without prompting (`--auto-accept`). + AutoAccept, + /// Prompt on the CLI (y/n) for every sensitive action. + Prompt, +} + +/// Filesystem locations for one host/session runtime. +#[derive(Clone)] +pub struct CliStoragePaths { + state_dir: PathBuf, + product_storage_dir: PathBuf, + pairing_scope: Option, +} + +#[derive(Clone)] +struct PairingStorageScope { + network_dir: PathBuf, + bootstrap_dir: PathBuf, +} + +impl CliStoragePaths { + pub fn new(state_dir: PathBuf, product_storage_dir: PathBuf) -> Self { + Self { + state_dir, + product_storage_dir, + pairing_scope: None, + } + } + + /// Resolve the last paired user, falling back to a role-level bootstrap + /// directory until the first identity is known. + pub fn pairing(network_dir: PathBuf) -> Self { + let bootstrap_dir = network_dir.join("pairing-host"); + let state_dir = read_current_pairing_user(&bootstrap_dir) + .map(|user_id| network_dir.join(format!("{user_id}_pairing_host"))) + .filter(|path| path.is_dir()) + .unwrap_or_else(|| bootstrap_dir.clone()); + let product_storage_dir = if state_dir == bootstrap_dir + && bootstrap_dir.join("storage").join("default").is_dir() + { + bootstrap_dir.join("storage").join("default") + } else { + state_dir.join("storage") + }; + Self { + product_storage_dir, + state_dir, + pairing_scope: Some(PairingStorageScope { + network_dir, + bootstrap_dir, + }), + } + } +} + +/// Headless-host platform shared by both roles. +pub struct CliPlatform { + chain: WsChainProvider, + product_storage: Mutex>>>, + core_storage: Mutex, Vec>>, + product_storage_dir: Mutex>, + core_storage_path: Mutex>, + state_dir: Mutex>, + pairing_scope: Option, + preimages: Mutex, Vec>>, + next_notification_id: AtomicU32, + scheduled_notifications: + Arc>>, + approval: ApprovalPolicy, + ui: Option, + /// Serializes interactive CLI prompts so concurrent confirmations don't + /// interleave on stdin. + prompt_lock: AsyncMutex<()>, +} + +impl CliPlatform { + /// Build a platform whose chain provider connects to the network's People + /// chain and whose optional state directory backs product/core storage. + pub fn new( + statement_store_url: impl Into, + live_chain_endpoints: &[crate::network::ChainEndpoint], + storage: Option, + approval: ApprovalPolicy, + ui: Option, + ) -> Arc { + let (product_storage_dir, legacy_product_storage_path, core_storage_path) = storage + .as_ref() + .map(|paths| { + if let Err(err) = fs::create_dir_all(&paths.state_dir) { + tracing::warn!( + path = %paths.state_dir.display(), + %err, + "could not create CLI storage dir" + ); + } + ( + Some(paths.product_storage_dir.clone()), + Some(paths.state_dir.join("product-storage.json")), + Some(paths.state_dir.join("core-storage.json")), + ) + }) + .unwrap_or((None, None, None)); + let product_storage = product_storage_dir + .as_deref() + .zip(legacy_product_storage_path.as_deref()) + .map(|(directory, legacy)| load_product_storage(directory, legacy)) + .unwrap_or_default(); + let core_storage = core_storage_path + .as_deref() + .map(load_hex_key_map) + .unwrap_or_default(); + + Arc::new(Self { + chain: WsChainProvider::new(statement_store_url, live_chain_endpoints), + product_storage: Mutex::new(product_storage), + core_storage: Mutex::new(core_storage), + product_storage_dir: Mutex::new(product_storage_dir), + core_storage_path: Mutex::new(core_storage_path), + state_dir: Mutex::new(storage.as_ref().map(|paths| paths.state_dir.clone())), + pairing_scope: storage.and_then(|paths| paths.pairing_scope), + preimages: Mutex::new(HashMap::new()), + next_notification_id: AtomicU32::new(1), + scheduled_notifications: Arc::new(Mutex::new(HashMap::new())), + approval, + ui, + prompt_lock: AsyncMutex::new(()), + }) + } + + fn core_key(key: &CoreStorageKey) -> Vec { + use parity_scale_codec::Encode; + key.encode() + } + + fn persist_product_storage( + &self, + product_id: &str, + values: &HashMap>, + ) -> Result<(), String> { + let Some(directory) = self + .product_storage_dir + .lock() + .expect("product storage path mutex poisoned") + .clone() + else { + return Ok(()); + }; + save_product_storage(&directory, product_id, values) + } + + fn persist_core_storage(&self) -> Result<(), String> { + let Some(path) = self + .core_storage_path + .lock() + .expect("core storage path mutex poisoned") + .clone() + else { + return Ok(()); + }; + let storage = self + .core_storage + .lock() + .expect("core storage mutex poisoned"); + save_hex_key_map(&path, &storage) + } + + /// Current identity-owned state directory (or the pairing bootstrap before + /// a user has connected). + pub fn state_dir(&self) -> Option { + self.state_dir + .lock() + .expect("state path mutex poisoned") + .clone() + } + + fn switch_pairing_user_storage(&self, user_id: &str) -> Result<(), String> { + let Some(scope) = &self.pairing_scope else { + return Ok(()); + }; + crate::sessions::validate_name(user_id)?; + let target_state = scope.network_dir.join(format!("{user_id}_pairing_host")); + let current_state = self + .state_dir + .lock() + .expect("state path mutex poisoned") + .clone(); + if current_state.as_ref() == Some(&target_state) { + persist_current_pairing_user(&scope.bootstrap_dir, user_id)?; + return Ok(()); + } + + fs::create_dir_all(&target_state) + .map_err(|error| format!("create {}: {error}", target_state.display()))?; + let target_product_dir = target_state.join("storage"); + let target_core_path = target_state.join("core-storage.json"); + let migrating_bootstrap = current_state.as_ref() == Some(&scope.bootstrap_dir); + + // A fresh login writes these values before its username is known. + // Carry only that pairing bootstrap across namespaces; permissions, + // allowances, and product KV remain isolated to their previous user. + let transient_keys = [ + CoreStorageKey::AuthSession, + CoreStorageKey::PairingDeviceIdentity, + CoreStorageKey::LastProcessedPairingStatement, + ] + .map(|key| Self::core_key(&key)); + let carried = { + // Keep the same path -> storage lock order used by persistence so + // an auth transition cannot deadlock with a concurrent core write. + let current_path = self + .core_storage_path + .lock() + .expect("core storage path mutex poisoned"); + let mut current = self + .core_storage + .lock() + .expect("core storage mutex poisoned"); + if migrating_bootstrap { + current.drain().collect::>() + } else { + let carried = transient_keys + .iter() + .filter_map(|key| current.remove(key).map(|value| (key.clone(), value))) + .collect::>(); + if let Some(path) = current_path.as_deref() { + save_hex_key_map(path, ¤t)?; + } + carried + } + }; + + let mut target_core = load_hex_key_map(&target_core_path); + target_core.extend(carried); + let mut target_products = load_product_storage( + &target_product_dir, + &target_state.join("product-storage.json"), + ); + if migrating_bootstrap { + target_products.extend( + self.product_storage + .lock() + .expect("product storage mutex poisoned") + .clone(), + ); + for (product_id, values) in &target_products { + save_product_storage(&target_product_dir, product_id, values)?; + } + } + { + let mut core_storage_path = self + .core_storage_path + .lock() + .expect("core storage path mutex poisoned"); + let mut core_storage = self + .core_storage + .lock() + .expect("core storage mutex poisoned"); + *core_storage = target_core; + *core_storage_path = Some(target_core_path); + } + *self + .product_storage + .lock() + .expect("product storage mutex poisoned") = target_products; + *self + .product_storage_dir + .lock() + .expect("product storage path mutex poisoned") = Some(target_product_dir); + *self.state_dir.lock().expect("state path mutex poisoned") = Some(target_state); + self.persist_core_storage()?; + persist_current_pairing_user(&scope.bootstrap_dir, user_id) + } + + /// Resolve a confirmation: auto-accept, or prompt y/n on the CLI. + async fn decide(&self, action: &str, detail: String) -> bool { + match self.approval { + ApprovalPolicy::AutoAccept => { + if let Some(ui) = &self.ui { + ui.success(format!("Approved {action} automatically"), Some(detail)); + } else { + crate::terminal_ui::output_success( + format!("Approved {action} automatically"), + Some(detail), + ); + } + true + } + ApprovalPolicy::Prompt => { + let _guard = self.prompt_lock.lock().await; + if let Some(ui) = &self.ui { + ui.confirm(action, detail).await + } else { + prompt_yes_no(action, &detail).await + } + } + } + } +} + +/// Print a confirmation and read a y/n answer from the CLI (default: no). +async fn prompt_yes_no(action: &str, detail: &str) -> bool { + if !std::io::stdin().is_terminal() { + eprintln!("approval required for {action}, but stdin is not a terminal; rejecting"); + return false; + } + let mut stdout = tokio::io::stdout(); + let _ = stdout + .write_all( + format!( + "\n\u{2500}\u{2500} confirm: {action} \u{2500}\u{2500}\n{detail}\nApprove? [y/N] " + ) + .as_bytes(), + ) + .await; + let _ = stdout.flush().await; + let mut line = String::new(); + let mut reader = BufReader::new(tokio::io::stdin()); + if reader.read_line(&mut line).await.unwrap_or(0) == 0 { + return false; + } + matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes") +} + +#[async_trait] +impl ProductStorage for CliPlatform { + async fn read(&self, key: String) -> Result>, api::HostLocalStorageReadError> { + let scoped = ProductStorageKey::decode(&key) + .map_err(|reason| api::HostLocalStorageReadError::Unknown { reason })?; + Ok(self + .product_storage + .lock() + .expect("product storage mutex poisoned") + .get(scoped.product_id()) + .and_then(|values| values.get(scoped.key())) + .cloned()) + } + + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), api::HostLocalStorageReadError> { + let scoped = ProductStorageKey::decode(&key) + .map_err(|reason| api::HostLocalStorageReadError::Unknown { reason })?; + let mut storage = self + .product_storage + .lock() + .expect("product storage mutex poisoned"); + let values = storage.entry(scoped.product_id().to_string()).or_default(); + values.insert(scoped.key().to_string(), value); + self.persist_product_storage(scoped.product_id(), values) + .map_err(|reason| api::HostLocalStorageReadError::Unknown { reason }) + } + + async fn clear(&self, key: String) -> Result<(), api::HostLocalStorageReadError> { + let scoped = ProductStorageKey::decode(&key) + .map_err(|reason| api::HostLocalStorageReadError::Unknown { reason })?; + let mut storage = self + .product_storage + .lock() + .expect("product storage mutex poisoned"); + let values = storage.entry(scoped.product_id().to_string()).or_default(); + values.remove(scoped.key()); + self.persist_product_storage(scoped.product_id(), values) + .map_err(|reason| api::HostLocalStorageReadError::Unknown { reason }) + } +} + +#[async_trait] +impl CoreStorage for CliPlatform { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, api::GenericError> { + Ok(self + .core_storage + .lock() + .expect("core storage mutex poisoned") + .get(&Self::core_key(&key)) + .cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), api::GenericError> { + { + self.core_storage + .lock() + .expect("core storage mutex poisoned") + .insert(Self::core_key(&key), value); + } + self.persist_core_storage() + .map_err(|reason| api::GenericError { reason }) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), api::GenericError> { + { + self.core_storage + .lock() + .expect("core storage mutex poisoned") + .remove(&Self::core_key(&key)); + } + self.persist_core_storage() + .map_err(|reason| api::GenericError { reason }) + } +} + +#[async_trait] +impl ChainProvider for CliPlatform { + async fn connect( + &self, + genesis_hash: [u8; 32], + ) -> Result, api::GenericError> { + self.chain.connect(genesis_hash).await + } +} + +#[async_trait] +impl Navigation for CliPlatform { + async fn navigate_to(&self, url: String) -> Result<(), api::HostNavigateToError> { + tracing::debug!(%url, "navigate_to"); + Ok(()) + } +} + +#[async_trait] +impl Notifications for CliPlatform { + async fn push_notification( + &self, + notification: api::HostPushNotificationRequest, + ) -> Result { + let id = self.next_notification_id.fetch_add(1, Ordering::Relaxed); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + if let Some(scheduled_at) = notification.scheduled_at.filter(|at| *at > now) { + { + let mut pending = self + .scheduled_notifications + .lock() + .expect("notification mutex poisoned"); + if pending.len() >= 64 { + return Err(api::GenericError { + reason: "the CLI notification schedule is full (64 pending notifications)" + .to_string(), + }); + } + pending.insert(id, notification.clone()); + } + emit_notification_event( + self.ui.as_ref(), + SystemEvent::NotificationScheduled { + id, + text: notification.text.clone(), + scheduled_at, + }, + ); + let pending = self.scheduled_notifications.clone(); + let ui = self.ui.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(scheduled_at.saturating_sub(now))).await; + let notification = pending + .lock() + .expect("notification mutex poisoned") + .remove(&id); + if let Some(notification) = notification { + emit_notification_event( + ui.as_ref(), + SystemEvent::NotificationDelivered { + id, + text: notification.text, + deeplink: notification.deeplink, + }, + ); + } + }); + } else { + emit_notification_event( + self.ui.as_ref(), + SystemEvent::NotificationDelivered { + id, + text: notification.text, + deeplink: notification.deeplink, + }, + ); + } + Ok(api::HostPushNotificationResponse { id }) + } + + async fn cancel_notification(&self, id: api::NotificationId) -> Result<(), api::GenericError> { + if self + .scheduled_notifications + .lock() + .expect("notification mutex poisoned") + .remove(&id) + .is_some() + { + emit_notification_event(self.ui.as_ref(), SystemEvent::NotificationCancelled { id }); + } + Ok(()) + } +} + +fn emit_notification_event(ui: Option<&UiHandle>, event: SystemEvent) { + if let Some(ui) = ui { + ui.event(event); + } else { + crate::terminal_ui::output_event(event); + } +} + +#[async_trait] +impl Permissions for CliPlatform { + async fn device_permission( + &self, + _request: api::HostDevicePermissionRequest, + ) -> Result { + let granted = self + .decide( + "device permission", + "A product requested access to a device capability.".to_string(), + ) + .await; + Ok(api::HostDevicePermissionResponse { granted }) + } + + async fn remote_permission( + &self, + _request: api::RemotePermissionRequest, + ) -> Result { + let granted = self + .decide( + "remote permission", + "A paired product requested a remote capability.".to_string(), + ) + .await; + Ok(api::RemotePermissionResponse { granted }) + } +} + +#[async_trait] +impl Features for CliPlatform { + async fn feature_supported( + &self, + _request: api::HostFeatureSupportedRequest, + ) -> Result { + Ok(api::HostFeatureSupportedResponse { supported: false }) + } +} + +impl truapi_platform::AuthPresenter for CliPlatform { + fn auth_state_changed(&self, state: AuthState) { + if let AuthState::Connected(info) = &state + && let Some(user_id) = storage_user_id(info) + && let Err(reason) = self.switch_pairing_user_storage(user_id) + { + tracing::warn!(%reason, %user_id, "could not switch pairing-host user storage"); + } + let (connection, event) = match &state { + AuthState::Pairing { deeplink } => ( + "pairing".to_string(), + SystemEvent::PairingDeeplink { + url: deeplink.clone(), + }, + ), + AuthState::Authenticating => ( + "authenticating".to_string(), + SystemEvent::PairingAuthenticating, + ), + AuthState::Connected(info) => ( + connected_user_id(info).unwrap_or("connected").to_string(), + SystemEvent::PairingConnected { + user_id: connected_user_id(info).map(str::to_string), + }, + ), + AuthState::Disconnected => { + ("disconnected".to_string(), SystemEvent::PairingDisconnected) + } + AuthState::LoginFailed { reason } => ( + "failed".to_string(), + SystemEvent::PairingFailed { + reason: reason.clone(), + }, + ), + }; + if let Some(ui) = &self.ui { + ui.connection(connection); + ui.event(event); + } else { + crate::terminal_ui::output_event(event); + } + } +} + +fn connected_user_id(info: &SessionUiInfo) -> Option<&str> { + info.full_username + .as_deref() + .filter(|value| !value.is_empty()) + .or_else(|| { + info.lite_username + .as_deref() + .filter(|value| !value.is_empty()) + }) +} + +fn storage_user_id(info: &SessionUiInfo) -> Option<&str> { + info.lite_username + .as_deref() + .filter(|value| !value.is_empty()) + .or_else(|| { + info.full_username + .as_deref() + .filter(|value| !value.is_empty()) + }) +} + +#[async_trait] +impl UserConfirmation for CliPlatform { + async fn confirm_user_action( + &self, + review: UserConfirmationReview, + ) -> Result { + let (action, detail) = approval_summary(&review); + Ok(self.decide(action, detail).await) + } +} + +fn approval_summary(review: &UserConfirmationReview) -> (&'static str, String) { + match review { + UserConfirmationReview::SignPayload(_) => ( + "sign payload", + "A product requested a SCALE payload signature.".to_string(), + ), + UserConfirmationReview::SignRaw(_) => ( + "sign raw data", + "A product requested a raw-data signature. The payload is hidden here.".to_string(), + ), + UserConfirmationReview::StatementStoreProductSign(review) => ( + "sign statement proof", + format!( + "Product {} requested a Statement Store proof signature over a {}-byte payload.", + review.account.dot_ns_identifier, + review.payload.len() + ), + ), + UserConfirmationReview::CreateTransaction(_) => ( + "create transaction", + "A product requested a transaction from one of your accounts.".to_string(), + ), + UserConfirmationReview::AccountAlias(review) => ( + "derive account alias", + format!( + "Product {} requested a contextual account alias.", + review.calling_product_id + ), + ), + UserConfirmationReview::CreateProof(review) => ( + "create account proof", + format!( + "Product {} requested a contextual proof bound to {} bytes.", + review.calling_product_id, + review.message.len() + ), + ), + UserConfirmationReview::IdentityDisclosure(review) => ( + "share identity", + format!( + "Product {} requested your primary identity.", + review.product_id + ), + ), + UserConfirmationReview::ResourceAllocation(_) => ( + "allocate resources", + "A product requested host-managed resources.".to_string(), + ), + UserConfirmationReview::PreimageSubmit(review) => ( + "submit preimage", + format!( + "A product requested submission of a {}-byte preimage.", + review.size + ), + ), + UserConfirmationReview::AccountAccess(review) => ( + "access another product account", + format!( + "Product {} requested access to the {} account.", + review.requesting_product_id, review.target_product_id + ), + ), + } +} + +impl ThemeHost for CliPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + Box::pin(stream::once(async { Ok(api::ThemeVariant::Dark) })) + } +} + +impl PreimageHost for CliPlatform { + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, api::GenericError>> { + let value = self + .preimages + .lock() + .expect("preimage mutex poisoned") + .get(&key) + .cloned(); + Box::pin(stream::once(async move { Ok(value) })) + } +} + +#[derive(Serialize, Deserialize)] +struct JsonMap { + values: HashMap, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProductStorageDocument { + version: u32, + product_id: String, + values: HashMap, +} + +fn load_product_storage( + directory: &Path, + legacy_path: &Path, +) -> HashMap>> { + let legacy_exists = legacy_path.is_file(); + let mut migration_safe = true; + let mut products = HashMap::>>::new(); + + if legacy_exists { + match read_string_map(legacy_path) { + Ok(values) => { + for (key, value) in values { + match ProductStorageKey::decode(&key) { + Ok(scoped) => { + products + .entry(scoped.product_id().to_string()) + .or_default() + .insert(scoped.key().to_string(), value); + } + Err(error) => { + migration_safe = false; + tracing::warn!( + path = %legacy_path.display(), + %error, + "could not migrate an unrecognized product storage key" + ); + } + } + } + } + Err(error) => { + migration_safe = false; + tracing::warn!( + path = %legacy_path.display(), + %error, + "could not decode legacy product storage" + ); + } + } + } + + let entries = match fs::read_dir(directory) { + Ok(entries) => Some(entries), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + tracing::warn!( + path = %directory.display(), + %error, + "could not list per-product CLI storage" + ); + None + } + }; + if let Some(entries) = entries { + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let Some((product_id, values)) = load_product_storage_file(&path) else { + continue; + }; + let expected_path = product_storage_path(directory, &product_id); + if path != expected_path { + tracing::warn!( + path = %path.display(), + expected = %expected_path.display(), + "ignored product storage with a non-canonical filename" + ); + continue; + } + products.entry(product_id).or_default().extend(values); + } + } + + if legacy_exists && migration_safe { + let migrated = products.iter().try_for_each(|(product_id, values)| { + save_product_storage(directory, product_id, values) + }); + match migrated { + Ok(()) => { + let backup = legacy_path.with_file_name("product-storage.v1.json.migrated"); + if backup.exists() { + tracing::warn!( + path = %legacy_path.display(), + backup = %backup.display(), + "legacy product storage was migrated but its backup path already exists" + ); + } else if let Err(error) = fs::rename(legacy_path, &backup) { + tracing::warn!( + path = %legacy_path.display(), + backup = %backup.display(), + %error, + "could not retain migrated product storage backup" + ); + } + } + Err(error) => tracing::warn!( + path = %legacy_path.display(), + %error, + "could not migrate legacy product storage" + ), + } + } + + products +} + +fn load_product_storage_file(path: &Path) -> Option<(String, HashMap>)> { + let text = match fs::read_to_string(path) { + Ok(text) => text, + Err(error) => { + tracing::warn!(path = %path.display(), %error, "could not read product storage"); + return None; + } + }; + let document = match serde_json::from_str::(&text) { + Ok(document) if document.version == 1 => document, + Ok(document) => { + tracing::warn!( + path = %path.display(), + version = document.version, + "unsupported product storage version" + ); + return None; + } + Err(error) => { + tracing::warn!(path = %path.display(), %error, "could not decode product storage"); + return None; + } + }; + let normalized = match ProductStorageKey::new(&document.product_id, "") { + Ok(scoped) => scoped.product_id().to_string(), + Err(error) => { + tracing::warn!( + path = %path.display(), + %error, + "product storage contains an invalid product id" + ); + return None; + } + }; + let values = document + .values + .into_iter() + .map(|(key, value)| hex::decode(value).map(|bytes| (key, bytes))) + .collect::, _>>(); + let values = match values { + Ok(values) => values, + Err(error) => { + tracing::warn!( + path = %path.display(), + %error, + "product storage contains an invalid value" + ); + return None; + } + }; + Some((normalized, values)) +} + +fn save_product_storage( + directory: &Path, + product_id: &str, + values: &HashMap>, +) -> Result<(), String> { + fs::create_dir_all(directory).map_err(|error| { + format!( + "create product storage directory {}: {error}", + directory.display() + ) + })?; + let document = ProductStorageDocument { + version: 1, + product_id: product_id.to_string(), + values: values + .iter() + .map(|(key, value)| (key.clone(), hex::encode(value))) + .collect(), + }; + let text = serde_json::to_string_pretty(&document).map_err(|error| error.to_string())?; + atomic_write( + &product_storage_path(directory, product_id), + text.as_bytes(), + ) +} + +fn product_storage_path(directory: &Path, product_id: &str) -> PathBuf { + let mut slug = product_id + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '.' | '-') { + character + } else { + '-' + } + }) + .take(48) + .collect::(); + slug = slug + .trim_matches(|character| matches!(character, '.' | '-')) + .to_string(); + if slug.is_empty() { + slug.push_str("product"); + } + let digest = Sha256::digest(product_id.as_bytes()); + directory.join(format!("{slug}--{}.json", hex::encode(digest))) +} + +fn load_string_map(path: &Path) -> HashMap> { + match read_string_map(path) { + Ok(values) => values, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => HashMap::new(), + Err(err) => { + tracing::warn!(path = %path.display(), %err, "could not read CLI storage"); + HashMap::new() + } + } +} + +fn read_string_map(path: &Path) -> std::io::Result>> { + let text = fs::read_to_string(path)?; + let json = serde_json::from_str::(&text) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + json.values + .into_iter() + .map(|(key, value)| { + hex::decode(value) + .map(|bytes| (key, bytes)) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error)) + }) + .collect() +} + +fn save_string_map(path: &Path, values: &HashMap>) -> Result<(), String> { + let json = JsonMap { + values: values + .iter() + .map(|(key, value)| (key.clone(), hex::encode(value))) + .collect(), + }; + let text = serde_json::to_string_pretty(&json).map_err(|err| err.to_string())?; + atomic_write(path, text.as_bytes()) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("storage path has no parent: {}", path.display()))?; + fs::create_dir_all(parent).map_err(|error| format!("create storage dir: {error}"))?; + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("storage.json"); + let temporary_id = NEXT_STORAGE_TEMP_ID.fetch_add(1, Ordering::Relaxed); + let temporary = + path.with_file_name(format!(".{name}.{}.{temporary_id}.tmp", std::process::id())); + let mut file = fs::File::create(&temporary) + .map_err(|error| format!("create {}: {error}", temporary.display()))?; + file.write_all(bytes) + .map_err(|error| format!("write {}: {error}", temporary.display()))?; + file.sync_all() + .map_err(|error| format!("sync {}: {error}", temporary.display()))?; + drop(file); + #[cfg(windows)] + if path.exists() { + fs::remove_file(path).map_err(|error| format!("replace {}: {error}", path.display()))?; + } + fs::rename(&temporary, path).map_err(|error| format!("persist {}: {error}", path.display()))?; + #[cfg(unix)] + fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("sync storage dir {}: {error}", parent.display()))?; + Ok(()) +} + +fn load_hex_key_map(path: &Path) -> HashMap, Vec> { + load_string_map(path) + .into_iter() + .filter_map(|(key, value)| hex::decode(key).ok().map(|decoded| (decoded, value))) + .collect() +} + +const CURRENT_PAIRING_USER_FILE: &str = "current-user"; + +fn read_current_pairing_user(bootstrap_dir: &Path) -> Option { + let user_id = fs::read_to_string(bootstrap_dir.join(CURRENT_PAIRING_USER_FILE)) + .ok()? + .trim() + .to_string(); + crate::sessions::validate_name(&user_id).ok()?; + Some(user_id) +} + +fn persist_current_pairing_user(bootstrap_dir: &Path, user_id: &str) -> Result<(), String> { + fs::create_dir_all(bootstrap_dir) + .map_err(|error| format!("create {}: {error}", bootstrap_dir.display()))?; + let path = bootstrap_dir.join(CURRENT_PAIRING_USER_FILE); + let temporary = bootstrap_dir.join(format!( + ".{CURRENT_PAIRING_USER_FILE}.{}.tmp", + std::process::id() + )); + fs::write(&temporary, format!("{user_id}\n")) + .map_err(|error| format!("write {}: {error}", temporary.display()))?; + fs::rename(&temporary, &path).map_err(|error| format!("persist {}: {error}", path.display())) +} + +fn save_hex_key_map(path: &Path, values: &HashMap, Vec>) -> Result<(), String> { + let keyed: HashMap> = values + .iter() + .map(|(key, value)| (hex::encode(key), value.clone())) + .collect(); + save_string_map(path, &keyed) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn test_storage_paths(root: &Path, session: &str) -> CliStoragePaths { + CliStoragePaths::new(root.to_path_buf(), root.join("storage").join(session)) + } + + #[test] + fn connected_user_id_prefers_the_full_username() { + let info = SessionUiInfo { + lite_username: Some("alice.dot".to_string()), + full_username: Some("Alice".to_string()), + ..SessionUiInfo::default() + }; + assert_eq!(connected_user_id(&info), Some("Alice")); + + let info = SessionUiInfo { + lite_username: Some("alice.dot".to_string()), + ..SessionUiInfo::default() + }; + assert_eq!(connected_user_id(&info), Some("alice.dot")); + assert_eq!(connected_user_id(&SessionUiInfo::default()), None); + } + + #[test] + fn pairing_storage_switches_with_the_connected_username() { + let temporary = tempdir().expect("create pairing storage root"); + let network_dir = temporary.path().join("testnet"); + let platform = CliPlatform::new( + "", + &[], + Some(CliStoragePaths::pairing(network_dir.clone())), + ApprovalPolicy::AutoAccept, + None, + ); + let product_key = + ProductStorageKey::new("product.dot", "theme").expect("product storage key"); + + platform + .switch_pairing_user_storage("alice.dot") + .expect("select alice"); + futures::executor::block_on(platform.write(product_key.encode(), b"dark".to_vec())) + .expect("write alice product value"); + + platform + .switch_pairing_user_storage("bob.dot") + .expect("select bob"); + assert_eq!( + futures::executor::block_on(platform.read(product_key.encode())) + .expect("read bob product value"), + None + ); + + platform + .switch_pairing_user_storage("alice.dot") + .expect("restore alice"); + assert_eq!( + futures::executor::block_on(platform.read(product_key.encode())) + .expect("read alice product value"), + Some(b"dark".to_vec()) + ); + assert_eq!( + platform.state_dir().as_deref(), + Some(network_dir.join("alice.dot_pairing_host").as_path()) + ); + assert_eq!( + read_current_pairing_user(&network_dir.join("pairing-host")).as_deref(), + Some("alice.dot") + ); + } + + #[test] + fn legacy_pairing_storage_moves_to_the_first_resolved_user() { + let temporary = tempdir().expect("create pairing storage root"); + let network_dir = temporary.path().join("testnet"); + let legacy_product_dir = network_dir.join("pairing-host/storage/default"); + let product_key = + ProductStorageKey::new("product.dot", "theme").expect("product storage key"); + save_product_storage( + &legacy_product_dir, + "product.dot", + &HashMap::from([("theme".to_string(), b"dark".to_vec())]), + ) + .expect("write legacy pairing product storage"); + let platform = CliPlatform::new( + "", + &[], + Some(CliStoragePaths::pairing(network_dir.clone())), + ApprovalPolicy::AutoAccept, + None, + ); + + platform + .switch_pairing_user_storage("alice.dot") + .expect("resolve legacy storage owner"); + + assert_eq!( + futures::executor::block_on(platform.read(product_key.encode())) + .expect("read migrated product value"), + Some(b"dark".to_vec()) + ); + assert!(network_dir.join("alice.dot_pairing_host/storage").is_dir()); + } + + #[test] + fn approval_summaries_are_concise_and_do_not_dump_payloads() { + let review = + UserConfirmationReview::PreimageSubmit(truapi_platform::PreimageSubmitReview { + size: 4_096, + }); + + let (action, detail) = approval_summary(&review); + + assert_eq!(action, "submit preimage"); + assert_eq!( + detail, + "A product requested submission of a 4096-byte preimage." + ); + assert!(!detail.contains("[")); + } + + #[test] + fn statement_proof_approval_names_product_without_dumping_payload() { + let review = UserConfirmationReview::StatementStoreProductSign( + truapi_platform::StatementStoreProductSignReview { + account: api::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: vec![0x42; 128], + }, + ); + + let (action, detail) = approval_summary(&review); + + assert_eq!(action, "sign statement proof"); + assert_eq!( + detail, + "Product myapp.dot requested a Statement Store proof signature over a 128-byte payload." + ); + assert!(!detail.contains("[66")); + } + + #[test] + fn cli_notifications_return_stable_ids_and_cancel_idempotently() { + let platform = CliPlatform::new("", &[], None, ApprovalPolicy::AutoAccept, None); + let first = futures::executor::block_on(platform.push_notification( + api::HostPushNotificationRequest { + text: "Hello".to_string(), + deeplink: None, + scheduled_at: None, + }, + )) + .expect("immediate notification"); + let second = futures::executor::block_on(platform.push_notification( + api::HostPushNotificationRequest { + text: "Again".to_string(), + deeplink: Some("polkadot://example".to_string()), + scheduled_at: None, + }, + )) + .expect("second notification"); + + assert_eq!(first.id, 1); + assert_eq!(second.id, 2); + futures::executor::block_on(platform.cancel_notification(first.id)) + .expect("already-fired cancellation is idempotent"); + futures::executor::block_on(platform.cancel_notification(999)) + .expect("unknown cancellation is idempotent"); + } + + #[test] + fn product_storage_uses_safe_per_product_files() { + let temporary = tempdir().expect("create product storage root"); + let first = ProductStorageKey::new("first.dot", "theme").expect("first product key"); + let localhost = + ProductStorageKey::new("localhost:3000", "theme").expect("localhost product key"); + let platform = CliPlatform::new( + "", + &[], + Some(test_storage_paths(temporary.path(), "test")), + ApprovalPolicy::AutoAccept, + None, + ); + + futures::executor::block_on(async { + platform + .write(first.encode(), b"dark".to_vec()) + .await + .expect("write first product"); + platform + .write(localhost.encode(), b"light".to_vec()) + .await + .expect("write localhost product"); + }); + + let directory = temporary.path().join("storage").join("test"); + let files = fs::read_dir(&directory) + .expect("list product storage") + .map(|entry| entry.expect("product storage entry").path()) + .collect::>(); + assert_eq!(files.len(), 2); + assert!(files.iter().all(|path| { + path.parent() == Some(directory.as_path()) + && path.extension().and_then(|extension| extension.to_str()) == Some("json") + && !path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.contains(':')) + })); + + drop(platform); + let restored = CliPlatform::new( + "", + &[], + Some(test_storage_paths(temporary.path(), "test")), + ApprovalPolicy::AutoAccept, + None, + ); + let (first_value, localhost_value) = futures::executor::block_on(async { + ( + restored.read(first.encode()).await.expect("read first"), + restored + .read(localhost.encode()) + .await + .expect("read localhost"), + ) + }); + assert_eq!(first_value, Some(b"dark".to_vec())); + assert_eq!(localhost_value, Some(b"light".to_vec())); + } + + #[test] + fn product_storage_is_isolated_by_session_then_product() { + let temporary = tempdir().expect("create session storage root"); + let key = ProductStorageKey::new("same.dot", "value").expect("product key"); + let first = CliPlatform::new( + "", + &[], + Some(test_storage_paths(temporary.path(), "first")), + ApprovalPolicy::AutoAccept, + None, + ); + let second = CliPlatform::new( + "", + &[], + Some(test_storage_paths(temporary.path(), "second")), + ApprovalPolicy::AutoAccept, + None, + ); + + futures::executor::block_on(async { + first + .write(key.encode(), b"one".to_vec()) + .await + .expect("write first session"); + second + .write(key.encode(), b"two".to_vec()) + .await + .expect("write second session"); + }); + + let first_path = + product_storage_path(&temporary.path().join("storage").join("first"), "same.dot"); + let second_path = + product_storage_path(&temporary.path().join("storage").join("second"), "same.dot"); + assert!(first_path.is_file()); + assert!(second_path.is_file()); + assert_ne!( + fs::read_to_string(first_path).expect("read first session file"), + fs::read_to_string(second_path).expect("read second session file") + ); + } + + #[test] + fn legacy_product_storage_migrates_and_keeps_a_backup() { + let temporary = tempdir().expect("create migration root"); + let first = ProductStorageKey::new("first.dot", "alpha").expect("first product key"); + let second = ProductStorageKey::new("second.dot", "beta").expect("second product key"); + let legacy_path = temporary.path().join("product-storage.json"); + save_string_map( + &legacy_path, + &HashMap::from([ + (first.encode(), b"one".to_vec()), + (second.encode(), b"two".to_vec()), + ]), + ) + .expect("write legacy product storage"); + + let platform = CliPlatform::new( + "", + &[], + Some(test_storage_paths(temporary.path(), "test")), + ApprovalPolicy::AutoAccept, + None, + ); + + assert!(!legacy_path.exists()); + assert!( + temporary + .path() + .join("product-storage.v1.json.migrated") + .is_file() + ); + assert_eq!( + fs::read_dir(temporary.path().join("storage").join("test")) + .expect("list migrated product files") + .count(), + 2 + ); + let values = futures::executor::block_on(async { + ( + platform.read(first.encode()).await.expect("read first"), + platform.read(second.encode()).await.expect("read second"), + ) + }); + assert_eq!(values, (Some(b"one".to_vec()), Some(b"two".to_vec()))); + } + + #[test] + fn corrupt_legacy_product_storage_is_not_marked_as_migrated() { + let temporary = tempdir().expect("create corrupt migration root"); + let legacy_path = temporary.path().join("product-storage.json"); + fs::write(&legacy_path, "{not-json").expect("write corrupt legacy storage"); + + let _platform = CliPlatform::new( + "", + &[], + Some(test_storage_paths(temporary.path(), "test")), + ApprovalPolicy::AutoAccept, + None, + ); + + assert!(legacy_path.is_file()); + assert!( + !temporary + .path() + .join("product-storage.v1.json.migrated") + .exists() + ); + } +} diff --git a/rust/crates/truapi-host-cli/src/script_runner.rs b/rust/crates/truapi-host-cli/src/script_runner.rs new file mode 100644 index 00000000..97ab3e20 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/script_runner.rs @@ -0,0 +1,278 @@ +//! Runs a user host-script under `bun`, driving a host through the injected +//! `truapi` global. +//! +//! The Rust CLI owns the flow: it starts the host, then spawns `js/runner.ts` +//! (which connects the `@parity/truapi` client to the host and evaluates the +//! user script). The child's exit status becomes the host command's status, so +//! `truapi-host pairing-host --script foo.ts` *is* the test — there is no +//! separate bun orchestrator. + +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::ExitStatus; +use std::process::Stdio; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; + +use crate::terminal_ui::{self, SystemEvent, UiHandle}; + +/// Host topology serving the product script. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScriptHostRole { + PairingHost, + SigningHost, +} + +impl ScriptHostRole { + fn as_env_value(self) -> &'static str { + match self { + Self::PairingHost => "pairing-host", + Self::SigningHost => "signing-host", + } + } +} + +const SCRATCH_TEMPLATE: &str = r#"#!/usr/bin/env bun + +// Import any npm package you need - Bun installs missing dependencies automatically. +import chalk from "chalk"; + +console.log(chalk.cyan.bold("\n🚀 TrUAPI script\n")); + +const result = await truapi.account.getUserId(); +if (!result.isOk()) { + throw new Error(`getUserId failed: ${JSON.stringify(result.error)}`); +} +console.log(chalk.green("user id:"), result.value); +"#; + +/// Locate `js/runner.ts`, shipped alongside the crate. +/// +/// Overridable with `TRUAPI_HOST_RUNNER` for packaged/relocated builds. +fn runner_path() -> PathBuf { + if let Ok(path) = std::env::var("TRUAPI_HOST_RUNNER") { + return PathBuf::from(path); + } + Path::new(env!("CARGO_MANIFEST_DIR")).join("js/runner.ts") +} + +/// Create a durable, uniquely-named TypeScript scratch file seeded with the +/// public TrUAPI example. +pub fn create_scratch_script(directory: &Path) -> Result { + fs::create_dir_all(directory) + .with_context(|| format!("create script directory {}", directory.display()))?; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + for sequence in 0..100 { + let path = directory.join(format!( + "script-{timestamp}-{}-{sequence}.ts", + std::process::id() + )); + let mut file = match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(error) + .with_context(|| format!("create scratch script {}", path.display())); + } + }; + file.write_all(SCRATCH_TEMPLATE.as_bytes()) + .with_context(|| format!("write scratch script {}", path.display()))?; + return Ok(path); + } + anyhow::bail!( + "could not allocate a unique scratch script in {}", + directory.display() + ); +} + +/// Open the script in the configured terminal editor and wait for it to exit. +pub async fn edit(script: &Path) -> Result { + let specification = configured_editor(); + let (program, arguments) = parse_editor(&specification)?; + let mut command = Command::new(program); + command + .args(arguments) + .arg(script) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + command + .status() + .await + .with_context(|| format!("failed to launch editor {specification:?}")) +} + +fn configured_editor() -> String { + std::env::var("VISUAL") + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + std::env::var("EDITOR") + .ok() + .filter(|value| !value.trim().is_empty()) + }) + .unwrap_or_else(|| { + if cfg!(windows) { + "notepad".to_string() + } else { + "vi".to_string() + } + }) +} + +fn parse_editor(specification: &str) -> Result<(String, Vec)> { + let mut parts = shlex::split(specification) + .with_context(|| format!("invalid editor command {specification:?}"))? + .into_iter(); + let program = parts + .next() + .with_context(|| format!("editor command is empty: {specification:?}"))?; + Ok((program, parts.collect())) +} + +/// Run `script` against the host serving frames at `frame_url`, as product +/// `product_id`. Inherits stdio so the script's output and any CLI confirmation +/// prompts share the terminal. Returns the child's exit status. +pub async fn run( + frame_url: &str, + product_id: &str, + script: &Path, + host_role: ScriptHostRole, +) -> Result { + let mut command = command(frame_url, product_id, script, host_role)?; + terminal_ui::output_event(SystemEvent::ScriptStarted); + command + .status() + .await + .context("failed to spawn `bun` for the host script (is bun installed?)") +} + +/// Run a product script with stdout and stderr streamed into the terminal UI. +pub async fn run_captured( + frame_url: &str, + product_id: &str, + script: &Path, + ui: UiHandle, + host_role: ScriptHostRole, +) -> Result { + let mut command = command(frame_url, product_id, script, host_role)?; + terminal_ui::output_event(SystemEvent::ScriptStarted); + command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = command + .spawn() + .context("failed to spawn `bun` for the host script (is bun installed?)")?; + let stdout = child.stdout.take().context("capture script stdout")?; + let stderr = child.stderr.take().context("capture script stderr")?; + let stdout_ui = ui.clone(); + let stdout_task = async move { + let mut lines = BufReader::new(stdout).lines(); + while let Some(line) = lines.next_line().await? { + stdout_ui.script_stdout(line); + } + Ok::<(), std::io::Error>(()) + }; + let stderr_task = async move { + let mut lines = BufReader::new(stderr).lines(); + while let Some(line) = lines.next_line().await? { + ui.script_stderr(line); + } + Ok::<(), std::io::Error>(()) + }; + let (status, stdout, stderr) = tokio::join!(child.wait(), stdout_task, stderr_task); + stdout.context("read script stdout")?; + stderr.context("read script stderr")?; + status.context("wait for host script") +} + +fn command( + frame_url: &str, + product_id: &str, + script: &Path, + host_role: ScriptHostRole, +) -> Result { + let runner = runner_path(); + if !runner.exists() { + anyhow::bail!( + "host-script runner not found at {}; set TRUAPI_HOST_RUNNER", + runner.display() + ); + } + let script = script + .canonicalize() + .with_context(|| format!("script not found: {}", script.display()))?; + + let mut command = Command::new("bun"); + command + .arg("run") + .arg(&runner) + .env("TRUAPI_FRAME_URL", frame_url) + .env("TRUAPI_PRODUCT_ID", product_id) + .env("TRUAPI_SCRIPT", &script) + .env("TRUAPI_CLI_HOST_ROLE", host_role.as_env_value()); + Ok(command) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scratch_script_starts_as_a_bun_script_with_dependency_examples() -> Result<()> { + let temporary = tempfile::tempdir()?; + + let script = create_scratch_script(temporary.path())?; + let contents = fs::read_to_string(script)?; + + assert!(contents.starts_with("#!/usr/bin/env bun\n")); + assert!(contents.contains("import chalk from \"chalk\";")); + assert!(contents.contains("truapi.account.getUserId()")); + assert_eq!(contents, SCRATCH_TEMPLATE); + Ok(()) + } + + #[test] + fn host_scripts_are_run_by_bun() -> Result<()> { + let temporary = tempfile::tempdir()?; + let script = temporary.path().join("script.ts"); + fs::write(&script, "console.log('hello');\n")?; + + let command = command( + "ws://127.0.0.1:1234", + "example.dot", + &script, + ScriptHostRole::SigningHost, + )?; + let command = command.as_std(); + let arguments = command.get_args().collect::>(); + + assert_eq!(command.get_program(), std::ffi::OsStr::new("bun")); + assert_eq!(arguments[0], std::ffi::OsStr::new("run")); + assert_eq!(arguments[1], runner_path()); + assert_eq!( + command + .get_envs() + .find_map(|(key, value)| { (key == "TRUAPI_CLI_HOST_ROLE").then_some(value) }), + Some(Some(std::ffi::OsStr::new("signing-host"))) + ); + Ok(()) + } + + #[test] + fn editor_command_accepts_quoted_arguments_without_a_shell() -> Result<()> { + let (program, arguments) = parse_editor("code --wait \"profile one\"")?; + + assert_eq!(program, "code"); + assert_eq!(arguments, ["--wait", "profile one"]); + Ok(()) + } +} diff --git a/rust/crates/truapi-host-cli/src/sessions.rs b/rust/crates/truapi-host-cli/src/sessions.rs new file mode 100644 index 00000000..b9bb8249 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/sessions.rs @@ -0,0 +1,606 @@ +//! Network-scoped signing-host session directories and current selection. + +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +pub const DEFAULT_SESSION_NAME: &str = "default"; +const CURRENT_SESSION_FILE: &str = "current-session"; +const SESSION_INFO_FILE: &str = "session.json"; + +#[derive(Debug, Serialize, Deserialize)] +struct SessionInfo { + version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + user_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + last_script: Option, +} + +impl Default for SessionInfo { + fn default() -> Self { + Self { + version: 1, + user_id: None, + last_script: None, + } + } +} + +/// Filesystem locations owned by one managed signing-host session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionProfile { + pub name: String, + /// Core/session state directory shown by `/session`. + pub path: PathBuf, + /// Product-local KV directory for this named session. + pub product_storage_dir: PathBuf, + /// Directory containing this session's `accounts.json`. + pub account_base_path: PathBuf, +} + +/// Persistent session catalog for one network. +#[derive(Debug, Clone)] +pub struct SessionCatalog { + base_path: PathBuf, + network_path: PathBuf, + role_path: PathBuf, +} + +impl SessionCatalog { + pub fn new(base_path: PathBuf, network_id: &str) -> Result { + let base_path = absolute_path(base_path)?; + let network_path = base_path.join(network_id); + let role_path = network_path.join("signing-host"); + fs::create_dir_all(&role_path) + .with_context(|| format!("create session root {}", role_path.display()))?; + Ok(Self { + base_path, + network_path, + role_path, + }) + } + + pub fn profile(&self, name: &str) -> Result { + validate_name(name).map_err(anyhow::Error::msg)?; + if name == DEFAULT_SESSION_NAME { + return Ok(SessionProfile { + name: name.to_string(), + path: self.role_path.clone(), + product_storage_dir: self.role_path.join("storage").join(name), + // Preserve the pre-session account store for compatibility. + account_base_path: self.base_path.clone(), + }); + } + let identity_path = self.identity_path(name); + let legacy_path = self.role_path.join("sessions").join(name); + let path = if legacy_path.is_dir() && !identity_path.is_dir() { + legacy_path + } else { + identity_path + }; + let product_storage_dir = if path.starts_with(self.role_path.join("sessions")) { + self.role_path.join("storage").join(name) + } else { + path.join("storage") + }; + Ok(SessionProfile { + name: name.to_string(), + path: path.clone(), + product_storage_dir, + account_base_path: path, + }) + } + + pub fn ensure_profile(&self, name: &str) -> Result { + let profile = self.profile(name)?; + fs::create_dir_all(&profile.path) + .with_context(|| format!("create session {}", profile.path.display()))?; + Ok(profile) + } + + pub fn exists(&self, name: &str) -> bool { + self.profile(name) + .is_ok_and(|profile| name == DEFAULT_SESSION_NAME || profile.path.is_dir()) + } + + pub fn current_name(&self) -> String { + let path = self.role_path.join(CURRENT_SESSION_FILE); + let Ok(name) = fs::read_to_string(path) else { + return DEFAULT_SESSION_NAME.to_string(); + }; + let name = name.trim(); + if self.exists(name) { + name.to_string() + } else { + DEFAULT_SESSION_NAME.to_string() + } + } + + pub fn set_current(&self, name: &str) -> Result<()> { + let profile = self.ensure_profile(name)?; + let path = self.role_path.join(CURRENT_SESSION_FILE); + let temporary = self.role_path.join(format!( + ".{CURRENT_SESSION_FILE}.{}.tmp", + std::process::id() + )); + fs::write(&temporary, format!("{}\n", profile.name)) + .with_context(|| format!("write current session {}", temporary.display()))?; + fs::rename(&temporary, &path) + .with_context(|| format!("persist current session {}", path.display()))?; + Ok(()) + } + + pub fn list(&self) -> Result> { + let mut names = Vec::new(); + let sessions_path = self.role_path.join("sessions"); + match fs::read_dir(&sessions_path) { + Ok(entries) => { + for entry in entries.filter_map(std::result::Result::ok) { + if !entry.file_type().is_ok_and(|kind| kind.is_dir()) { + continue; + } + let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { + continue; + }; + if validate_name(&name).is_ok() && name != DEFAULT_SESSION_NAME { + names.push(name); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("list sessions {}", sessions_path.display())); + } + } + for entry in fs::read_dir(&self.network_path) + .with_context(|| format!("list host profiles {}", self.network_path.display()))? + .filter_map(std::result::Result::ok) + { + if !entry.file_type().is_ok_and(|kind| kind.is_dir()) { + continue; + } + let Some(filename) = entry.file_name().to_str().map(ToOwned::to_owned) else { + continue; + }; + let Some(name) = filename.strip_suffix("_signing_host") else { + continue; + }; + if validate_name(name).is_ok() && name != DEFAULT_SESSION_NAME { + names.push(name.to_string()); + } + } + names.sort(); + names.dedup(); + Ok(names) + } + + /// Move a provisional or legacy session into the user-owned host root. + /// + /// The public session name is the Lite username. The suffix is only a + /// filesystem discriminator so pairing and signing state cannot collide. + pub fn promote_to_user( + &self, + profile: &SessionProfile, + user_id: &str, + ) -> Result { + validate_name(user_id).map_err(anyhow::Error::msg)?; + let target_path = self.identity_path(user_id); + if profile.path != target_path && !target_path.exists() { + if profile.path == self.role_path { + fs::create_dir_all(&target_path) + .with_context(|| format!("create user host {}", target_path.display()))?; + migrate_default_profile(profile, &target_path)?; + } else { + fs::rename(&profile.path, &target_path).with_context(|| { + format!( + "move host profile {} to {}", + profile.path.display(), + target_path.display() + ) + })?; + if profile.product_storage_dir.exists() + && !profile.product_storage_dir.starts_with(&profile.path) + { + let target_storage = target_path.join("storage"); + fs::create_dir_all(&target_path)?; + fs::rename(&profile.product_storage_dir, &target_storage).with_context( + || { + format!( + "move product storage {} to {}", + profile.product_storage_dir.display(), + target_storage.display() + ) + }, + )?; + } + } + } + let promoted = SessionProfile { + name: user_id.to_string(), + path: target_path.clone(), + product_storage_dir: target_path.join("storage"), + account_base_path: target_path, + }; + fs::create_dir_all(&promoted.path) + .with_context(|| format!("create user host {}", promoted.path.display()))?; + self.store_user_id(&promoted, user_id)?; + Ok(promoted) + } + + pub fn cached_user_id(&self, profile: &SessionProfile) -> Result> { + Ok(read_session_info(&profile.path)? + .user_id + .filter(|user_id| !user_id.is_empty())) + } + + pub fn store_user_id(&self, profile: &SessionProfile, user_id: &str) -> Result<()> { + if user_id.is_empty() { + return Ok(()); + } + let mut info = read_session_info(&profile.path)?; + info.user_id = Some(user_id.to_string()); + write_session_info(&profile.path, &info) + } + + /// Return the last script used in this session, if it still exists. + pub fn last_script(&self, profile: &SessionProfile) -> Result> { + session_last_script(&profile.path) + } + + /// Remember the last script used in this session. + pub fn store_last_script(&self, profile: &SessionProfile, script: &Path) -> Result<()> { + store_session_last_script(&profile.path, script) + } + + fn identity_path(&self, user_id: &str) -> PathBuf { + self.network_path.join(format!("{user_id}_signing_host")) + } +} + +fn migrate_default_profile(profile: &SessionProfile, target_path: &Path) -> Result<()> { + for name in [ + "core-storage.json", + "product-storage.json", + SESSION_INFO_FILE, + ] { + let source = profile.path.join(name); + if source.is_file() { + fs::rename(&source, target_path.join(name)) + .with_context(|| format!("move {}", source.display()))?; + } + } + let scripts = profile.path.join("scripts"); + if scripts.is_dir() { + fs::rename(&scripts, target_path.join("scripts")) + .with_context(|| format!("move {}", scripts.display()))?; + } + if profile.product_storage_dir.is_dir() { + fs::rename(&profile.product_storage_dir, target_path.join("storage")) + .with_context(|| format!("move {}", profile.product_storage_dir.display()))?; + } + let account_store = profile.account_base_path.join("accounts.json"); + if account_store.is_file() { + fs::copy(&account_store, target_path.join("accounts.json")) + .with_context(|| format!("copy {}", account_store.display()))?; + } + Ok(()) +} + +/// Return the last script recorded in a host/session state directory. +pub fn session_last_script(session_path: &Path) -> Result> { + let info = read_session_info(session_path)?; + let Some(filename) = info.last_script else { + return Ok(None); + }; + let configured = Path::new(&filename); + if configured.is_absolute() { + return Ok(configured.is_file().then(|| configured.to_path_buf())); + } + + let relative = configured; + let mut components = relative.components(); + let Some(Component::Normal(filename)) = components.next() else { + anyhow::bail!("session last script is not a portable filename"); + }; + if components.next().is_some() { + anyhow::bail!("session last script must stay inside its scripts directory"); + } + let script = session_path.join("scripts").join(filename); + Ok(script.is_file().then_some(script)) +} + +/// Record a script in a host/session state directory. +/// +/// Scratch scripts are stored by filename so existing session directories stay +/// portable. Explicit scripts outside that directory are stored as absolute +/// paths so a later bare `/script` reopens the same file. +pub fn store_session_last_script(session_path: &Path, script: &Path) -> Result<()> { + let script = absolute_path(script.to_path_buf())?; + let scripts = session_path.join("scripts"); + let stored_path = if script.parent() == Some(scripts.as_path()) { + script + .file_name() + .and_then(|filename| filename.to_str()) + .context("scratch script filename is not valid UTF-8")? + } else { + script.to_str().context("script path is not valid UTF-8")? + }; + let mut info = read_session_info(session_path)?; + info.last_script = Some(stored_path.to_string()); + write_session_info(session_path, &info) +} + +fn read_session_info(session_path: &Path) -> Result { + let path = session_path.join(SESSION_INFO_FILE); + let text = match fs::read_to_string(&path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(SessionInfo::default()); + } + Err(error) => { + return Err(error).with_context(|| format!("read session metadata {}", path.display())); + } + }; + serde_json::from_str(&text) + .with_context(|| format!("decode session metadata {}", path.display())) +} + +fn write_session_info(session_path: &Path, info: &SessionInfo) -> Result<()> { + fs::create_dir_all(session_path) + .with_context(|| format!("create session {}", session_path.display()))?; + let path = session_path.join(SESSION_INFO_FILE); + let temporary = session_path.join(format!(".{SESSION_INFO_FILE}.{}.tmp", std::process::id())); + let text = serde_json::to_string_pretty(info)?; + fs::write(&temporary, format!("{text}\n")) + .with_context(|| format!("write session metadata {}", temporary.display()))?; + fs::rename(&temporary, &path) + .with_context(|| format!("persist session metadata {}", path.display()))?; + Ok(()) +} + +/// Validate a portable session name before using it as a path component. +pub fn validate_name(name: &str) -> Result<(), String> { + if name.is_empty() || name.len() > 64 { + return Err("session name must contain between 1 and 64 characters".to_string()); + } + let mut bytes = name.bytes(); + let Some(first) = bytes.next() else { + return Err("session name cannot be empty".to_string()); + }; + if !first.is_ascii_lowercase() && !first.is_ascii_digit() { + return Err("session name must start with a lowercase letter or digit".to_string()); + } + if !bytes.all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) { + return Err( + "session name may contain only lowercase letters, digits, `.`, `_`, and `-`" + .to_string(), + ); + } + if matches!(name, "." | "..") { + return Err("session name cannot be `.` or `..`".to_string()); + } + Ok(()) +} + +/// Validate a user-selectable session name. +/// +/// `default` remains a private bootstrap profile and must not be exposed as a +/// session users can create or switch to. +pub fn validate_selectable_name(name: &str) -> Result<(), String> { + validate_name(name)?; + if name == DEFAULT_SESSION_NAME { + return Err( + "session name `default` is reserved for bootstrap state; choose a user session name such as `alice`" + .to_string(), + ); + } + Ok(()) +} + +/// Select the Lite username prefix for auto-accounts owned by a session. +/// +/// Lite username bases accept lowercase ASCII letters only, while session +/// names additionally accept digits and separators. Preserve the recognizable +/// alphabetic portion of a named session and use a neutral fallback when its +/// name contains no letters. The default session retains the account manager's +/// historical default unless an explicit prefix was supplied. +pub fn lite_username_prefix(name: &str, explicit: Option<&str>) -> Option { + if let Some(explicit) = explicit { + return Some(explicit.to_string()); + } + if name == DEFAULT_SESSION_NAME { + return None; + } + let prefix: String = name + .bytes() + .filter(u8::is_ascii_lowercase) + .map(char::from) + .collect(); + Some(if prefix.is_empty() { + "session".to_string() + } else { + prefix + }) +} + +fn absolute_path(path: PathBuf) -> Result { + if path.is_absolute() { + return Ok(path); + } + Ok(std::env::current_dir() + .context("resolve current directory")? + .join(path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn rejects_names_that_could_escape_the_session_root() { + for invalid in ["", ".", "..", "Alice", "two words", "../escape", "a/b"] { + assert!(validate_name(invalid).is_err(), "accepted {invalid:?}"); + } + assert!(validate_name("alice-2.test").is_ok()); + } + + #[test] + fn default_is_internal_and_not_user_selectable() { + assert!(validate_name(DEFAULT_SESSION_NAME).is_ok()); + assert!(validate_selectable_name(DEFAULT_SESSION_NAME).is_err()); + assert!(validate_selectable_name("alice").is_ok()); + } + + #[test] + fn derives_lite_username_prefix_from_session_name() { + assert_eq!( + lite_username_prefix("pgtest", None).as_deref(), + Some("pgtest") + ); + assert_eq!( + lite_username_prefix("pg-test_2", None).as_deref(), + Some("pgtest") + ); + assert_eq!( + lite_username_prefix("123", None).as_deref(), + Some("session") + ); + assert_eq!(lite_username_prefix(DEFAULT_SESSION_NAME, None), None); + assert_eq!( + lite_username_prefix("pgtest", Some("custom")).as_deref(), + Some("custom") + ); + } + + #[test] + fn persists_and_lists_the_current_network_session() -> Result<()> { + let temporary = tempdir()?; + let catalog = SessionCatalog::new(temporary.path().to_path_buf(), "testnet")?; + catalog.set_current("alice")?; + + assert_eq!(catalog.current_name(), "alice"); + assert_eq!(catalog.list()?, vec!["alice"]); + let profile = catalog.profile("alice")?; + assert!(profile.path.ends_with("testnet/alice_signing_host")); + assert!( + profile + .product_storage_dir + .ends_with("testnet/alice_signing_host/storage") + ); + assert_eq!(profile.path, profile.account_base_path); + Ok(()) + } + + #[test] + fn promotes_a_provisional_profile_to_the_username_host_root() -> Result<()> { + let temporary = tempdir()?; + let catalog = SessionCatalog::new(temporary.path().to_path_buf(), "testnet")?; + let provisional = catalog.ensure_profile("pgtest")?; + fs::write(provisional.path.join("accounts.json"), "{}")?; + fs::create_dir_all(&provisional.product_storage_dir)?; + fs::write(provisional.product_storage_dir.join("product.json"), "{}")?; + + let promoted = catalog.promote_to_user(&provisional, "alice.dot")?; + + assert_eq!(promoted.name, "alice.dot"); + assert!(promoted.path.ends_with("testnet/alice.dot_signing_host")); + assert!(promoted.path.join("accounts.json").is_file()); + assert!(promoted.product_storage_dir.join("product.json").is_file()); + assert!(!provisional.path.exists()); + Ok(()) + } + + #[test] + fn default_profile_preserves_legacy_storage_locations() -> Result<()> { + let temporary = tempdir()?; + let catalog = SessionCatalog::new(temporary.path().to_path_buf(), "testnet")?; + let profile = catalog.profile(DEFAULT_SESSION_NAME)?; + + assert_eq!(profile.account_base_path, temporary.path()); + assert!(profile.path.ends_with("testnet/signing-host")); + assert!( + profile + .product_storage_dir + .ends_with("testnet/signing-host/storage/default") + ); + Ok(()) + } + + #[test] + fn session_metadata_preserves_user_id_and_last_script() -> Result<()> { + let temporary = tempdir()?; + let catalog = SessionCatalog::new(temporary.path().to_path_buf(), "testnet")?; + let profile = catalog.ensure_profile("alice")?; + let scripts = profile.path.join("scripts"); + fs::create_dir_all(&scripts)?; + let script = scripts.join("script.ts"); + fs::write(&script, "console.log('test');")?; + + assert_eq!(catalog.cached_user_id(&profile)?, None); + assert_eq!(catalog.last_script(&profile)?, None); + catalog.store_last_script(&profile, &script)?; + catalog.store_user_id(&profile, "alice.dot")?; + let replacement = scripts.join("replacement.ts"); + fs::write(&replacement, "console.log('replacement');")?; + catalog.store_last_script(&profile, &replacement)?; + + assert_eq!( + catalog.cached_user_id(&profile)?.as_deref(), + Some("alice.dot") + ); + assert_eq!( + catalog.last_script(&profile)?.as_deref(), + Some(replacement.as_path()) + ); + let metadata = fs::read_to_string(profile.path.join(SESSION_INFO_FILE))?; + assert!(metadata.contains("\"user_id\": \"alice.dot\"")); + assert!(metadata.contains("\"last_script\": \"replacement.ts\"")); + assert!(profile.path.join(SESSION_INFO_FILE).is_file()); + Ok(()) + } + + #[test] + fn stale_or_escaping_last_script_is_never_opened() -> Result<()> { + let temporary = tempdir()?; + let catalog = SessionCatalog::new(temporary.path().to_path_buf(), "testnet")?; + let profile = catalog.ensure_profile("alice")?; + let scripts = profile.path.join("scripts"); + fs::create_dir_all(&scripts)?; + let stale = scripts.join("missing.ts"); + catalog.store_last_script(&profile, &stale)?; + + assert_eq!(catalog.last_script(&profile)?, None); + fs::write( + profile.path.join(SESSION_INFO_FILE), + r#"{"version":1,"last_script":"../outside.ts"}"#, + )?; + assert!(catalog.last_script(&profile).is_err()); + Ok(()) + } + + #[test] + fn session_metadata_preserves_an_explicit_script_outside_scratch_storage() -> Result<()> { + let temporary = tempdir()?; + let catalog = SessionCatalog::new(temporary.path().to_path_buf(), "testnet")?; + let profile = catalog.ensure_profile("alice")?; + let script = temporary.path().join("product-script.ts"); + fs::write(&script, "console.log('product');")?; + + catalog.store_last_script(&profile, &script)?; + + assert_eq!( + catalog.last_script(&profile)?.as_deref(), + Some(script.as_path()) + ); + let metadata = fs::read_to_string(profile.path.join(SESSION_INFO_FILE))?; + assert!(metadata.contains(script.to_str().context("temporary path is not UTF-8")?)); + Ok(()) + } +} diff --git a/rust/crates/truapi-host-cli/src/signing_shell.rs b/rust/crates/truapi-host-cli/src/signing_shell.rs new file mode 100644 index 00000000..96d2229d --- /dev/null +++ b/rust/crates/truapi-host-cli/src/signing_shell.rs @@ -0,0 +1,693 @@ +//! Slash-command parsing and command-bar editing for the signing-host UI. + +use std::fs; +use std::path::{Path, PathBuf}; + +use truapi_platform::normalize_product_identifier; + +use crate::LogLevel; +use crate::sessions; + +/// Operation selected through `/product`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProductCommand { + /// Print the currently selected product. + Current, + /// Switch to the validated, normalized product id. + Switch(String), +} + +/// Operation selected through `/session`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionCommand { + /// Print the current session. + Current, + /// List sessions for the active network. + List, + /// Switch to or create the named session. + Switch(String), +} + +/// A command accepted by the signing-host command bar or `exec` mode. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShellCommand { + /// Answer a Polkadot Mobile pairing deeplink. + Pair(String), + /// Edit the remembered product script, or run an explicit one, through the + /// public frame endpoint. + Script(Option), + /// Show command and keyboard help. + Help, + /// Clear the visible transcript. + Clear, + /// Copy the retained transcript to the system clipboard. + Copy, + /// Start the pairing-host login flow for the selected product. + Login, + /// Disconnect a pairing host and discard its old pairing keypair. + Logout, + /// Replace the active tracing filter with a log level. + Log(LogLevel), + /// Inspect or switch the product used by scripts and frame connections. + Product(ProductCommand), + /// Inspect, list, or switch the active persistent session. + Session(SessionCommand), + /// Shut down the signing host. + Quit, +} + +/// Parse one slash command without invoking a shell. +pub fn parse_command(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + return Err("enter a command starting with `/`".to_string()); + } + if !input.starts_with('/') { + return Err("commands start with `/`; use /help to list them".to_string()); + } + + let (name, argument) = input + .split_once(char::is_whitespace) + .map_or((input, ""), |(name, argument)| (name, argument.trim())); + match name { + "/pair" => { + if argument.is_empty() { + return Err("usage: /pair ".to_string()); + } + if !argument.starts_with("polkadotapp://pair?") { + return Err("/pair expects a polkadotapp://pair?... URL".to_string()); + } + Ok(ShellCommand::Pair(argument.to_string())) + } + "/script" => { + if argument.is_empty() { + return Ok(ShellCommand::Script(None)); + } + Ok(ShellCommand::Script(Some(PathBuf::from(argument)))) + } + "/help" => no_argument(name, argument, ShellCommand::Help), + "/clear" => no_argument(name, argument, ShellCommand::Clear), + "/copy" => no_argument(name, argument, ShellCommand::Copy), + "/login" => no_argument(name, argument, ShellCommand::Login), + "/logout" => no_argument(name, argument, ShellCommand::Logout), + "/log" => { + if argument.is_empty() { + return Err("usage: /log ".to_string()); + } + Ok(ShellCommand::Log(argument.parse::()?)) + } + "/product" => { + if argument.is_empty() { + return Ok(ShellCommand::Product(ProductCommand::Current)); + } + let product_id = + normalize_product_identifier(argument).map_err(|error| error.to_string())?; + Ok(ShellCommand::Product(ProductCommand::Switch(product_id))) + } + "/session" => { + if argument.is_empty() { + return Ok(ShellCommand::Session(SessionCommand::Current)); + } + if argument == "--list" { + return Ok(ShellCommand::Session(SessionCommand::List)); + } + sessions::validate_selectable_name(argument)?; + Ok(ShellCommand::Session(SessionCommand::Switch( + argument.to_string(), + ))) + } + "/quit" => no_argument(name, argument, ShellCommand::Quit), + _ => Err(format!( + "unknown command `{name}`; use /help to list commands" + )), + } +} + +fn no_argument(name: &str, argument: &str, command: ShellCommand) -> Result { + if argument.is_empty() { + Ok(command) + } else { + Err(format!("{name} does not accept arguments")) + } +} + +/// One selectable completion shown above the command bar. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Completion { + /// Complete input inserted into the command bar. + pub value: String, + /// Short description rendered beside the completion. + pub description: &'static str, +} + +const SIGNING_COMMANDS: &[(&str, &str)] = &[ + ("/pair ", "answer a Polkadot Mobile pairing URL"), + ("/script", "edit the last or run an existing product script"), + ("/log ", "set error, warn, info, debug, or trace"), + ("/product", "show or switch the active product"), + ("/session", "show or switch the active session"), + ("/help", "show commands and keyboard shortcuts"), + ("/clear", "clear the visible transcript"), + ("/copy", "copy the transcript to the clipboard"), + ("/quit", "shut down the signing host"), +]; + +const PAIRING_COMMANDS: &[(&str, &str)] = &[ + ("/script", "edit the last or run an existing product script"), + ("/login", "pair with a signing host"), + ("/logout", "disconnect and reset pairing keys"), + ("/log ", "set error, warn, info, debug, or trace"), + ("/product", "show or switch the active product"), + ("/help", "show commands and keyboard shortcuts"), + ("/clear", "clear the visible transcript"), + ("/copy", "copy the transcript to the clipboard"), + ("/quit", "shut down the pairing host"), +]; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum CommandScope { + PairingHost, + #[default] + SigningHost, +} + +fn completions_for_scope( + input: &str, + session_names: &[String], + scope: CommandScope, +) -> Vec { + if let Some(path) = input.strip_prefix("/script ") { + return path_completions(path); + } + if scope == CommandScope::SigningHost + && let Some(prefix) = input.strip_prefix("/session ") + { + if prefix.contains(char::is_whitespace) { + return Vec::new(); + } + let mut matches = session_names + .iter() + .filter(|name| name.starts_with(prefix)) + .map(|name| Completion { + value: format!("/session {name}"), + description: "existing session", + }) + .collect::>(); + if "--list".starts_with(prefix) { + matches.insert( + 0, + Completion { + value: "/session --list".to_string(), + description: "list sessions", + }, + ); + } + return matches; + } + if !input.starts_with('/') || input.contains(char::is_whitespace) { + return Vec::new(); + } + let commands = match scope { + CommandScope::PairingHost => PAIRING_COMMANDS, + CommandScope::SigningHost => SIGNING_COMMANDS, + }; + commands + .iter() + .filter(|(command, _)| command.trim_end().starts_with(input)) + .map(|(command, description)| Completion { + value: (*command).to_string(), + description, + }) + .collect() +} + +fn path_completions(input: &str) -> Vec { + let path = Path::new(input); + let ends_with_separator = input.ends_with(std::path::MAIN_SEPARATOR); + let (directory, prefix) = if ends_with_separator { + (path, "") + } else { + ( + path.parent().unwrap_or_else(|| Path::new(".")), + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""), + ) + }; + let Ok(entries) = fs::read_dir(directory) else { + return Vec::new(); + }; + let displayed_parent = if ends_with_separator { + input.to_string() + } else { + input.strip_suffix(prefix).unwrap_or_default().to_string() + }; + let mut matches = entries + .filter_map(Result::ok) + .filter_map(|entry| { + let name = entry.file_name().into_string().ok()?; + if !name.starts_with(prefix) { + return None; + } + let suffix = if entry.file_type().ok()?.is_dir() { + "/" + } else { + "" + }; + Some(Completion { + value: format!("/script {displayed_parent}{name}{suffix}"), + description: "filesystem path", + }) + }) + .collect::>(); + matches.sort_by(|left, right| left.value.cmp(&right.value)); + matches.truncate(8); + matches +} + +/// Editable command input with completion selection and in-memory history. +#[derive(Debug)] +pub struct CommandEditor { + chars: Vec, + cursor: usize, + history: Vec, + history_index: Option, + history_draft: String, + completion_index: usize, + completions_dismissed: bool, + session_names: Vec, + scope: CommandScope, +} + +impl Default for CommandEditor { + fn default() -> Self { + Self { + chars: Vec::new(), + cursor: 0, + history: Vec::new(), + history_index: None, + history_draft: String::new(), + completion_index: 0, + completions_dismissed: false, + session_names: Vec::new(), + scope: CommandScope::SigningHost, + } + } +} + +impl CommandEditor { + /// Build an editor exposing only commands supported by the pairing host. + pub fn pairing_host() -> Self { + Self { + scope: CommandScope::PairingHost, + ..Self::default() + } + } + + /// Return the current command text. + pub fn text(&self) -> String { + self.chars.iter().collect() + } + + /// Return the character-indexed cursor position. + pub fn cursor(&self) -> usize { + self.cursor + } + + /// Replace the command text and place the cursor at its end. + pub fn set_text(&mut self, value: impl Into) { + self.chars = value.into().chars().collect(); + self.cursor = self.chars.len(); + self.edited(); + } + + /// Insert one character at the cursor. + pub fn insert(&mut self, value: char) { + self.chars.insert(self.cursor, value); + self.cursor += 1; + self.edited(); + } + + /// Remove the character before the cursor. + pub fn backspace(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + self.chars.remove(self.cursor); + self.edited(); + } + } + + /// Remove the character under the cursor. + pub fn delete(&mut self) { + if self.cursor < self.chars.len() { + self.chars.remove(self.cursor); + self.edited(); + } + } + + /// Move the cursor one character left. + pub fn left(&mut self) { + self.cursor = self.cursor.saturating_sub(1); + } + + /// Move the cursor one character right. + pub fn right(&mut self) { + self.cursor = (self.cursor + 1).min(self.chars.len()); + } + + /// Move the cursor to the start of the input. + pub fn home(&mut self) { + self.cursor = 0; + } + + /// Move the cursor to the end of the input. + pub fn end(&mut self) { + self.cursor = self.chars.len(); + } + + /// Clear the current command without adding it to history. + pub fn clear(&mut self) { + self.chars.clear(); + self.cursor = 0; + self.edited(); + } + + /// Replace session names offered after `/session `. + pub fn set_session_names(&mut self, names: Vec) { + self.session_names = names; + self.edited(); + } + + /// Return currently visible completions. + pub fn completions(&self) -> Vec { + if self.completions_dismissed { + Vec::new() + } else { + completions_for_scope(&self.text(), &self.session_names, self.scope) + } + } + + /// Return the selected completion index, clamped to visible results. + pub fn completion_index(&self) -> usize { + self.completion_index + .min(self.completions().len().saturating_sub(1)) + } + + /// Move to the previous completion, or older history when none is shown. + pub fn up(&mut self) { + let completions = self.completions(); + if !completions.is_empty() { + self.completion_index = self + .completion_index() + .checked_sub(1) + .unwrap_or(completions.len() - 1); + return; + } + self.older_history(); + } + + /// Move to the next completion, or newer history when none is shown. + pub fn down(&mut self) { + let completions = self.completions(); + if !completions.is_empty() { + self.completion_index = (self.completion_index() + 1) % completions.len(); + return; + } + self.newer_history(); + } + + /// Insert the selected completion, returning whether one existed. + pub fn accept_completion(&mut self) -> bool { + let completions = self.completions(); + let Some(completion) = completions.get(self.completion_index()) else { + return false; + }; + self.set_text(completion.value.clone()); + true + } + + /// Hide completions until the command text changes. + pub fn dismiss_completions(&mut self) { + self.completions_dismissed = true; + } + + /// Submit and remember the current input, clearing the editor. + pub fn submit(&mut self) -> String { + let value = self.text(); + if !value.trim().is_empty() && self.history.last() != Some(&value) { + self.history.push(value.clone()); + } + self.chars.clear(); + self.cursor = 0; + self.history_index = None; + self.history_draft.clear(); + self.edited(); + value + } + + fn edited(&mut self) { + self.completion_index = 0; + self.completions_dismissed = false; + self.history_index = None; + } + + fn older_history(&mut self) { + if self.history.is_empty() { + return; + } + let index = match self.history_index { + Some(index) => index.saturating_sub(1), + None => { + self.history_draft = self.text(); + self.history.len() - 1 + } + }; + self.history_index = Some(index); + self.chars = self.history[index].chars().collect(); + self.cursor = self.chars.len(); + } + + fn newer_history(&mut self) { + let Some(index) = self.history_index else { + return; + }; + if index + 1 < self.history.len() { + let next = index + 1; + self.history_index = Some(next); + self.chars = self.history[next].chars().collect(); + } else { + self.history_index = None; + self.chars = self.history_draft.chars().collect(); + } + self.cursor = self.chars.len(); + } +} + +/// Parse a confirmation answer, returning `None` for invalid input. +pub fn parse_approval(input: &str) -> Option { + match input.trim().to_ascii_lowercase().as_str() { + "y" | "yes" => Some(true), + "n" | "no" => Some(false), + _ => None, + } +} + +/// Text displayed by `/help` in either presentation mode. +pub const HELP_TEXT: &str = "\ +/pair answer a Polkadot Mobile pairing URL +/script edit and run the session's last Bun TypeScript script +/script run an existing JS/TS product script with Bun +/log set error, warn, info, debug, or trace +/product show the current product +/product switch product and reconnect product clients +/session show the current session and path +/session switch to or create a session +/session --list list sessions for this network +/help show this help +/clear clear the visible transcript +/copy copy the transcript to the clipboard +/quit shut down the signing host + +Keys: Up/Down completion or history, Tab complete, Ctrl-U/Ctrl-D scroll, +Esc close completion or reject approval, Ctrl-C clear/cancel/quit"; + +/// Help shown by the pairing-host command bar. +pub const PAIRING_HELP_TEXT: &str = "\ +/script edit and run the last Bun TypeScript product script +/script run an existing JS/TS product script with Bun +/login pair with a signing host for the current product +/logout disconnect and reset pairing keys +/log set error, warn, info, debug, or trace +/product show the current product +/product switch product and reconnect product clients +/help show this help +/clear clear the visible transcript +/copy copy the transcript to the clipboard +/quit shut down the pairing host + +Keys: Up/Down completion or history, Tab complete, Ctrl-U/Ctrl-D scroll, +Esc close completion, Ctrl-C clear/cancel/quit"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_all_operational_commands() { + assert_eq!( + parse_command("/pair polkadotapp://pair?handshake=01"), + Ok(ShellCommand::Pair( + "polkadotapp://pair?handshake=01".to_string() + )) + ); + assert_eq!( + parse_command("/script scripts/my smoke.ts"), + Ok(ShellCommand::Script(Some(PathBuf::from( + "scripts/my smoke.ts" + )))) + ); + assert_eq!(parse_command("/script"), Ok(ShellCommand::Script(None))); + assert_eq!(parse_command("/login"), Ok(ShellCommand::Login)); + assert_eq!(parse_command("/logout"), Ok(ShellCommand::Logout)); + assert_eq!( + parse_command("/log trace"), + Ok(ShellCommand::Log(LogLevel::Trace)) + ); + assert_eq!( + parse_command("/product"), + Ok(ShellCommand::Product(ProductCommand::Current)) + ); + assert_eq!( + parse_command("/product Dotli.DOT"), + Ok(ShellCommand::Product(ProductCommand::Switch( + "dotli.dot".to_string() + ))) + ); + assert_eq!(parse_command("/copy"), Ok(ShellCommand::Copy)); + assert_eq!( + parse_command("/session"), + Ok(ShellCommand::Session(SessionCommand::Current)) + ); + assert_eq!( + parse_command("/session alice"), + Ok(ShellCommand::Session(SessionCommand::Switch( + "alice".to_string() + ))) + ); + } + + #[test] + fn rejects_bare_and_malformed_commands() { + assert!( + parse_command("whoami") + .unwrap_err() + .contains("start with `/`") + ); + assert!(parse_command("/whoami").is_err()); + assert!(parse_command("/copy now").is_err()); + assert!(parse_command("/login now").is_err()); + assert!(parse_command("/logout now").is_err()); + assert!(parse_command("/pair https://example.com").is_err()); + assert!(parse_command("/deeplink polkadotapp://pair?handshake=01").is_err()); + assert!(parse_command("/log noisy").is_err()); + assert!(parse_command("/product example.com").is_err()); + assert!(parse_command("/session ../escape").is_err()); + assert!( + parse_command("/session default") + .unwrap_err() + .contains("reserved for bootstrap state") + ); + } + + #[test] + fn completion_selection_and_history_have_distinct_arrow_behavior() { + let mut editor = CommandEditor::default(); + editor.set_text("/"); + let first = editor.completions()[0].value.clone(); + editor.down(); + assert_ne!(editor.completions()[editor.completion_index()].value, first); + + editor.dismiss_completions(); + editor.set_text("/help"); + editor.submit(); + editor.set_text("draft"); + editor.dismiss_completions(); + editor.up(); + assert_eq!(editor.text(), "/help"); + editor.down(); + assert_eq!(editor.text(), "draft"); + } + + #[test] + fn editor_handles_unicode_at_character_boundaries() { + let mut editor = CommandEditor::default(); + editor.set_text("/script café.ts"); + editor.left(); + editor.left(); + editor.left(); + editor.backspace(); + assert_eq!(editor.text(), "/script caf.ts"); + } + + #[test] + fn approval_parser_is_trimmed_and_case_insensitive() { + assert_eq!(parse_approval(" YES "), Some(true)); + assert_eq!(parse_approval("n"), Some(false)); + assert_eq!(parse_approval("sure"), None); + } + + #[test] + fn script_completion_lists_matching_filesystem_paths() { + let command = completions_for_scope("/script", &[], CommandScope::SigningHost); + assert_eq!(command.len(), 1); + assert_eq!(command[0].value, "/script"); + + let prefix = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("signing_s"); + let matches = completions_for_scope( + &format!("/script {}", prefix.display()), + &[], + CommandScope::SigningHost, + ); + + assert!( + matches + .iter() + .any(|completion| completion.value.ends_with("signing_shell.rs")) + ); + } + + #[test] + fn session_completion_lists_existing_names() { + let matches = completions_for_scope( + "/session a", + &["alice".to_string(), "bob".to_string()], + CommandScope::SigningHost, + ); + + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].value, "/session alice"); + } + + #[test] + fn pairing_host_completions_only_offer_shared_commands() { + let matches = completions_for_scope("/", &[], CommandScope::PairingHost); + let commands = matches + .into_iter() + .map(|completion| completion.value) + .collect::>(); + + assert!(commands.contains(&"/script".to_string())); + assert!(commands.contains(&"/login".to_string())); + assert!(commands.contains(&"/logout".to_string())); + assert!(commands.contains(&"/product".to_string())); + assert!(commands.contains(&"/copy".to_string())); + assert!(!commands.iter().any(|command| command.starts_with("/pair"))); + assert!( + !commands + .iter() + .any(|command| command.starts_with("/session")) + ); + } +} diff --git a/rust/crates/truapi-host-cli/src/terminal_ui.rs b/rust/crates/truapi-host-cli/src/terminal_ui.rs new file mode 100644 index 00000000..ae0f777c --- /dev/null +++ b/rust/crates/truapi-host-cli/src/terminal_ui.rs @@ -0,0 +1,3227 @@ +//! Terminal ownership, rendering, and host-to-transcript event routing. + +use std::collections::VecDeque; +use std::future::Future; +use std::io::{self, IsTerminal, Write}; +use std::ops::Range; +use std::sync::{Mutex, OnceLock}; + +use anyhow::{Context, Result}; +use crossterm::cursor::{Hide, Show}; +use crossterm::event::{ + DisableBracketedPaste, EnableBracketedPaste, Event, EventStream, KeyCode, KeyEvent, + KeyEventKind, KeyModifiers, +}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use futures_util::StreamExt; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, HighlightSpacing, List, ListItem, ListState, Paragraph, Wrap}; +use tokio::sync::{mpsc, oneshot}; +use tracing::Subscriber; +use tracing::field::{Field, Visit}; +use tracing_subscriber::layer::{Context as LayerContext, Layer}; +use unicode_width::UnicodeWidthChar; + +use crate::LogLevel; +use crate::signing_shell::{CommandEditor, parse_approval}; + +const TRANSCRIPT_LIMIT: usize = 10_000; +const TRANSCRIPT_LINE_LIMIT: usize = 10_000; +const TRANSCRIPT_BYTE_LIMIT: usize = 1024 * 1024; +const STREAM_CHUNK_LINE_LIMIT: usize = 256; +const STREAM_CHUNK_BYTE_LIMIT: usize = 64 * 1024; +const STREAM_LINE_BYTE_LIMIT: usize = 16 * 1024; +const EVENT_BATCH_LIMIT: usize = 256; +const MAX_VISIBLE_COMPLETIONS: usize = 8; +const COMPOSER_HORIZONTAL_PADDING: u16 = 1; + +/// Tracing target reserved for SSO summaries that must remain visible at every log level. +pub const SSO_TRANSCRIPT_TARGET: &str = "truapi_server::sso_transcript"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NoticeTone { + Info, + Success, + Warning, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StreamKind { + Stdout, + Stderr, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActivityState { + Running, + Succeeded, + Warning, + Failed, + Cancelled, +} + +#[derive(Debug)] +enum FeedItem { + Log(String), + Notice { + tone: NoticeTone, + title: String, + detail: Option, + }, + Command(String), + Stream { + kind: StreamKind, + lines: Vec, + }, + Activity { + id: u64, + key: String, + label: String, + detail: Option, + state: ActivityState, + }, + Approval { + id: u64, + action: String, + detail: String, + outcome: Option, + }, + Request { + key: String, + name: String, + state: ActivityState, + metadata: Option, + elapsed_ms: Option, + reason: Option, + }, +} + +/// Stable lifecycle events with separate machine and interactive presentations. +#[derive(Debug, Clone)] +pub enum SystemEvent { + FramesListening { + url: String, + }, + SigningHostReady, + SigningHostNeedsSession, + SigningHostAccountExhausted { + name: String, + period: u32, + }, + SigningHostExit { + outcome: String, + }, + SigningHostError { + reason: String, + }, + SigningHostResponderStarted, + RingInfo { + ring_index: u32, + members: usize, + }, + AllowanceChecking { + target: String, + }, + AllowanceReady { + target: String, + sequence: u32, + block_hash: Option, + already_allocated: bool, + }, + NotificationDelivered { + id: u32, + text: String, + deeplink: Option, + }, + NotificationScheduled { + id: u32, + text: String, + scheduled_at: u64, + }, + NotificationCancelled { + id: u32, + }, + PairingDeeplink { + url: String, + }, + PairingAuthenticating, + PairingConnected { + user_id: Option, + }, + PairingDisconnected, + PairingFailed { + reason: String, + }, + ScriptStarted, + ScriptExit { + code: i32, + }, + SessionStatus { + name: String, + path: String, + user_id: String, + }, + SessionSwitching { + from: String, + to: String, + }, + SessionCreating { + name: String, + }, + LogLevelChanged { + level: LogLevel, + }, + CopiedTranscript { + entries: usize, + }, +} + +impl SystemEvent { + /// Render the same sentence-case copy used by the interactive transcript. + pub fn human(&self) -> String { + let mut app = App::new_pairing(String::new(), String::new(), LogLevel::Info); + app.connection = "connected".to_string(); + app.handle_system_event(self.clone()); + let text = app.transcript_text(); + match self { + Self::PairingDeeplink { url } => text.replace("", url), + _ => text, + } + } +} + +#[derive(Debug, Clone, Default)] +struct SsoEvent { + kind: Option, + request: Option, + statement_request_id: Option, + remote_message_id: Option, + response_message_id: Option, + outcome: Option, + elapsed_ms: Option, + reason: Option, + fallback_summary: Option, +} + +enum UiEvent { + Log(String), + Notice { + tone: NoticeTone, + title: String, + detail: Option, + }, + Stream { + kind: StreamKind, + text: String, + }, + System(SystemEvent), + Activity { + key: String, + label: String, + detail: Option, + state: ActivityState, + }, + Sso(SsoEvent), + Approval { + action: String, + detail: String, + response: oneshot::Sender, + }, + Connection(String), + Session { + name: String, + available: Vec, + }, +} + +static ACTIVE_UI: OnceLock>>> = OnceLock::new(); + +fn active_ui() -> &'static Mutex>> { + ACTIVE_UI.get_or_init(|| Mutex::new(None)) +} + +fn send_to_active(event: UiEvent) -> bool { + let sender = active_ui().lock().ok().and_then(|active| active.clone()); + sender.is_some_and(|sender| sender.send(event).is_ok()) +} + +/// Return whether stdin and stdout are both attached to terminals. +pub fn is_interactive_terminal() -> bool { + io::stdin().is_terminal() && io::stdout().is_terminal() +} + +/// Emit a lifecycle event using the same human copy in every presentation. +pub fn output_event(event: SystemEvent) { + if !send_to_active(UiEvent::System(event.clone())) { + let text = event.human(); + if !text.is_empty() { + write_human_stdout(&text); + } + } +} + +/// Emit a success notice through the active transcript or streaming renderer. +pub fn output_success(title: impl Into, detail: Option) { + let title = title.into(); + if !send_to_active(UiEvent::Notice { + tone: NoticeTone::Success, + title: title.clone(), + detail: detail.clone(), + }) { + let mut app = App::new_pairing(String::new(), String::new(), LogLevel::Info); + app.notice(NoticeTone::Success, title, detail); + write_human_stdout(&app.transcript_text()); + } +} + +fn write_human_stdout(text: &str) { + let styled = io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(); + let mut stdout = io::stdout().lock(); + for line in text.lines() { + if !styled { + let _ = writeln!(stdout, "{line}"); + continue; + } + let color = match line.chars().next() { + Some('✓') => "\x1b[32m", + Some('×') => "\x1b[31m", + Some('!') => "\x1b[33m", + Some('•' | '◌') => "\x1b[36m", + _ if line.starts_with(" ") => "\x1b[2m", + _ => "", + }; + if color.is_empty() { + let _ = writeln!(stdout, "{line}"); + } else { + let _ = writeln!(stdout, "{color}{line}\x1b[0m"); + } + } +} + +/// Update a keyed activity in the active TUI without affecting plain output. +pub fn update_activity( + key: impl Into, + label: impl Into, + detail: Option, + state: ActivityState, +) { + let _ = send_to_active(UiEvent::Activity { + key: key.into(), + label: label.into(), + detail, + state, + }); +} + +/// A tracing writer that redirects complete events into the active transcript. +#[derive(Default)] +pub struct LogWriter { + buffer: Vec, +} + +impl Write for LogWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.buffer.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl Drop for LogWriter { + fn drop(&mut self) { + let text = String::from_utf8_lossy(&self.buffer); + for line in text.lines().filter(|line| !line.is_empty()) { + if !send_to_active(UiEvent::Log(sanitize_terminal_text(line))) { + let mut stderr = io::stderr(); + if stderr.is_terminal() && std::env::var_os("NO_COLOR").is_none() { + let _ = writeln!(stderr, "\x1b[2m{line}\x1b[0m"); + } else { + let _ = writeln!(stderr, "{line}"); + } + } + } + } +} + +/// Unfiltered tracing layer for stable incoming and outgoing SSO summaries. +pub struct SsoTranscriptLayer; + +impl Layer for SsoTranscriptLayer +where + S: Subscriber, +{ + fn on_event(&self, event: &tracing::Event<'_>, _context: LayerContext<'_, S>) { + if event.metadata().target() != SSO_TRANSCRIPT_TARGET { + return; + } + let mut visitor = SsoSummaryVisitor::default(); + event.record(&mut visitor); + if visitor.event.fallback_summary.is_none() { + return; + } + if !send_to_active(UiEvent::Sso(visitor.event.clone())) { + write_human_stderr(&sso_event_text(visitor.event)); + } + } +} + +fn sso_event_text(event: SsoEvent) -> String { + let mut app = App::new_pairing(String::new(), String::new(), LogLevel::Info); + app.handle_sso_event(event); + app.transcript_text() +} + +fn write_human_stderr(text: &str) { + let styled = io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none(); + let mut stderr = io::stderr().lock(); + for line in text.lines() { + if !styled { + let _ = writeln!(stderr, "{line}"); + continue; + } + let color = match line.chars().next() { + Some('✓') => "\x1b[32m", + Some('×') => "\x1b[31m", + Some('!') => "\x1b[33m", + Some('•' | '◌') => "\x1b[36m", + _ if line.starts_with(" ") => "\x1b[2m", + _ => "", + }; + if color.is_empty() { + let _ = writeln!(stderr, "{line}"); + } else { + let _ = writeln!(stderr, "{color}{line}\x1b[0m"); + } + } +} + +#[derive(Default)] +struct SsoSummaryVisitor { + event: SsoEvent, +} + +impl Visit for SsoSummaryVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + let value = format!("{value:?}").trim_matches('"').to_string(); + self.record_value(field.name(), value); + } + + fn record_str(&mut self, field: &Field, value: &str) { + self.record_value(field.name(), value.to_string()); + } + + fn record_u64(&mut self, field: &Field, value: u64) { + if field.name() == "elapsed_ms" { + self.event.elapsed_ms = Some(value); + } + } +} + +impl SsoSummaryVisitor { + fn record_value(&mut self, name: &str, value: String) { + match name { + "cli_summary" => self.event.fallback_summary = Some(value), + "cli_event" => self.event.kind = Some(value), + "request" | "remote_message" => self.event.request = Some(value), + "statement_request_id" => self.event.statement_request_id = Some(value), + "remote_message_id" | "responding_to" => { + self.event.remote_message_id = Some(value); + } + "response_message_id" => self.event.response_message_id = Some(value), + "outcome" => self.event.outcome = Some(value), + "reason" if !value.is_empty() => self.event.reason = Some(value), + _ => {} + } + } +} + +/// Cloneable bridge used by the host platform and script runner. +#[derive(Clone)] +pub struct UiHandle { + sender: mpsc::UnboundedSender, +} + +impl UiHandle { + /// Add a successful human-facing outcome to the transcript. + pub fn success(&self, title: impl Into, detail: Option) { + let _ = self.sender.send(UiEvent::Notice { + tone: NoticeTone::Success, + title: title.into(), + detail, + }); + } + + /// Add a typed lifecycle event to the transcript. + pub fn event(&self, event: SystemEvent) { + let _ = self.sender.send(UiEvent::System(event)); + } + + /// Add child-script stdout to the active command block. + pub fn script_stdout(&self, text: impl Into) { + let _ = self.sender.send(UiEvent::Stream { + kind: StreamKind::Stdout, + text: sanitize_terminal_text(&text.into()), + }); + } + + /// Add child-script stderr to the active command block. + pub fn script_stderr(&self, text: impl Into) { + let _ = self.sender.send(UiEvent::Stream { + kind: StreamKind::Stderr, + text: sanitize_terminal_text(&text.into()), + }); + } + + /// Update the user or transitional auth label shown in the status bar. + pub fn connection(&self, state: impl Into) { + let _ = self.sender.send(UiEvent::Connection(state.into())); + } + + /// Update the active session and session-name completions. + pub fn session(&self, name: impl Into, available: Vec) { + let _ = self.sender.send(UiEvent::Session { + name: name.into(), + available, + }); + } + + /// Ask the terminal owner for a serialized yes/no decision. + pub async fn confirm(&self, action: impl Into, detail: impl Into) -> bool { + let (response, answer) = oneshot::channel(); + if self + .sender + .send(UiEvent::Approval { + action: action.into(), + detail: detail.into(), + response, + }) + .is_err() + { + return false; + } + answer.await.unwrap_or(false) + } +} + +/// Inactive terminal UI whose handle can be installed in the host platform. +pub struct TerminalUi { + sender: mpsc::UnboundedSender, + receiver: mpsc::UnboundedReceiver, + app: App, +} + +impl TerminalUi { + /// Create a terminal transcript and its cloneable host bridge. + pub fn new( + network: impl Into, + product_id: impl Into, + session: impl Into, + session_names: Vec, + log_level: LogLevel, + ) -> (Self, UiHandle) { + let (sender, receiver) = mpsc::unbounded_channel(); + let handle = UiHandle { + sender: sender.clone(), + }; + ( + Self { + sender, + receiver, + app: App::new( + network.into(), + product_id.into(), + session.into(), + session_names, + log_level, + ), + }, + handle, + ) + } + + /// Create the same terminal surface for a product-side pairing host. + pub fn new_pairing( + network: impl Into, + product_id: impl Into, + log_level: LogLevel, + ) -> (Self, UiHandle) { + let (sender, receiver) = mpsc::unbounded_channel(); + let handle = UiHandle { + sender: sender.clone(), + }; + ( + Self { + sender, + receiver, + app: App::new_pairing(network.into(), product_id.into(), log_level), + }, + handle, + ) + } + + /// Take exclusive terminal ownership and enter the full-screen UI. + pub fn enter(self) -> Result { + let mut active = active_ui() + .lock() + .map_err(|_| anyhow::anyhow!("active terminal UI mutex poisoned"))?; + let terminal = enter_terminal()?; + *active = Some(self.sender.clone()); + drop(active); + Ok(ActiveTerminalUi { + terminal: Some(terminal), + events: Some(EventStream::new()), + receiver: self.receiver, + sender: self.sender, + app: self.app, + clipboard: None, + copy_next_pairing_deeplink: false, + }) + } +} + +type Renderer = Terminal>; + +/// Result of driving the UI while an operational command runs. +pub enum DriveResult { + /// The command future completed normally. + Complete(T), + /// The user cancelled the command with Ctrl-C. + Cancelled, +} + +/// Full-screen terminal owner used by the signing-host session loop. +pub struct ActiveTerminalUi { + terminal: Option, + events: Option, + receiver: mpsc::UnboundedReceiver, + sender: mpsc::UnboundedSender, + app: App, + clipboard: Option, + copy_next_pairing_deeplink: bool, +} + +impl ActiveTerminalUi { + /// Return a handle suitable for background host work. + pub fn handle(&self) -> UiHandle { + UiHandle { + sender: self.sender.clone(), + } + } + + /// Record a submitted slash command in the transcript. + pub fn command(&mut self, command: impl Into) { + self.app.push_command(command.into()); + } + + /// Record an immediate system result. + pub fn system(&mut self, text: impl Into) { + self.app.notice(NoticeTone::Info, text.into(), None); + } + + /// Record an immediate successful result. + pub fn success(&mut self, text: impl Into, detail: Option) { + self.app.notice(NoticeTone::Success, text.into(), detail); + } + + /// Record a typed lifecycle event. + pub fn event(&mut self, event: SystemEvent) { + self.app.handle_system_event(event); + } + + /// Record an immediate command error. + pub fn error(&mut self, text: impl Into) { + self.app.notice(NoticeTone::Error, text.into(), None); + } + + /// Clear the visible transcript. + pub fn clear(&mut self) { + self.app.entries.clear(); + self.app.retained_lines = 0; + self.app.retained_bytes = 0; + self.app.scroll_from_bottom = 0; + } + + /// Copy the retained transcript to the system clipboard. + pub fn copy_transcript(&mut self) -> Result { + let text = self.app.transcript_text(); + let entries = self.app.entries.len(); + self.copy_text(text, "copy transcript to system clipboard")?; + Ok(entries) + } + + fn copy_text(&mut self, text: String, context: &'static str) -> Result<()> { + if self.clipboard.is_none() { + self.clipboard = Some(arboard::Clipboard::new().context("open system clipboard")?); + } + self.clipboard + .as_mut() + .expect("clipboard was initialized above") + .set_text(text) + .context(context) + } + + /// Update the displayed log level after `/log` succeeds. + pub fn set_log_level(&mut self, level: LogLevel) { + self.app.log_level = level; + } + + /// Update the product shown in the host status bar. + pub fn set_product(&mut self, product_id: impl Into) { + self.app.product = product_id.into(); + } + + /// Return an activity id boundary for one operational command. + pub fn activity_checkpoint(&self) -> u64 { + self.app.next_item_id + } + + /// Finalize activities started by the current command after an error or cancellation. + pub fn finish_activities_since(&mut self, checkpoint: u64, state: ActivityState, detail: &str) { + self.app + .finish_running_activities_since(checkpoint, state, detail); + } + + /// Yield terminal ownership to an external interactive program. + pub fn suspend(&mut self) -> Result<()> { + self.events = None; + let terminal = self + .terminal + .take() + .context("terminal renderer is unavailable")?; + if let Err(error) = leave_terminal(terminal) { + if let Ok(terminal) = enter_terminal() { + self.terminal = Some(terminal); + self.events = Some(EventStream::new()); + } + return Err(error); + } + Ok(()) + } + + /// Re-enter the full-screen UI after an external program exits. + pub fn resume(&mut self) -> Result<()> { + if self.terminal.is_some() { + return Ok(()); + } + self.terminal = Some(enter_terminal()?); + self.events = Some(EventStream::new()); + Ok(()) + } + + /// Wait for the next submitted command while continuing to render host events. + pub async fn next_command(&mut self) -> Result> { + loop { + self.draw()?; + tokio::select! { + event = self.receiver.recv() => { + let Some(event) = event else { return Ok(None); }; + self.handle_ui_event(event); + self.drain_pending_events(); + } + event = self.events.as_mut().expect("terminal events are active").next() => { + let Some(event) = event else { return Ok(None); }; + let event = event.context("read terminal event")?; + if let Some(outcome) = self.app.handle_idle_event(event) { + return Ok(outcome); + } + } + } + } + } + + /// Keep rendering and answering approvals while `future` performs one command. + pub async fn drive( + &mut self, + label: impl Into, + future: F, + ) -> Result> + where + F: Future, + { + self.app.busy = Some(redact_command(&label.into())); + let mut future = Box::pin(future); + loop { + self.draw()?; + tokio::select! { + output = &mut future => { + self.app.busy = None; + return Ok(DriveResult::Complete(output)); + } + event = self.receiver.recv() => { + if let Some(event) = event { + self.handle_ui_event(event); + self.drain_pending_events(); + } + } + event = self.events.as_mut().expect("terminal events are active").next() => { + let Some(event) = event else { + self.app.busy = None; + return Ok(DriveResult::Cancelled); + }; + let event = event.context("read terminal event")?; + if self.app.handle_busy_event(event) { + self.app.busy = None; + return Ok(DriveResult::Cancelled); + } + } + } + } + } + + /// Drive `/login` while copying the first typed pairing deeplink event. + pub async fn drive_pairing_login( + &mut self, + label: impl Into, + future: F, + ) -> Result> + where + F: Future, + { + self.copy_next_pairing_deeplink = true; + let result = self.drive(label, future).await; + self.copy_next_pairing_deeplink = false; + result + } + + fn draw(&mut self) -> Result<()> { + let app = &mut self.app; + self.terminal + .as_mut() + .context("terminal renderer is unavailable")? + .draw(|frame| render(frame, app)) + .context("draw terminal UI")?; + Ok(()) + } + + fn drain_pending_events(&mut self) { + for _ in 1..EVENT_BATCH_LIMIT { + let Ok(event) = self.receiver.try_recv() else { + break; + }; + self.handle_ui_event(event); + } + } + + fn handle_ui_event(&mut self, event: UiEvent) { + let deeplink = pairing_deeplink_to_copy(self.copy_next_pairing_deeplink, &event) + .map(ToOwned::to_owned); + self.app.handle_event(event); + let Some(deeplink) = deeplink else { + return; + }; + self.copy_next_pairing_deeplink = false; + match self.copy_text(deeplink, "copy pairing link to system clipboard") { + Ok(()) => self.app.notice( + NoticeTone::Success, + "Pairing link copied".to_string(), + Some("Clipboard updated".to_string()), + ), + Err(error) => self.app.notice( + NoticeTone::Warning, + "Could not copy pairing link".to_string(), + Some(error.to_string()), + ), + } + } +} + +fn pairing_deeplink_to_copy(enabled: bool, event: &UiEvent) -> Option<&str> { + if !enabled { + return None; + } + match event { + UiEvent::System(SystemEvent::PairingDeeplink { url }) => Some(url), + _ => None, + } +} + +impl Drop for ActiveTerminalUi { + fn drop(&mut self) { + if let Ok(mut active) = active_ui().lock() { + *active = None; + } + if let Some(terminal) = self.terminal.take() { + let _ = leave_terminal(terminal); + } else { + let _ = disable_raw_mode(); + } + } +} + +fn enter_terminal() -> Result { + enable_raw_mode().context("enable terminal raw mode")?; + let mut stdout = io::stdout(); + if let Err(error) = execute!(stdout, EnterAlternateScreen, EnableBracketedPaste, Hide) { + let _ = disable_raw_mode(); + return Err(error).context("enter alternate terminal screen"); + } + match Terminal::new(CrosstermBackend::new(stdout)) { + Ok(terminal) => Ok(terminal), + Err(error) => { + let mut stdout = io::stdout(); + let _ = execute!(stdout, Show, DisableBracketedPaste, LeaveAlternateScreen); + let _ = disable_raw_mode(); + Err(error).context("initialize terminal renderer") + } + } +} + +fn leave_terminal(mut terminal: Renderer) -> Result<()> { + let screen_result = execute!( + terminal.backend_mut(), + Show, + DisableBracketedPaste, + LeaveAlternateScreen + ) + .context("leave alternate terminal screen"); + let raw_result = disable_raw_mode().context("disable terminal raw mode"); + screen_result?; + raw_result +} + +struct PendingApproval { + id: u64, + response: oneshot::Sender, + saved_input: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HostRole { + Pairing, + Signing, +} + +struct App { + role: HostRole, + network: String, + product: String, + session: String, + connection: String, + log_level: LogLevel, + entries: VecDeque, + editor: CommandEditor, + pending_approval: Option, + busy: Option, + scroll_from_bottom: usize, + transcript_height: usize, + next_item_id: u64, + retained_lines: usize, + retained_bytes: usize, +} + +impl App { + fn new( + network: String, + product_id: String, + session: String, + session_names: Vec, + log_level: LogLevel, + ) -> Self { + Self::with_role( + HostRole::Signing, + network, + product_id, + session, + session_names, + log_level, + ) + } + + fn new_pairing(network: String, product_id: String, log_level: LogLevel) -> Self { + Self::with_role( + HostRole::Pairing, + network, + product_id, + String::new(), + Vec::new(), + log_level, + ) + } + + fn with_role( + role: HostRole, + network: String, + product: String, + session: String, + session_names: Vec, + log_level: LogLevel, + ) -> Self { + let mut editor = match role { + HostRole::Pairing => CommandEditor::pairing_host(), + HostRole::Signing => CommandEditor::default(), + }; + editor.set_session_names(session_names); + Self { + role, + network, + product, + session, + connection: "disconnected".to_string(), + log_level, + entries: VecDeque::new(), + editor, + pending_approval: None, + busy: None, + scroll_from_bottom: 0, + transcript_height: 1, + next_item_id: 1, + retained_lines: 0, + retained_bytes: 0, + } + } + + fn push(&mut self, item: FeedItem) { + let (lines, bytes) = feed_item_cost(&item); + self.retained_lines = self.retained_lines.saturating_add(lines); + self.retained_bytes = self.retained_bytes.saturating_add(bytes); + self.entries.push_back(item); + self.prune_transcript(); + } + + fn prune_transcript(&mut self) { + while self.entries.len() > TRANSCRIPT_LIMIT + || self.retained_lines > TRANSCRIPT_LINE_LIMIT + || self.retained_bytes > TRANSCRIPT_BYTE_LIMIT + { + let Some(item) = self.entries.pop_front() else { + break; + }; + let (lines, bytes) = feed_item_cost(&item); + self.retained_lines = self.retained_lines.saturating_sub(lines); + self.retained_bytes = self.retained_bytes.saturating_sub(bytes); + } + } + + fn recalculate_retained(&mut self) { + let (lines, bytes) = self.entries.iter().map(feed_item_cost).fold( + (0_usize, 0_usize), + |(lines, bytes), (item_lines, item_bytes)| { + ( + lines.saturating_add(item_lines), + bytes.saturating_add(item_bytes), + ) + }, + ); + self.retained_lines = lines; + self.retained_bytes = bytes; + self.prune_transcript(); + } + + fn push_command(&mut self, command: String) { + self.push(FeedItem::Command(redact_command(&command))); + } + + fn notice(&mut self, tone: NoticeTone, title: String, detail: Option) { + self.push(FeedItem::Notice { + tone, + title: sanitize_terminal_text(&title), + detail: detail.map(|value| sanitize_terminal_text(&value)), + }); + } + + fn stream(&mut self, kind: StreamKind, text: String) { + let text = truncate_utf8(&text, STREAM_LINE_BYTE_LIMIT); + let text_bytes = text.len(); + let can_append = self.entries.back().is_some_and(|item| { + matches!( + item, + FeedItem::Stream { + kind: previous_kind, + lines, + } if *previous_kind == kind + && lines.len() < STREAM_CHUNK_LINE_LIMIT + && lines.iter().map(String::len).sum::() + text_bytes + <= STREAM_CHUNK_BYTE_LIMIT + ) + }); + if can_append { + let Some(FeedItem::Stream { lines, .. }) = self.entries.back_mut() else { + unreachable!("stream append was checked above"); + }; + lines.push(text); + self.retained_lines = self.retained_lines.saturating_add(1); + self.retained_bytes = self.retained_bytes.saturating_add(text_bytes); + self.prune_transcript(); + return; + } + self.push(FeedItem::Stream { + kind, + lines: vec![text], + }); + } + + fn activity( + &mut self, + key: String, + label: String, + detail: Option, + state: ActivityState, + ) { + let label = sanitize_terminal_text(&label); + let detail = detail.map(|value| sanitize_terminal_text(&value)); + let updated_cost = self + .entries + .iter_mut() + .rev() + .find(|entry| { + matches!( + entry, + FeedItem::Activity { + key: current, + state: ActivityState::Running, + .. + } if current == &key + ) + }) + .map(|entry| { + let before = feed_item_cost(entry); + let FeedItem::Activity { + label: current_label, + detail: current_detail, + state: current_state, + .. + } = entry + else { + unreachable!("activity lookup matched a different item"); + }; + *current_label = label.clone(); + *current_detail = detail.clone(); + *current_state = state; + (before, feed_item_cost(entry)) + }); + if let Some(((before_lines, before_bytes), (after_lines, after_bytes))) = updated_cost { + self.retained_lines = self + .retained_lines + .saturating_sub(before_lines) + .saturating_add(after_lines); + self.retained_bytes = self + .retained_bytes + .saturating_sub(before_bytes) + .saturating_add(after_bytes); + self.prune_transcript(); + return; + } + let id = self.allocate_item_id(); + self.push(FeedItem::Activity { + id, + key, + label, + detail, + state, + }); + } + + fn start_activity(&mut self, key: String, label: String, detail: Option) { + self.finish_activity( + &key, + ActivityState::Cancelled, + Some("Superseded".to_string()), + ); + let id = self.allocate_item_id(); + self.push(FeedItem::Activity { + id, + key, + label: sanitize_terminal_text(&label), + detail: detail.map(|value| sanitize_terminal_text(&value)), + state: ActivityState::Running, + }); + } + + fn finish_activity(&mut self, key: &str, state: ActivityState, detail: Option) { + let Some(FeedItem::Activity { + label, + detail: current_detail, + .. + }) = self.entries.iter().rev().find( + |entry| matches!(entry, FeedItem::Activity { key: current, state: ActivityState::Running, .. } if current == key), + ) + else { + return; + }; + self.activity( + key.to_string(), + label.clone(), + detail.or_else(|| current_detail.clone()), + state, + ); + } + + fn finish_running_activities_since( + &mut self, + first_id: u64, + state: ActivityState, + detail: &str, + ) { + let keys = self + .entries + .iter() + .filter_map(|entry| match entry { + FeedItem::Activity { + id, + key, + state: ActivityState::Running, + .. + } if *id >= first_id => Some(key.clone()), + _ => None, + }) + .collect::>(); + for key in keys { + self.finish_activity(&key, state, Some(detail.to_string())); + } + } + + fn allocate_item_id(&mut self) -> u64 { + let id = self.next_item_id; + self.next_item_id += 1; + id + } + + fn transcript_text(&self) -> String { + self.entries + .iter() + .flat_map(feed_item_plain_lines) + .collect::>() + .join("\n") + } + + fn handle_event(&mut self, event: UiEvent) { + match event { + UiEvent::Log(text) => self.push(FeedItem::Log(text)), + UiEvent::Notice { + tone, + title, + detail, + } => self.notice(tone, title, detail), + UiEvent::Stream { kind, text } => self.stream(kind, text), + UiEvent::System(event) => self.handle_system_event(event), + UiEvent::Activity { + key, + label, + detail, + state, + } => self.activity(key, label, detail, state), + UiEvent::Sso(event) => self.handle_sso_event(event), + UiEvent::Connection(state) => self.connection = state, + UiEvent::Session { name, available } => { + self.session = name; + self.editor.set_session_names(available); + } + UiEvent::Approval { + action, + detail, + response, + } => { + if self.pending_approval.is_some() { + let _ = response.send(false); + self.notice( + NoticeTone::Error, + "Rejected an overlapping approval request".to_string(), + None, + ); + return; + } + let saved_input = self.editor.text(); + self.editor.clear(); + let id = self.allocate_item_id(); + self.push(FeedItem::Approval { + id, + action: sanitize_terminal_text(&action), + detail: sanitize_terminal_text(&detail), + outcome: None, + }); + self.pending_approval = Some(PendingApproval { + id, + response, + saved_input, + }); + } + } + } + + fn handle_system_event(&mut self, event: SystemEvent) { + match event { + SystemEvent::FramesListening { url } => self.notice( + NoticeTone::Info, + "Listening for product frames".to_string(), + Some(url), + ), + SystemEvent::SigningHostReady => self.activity( + "signer".to_string(), + "Signing host ready".to_string(), + None, + ActivityState::Succeeded, + ), + SystemEvent::SigningHostNeedsSession => self.notice( + NoticeTone::Warning, + "No connected user".to_string(), + Some("Use /session to start a session.".to_string()), + ), + SystemEvent::SigningHostAccountExhausted { name, period } => self.notice( + NoticeTone::Warning, + format!("Account {name} has no free slots; switching accounts"), + Some(format!("Statement period {period}")), + ), + SystemEvent::SigningHostExit { outcome } => self.notice( + NoticeTone::Info, + "Pairing responder stopped".to_string(), + Some(human_identifier(&outcome)), + ), + SystemEvent::SigningHostError { reason } => self.activity( + "pairing".to_string(), + "Pairing failed".to_string(), + Some(reason), + ActivityState::Failed, + ), + SystemEvent::SigningHostResponderStarted => self.activity( + "pairing".to_string(), + "Waiting for the pairing host".to_string(), + None, + ActivityState::Running, + ), + SystemEvent::RingInfo { + ring_index, + members, + } => self.notice( + NoticeTone::Success, + "LitePeople ring ready".to_string(), + Some(format!("Ring {ring_index} · {members} members")), + ), + SystemEvent::AllowanceChecking { target } => self.start_activity( + format!("allowance:{target}"), + format!("Preparing {} access", allowance_name(&target)), + None, + ), + SystemEvent::AllowanceReady { + target, + sequence, + block_hash, + already_allocated, + } => self.activity( + format!("allowance:{target}"), + format!("{} access ready", allowance_name(&target)), + Some(if already_allocated { + format!("Existing allocation · sequence {sequence}") + } else { + format!( + "Sequence {sequence} · block {}", + abbreviate(block_hash.as_deref().unwrap_or(""), 18) + ) + }), + ActivityState::Succeeded, + ), + SystemEvent::NotificationDelivered { id, text, deeplink } => self.notice( + NoticeTone::Info, + format!("Notification #{id}"), + Some(match deeplink { + Some(deeplink) => format!("{text}\n{deeplink}"), + None => text, + }), + ), + SystemEvent::NotificationScheduled { + id, + text, + scheduled_at, + } => self.notice( + NoticeTone::Info, + format!("Notification #{id} scheduled"), + Some(format!("{text}\nUnix time {scheduled_at} ms")), + ), + SystemEvent::NotificationCancelled { id } => self.notice( + NoticeTone::Info, + format!("Notification #{id} cancelled"), + None, + ), + SystemEvent::PairingDeeplink { url } => { + self.start_activity( + "pairing".to_string(), + "Pairing link ready".to_string(), + Some("Open the dedicated link shown by the pairing host.".to_string()), + ); + self.notice(NoticeTone::Info, "Pairing link".to_string(), Some(url)); + } + SystemEvent::PairingAuthenticating => self.activity( + "pairing".to_string(), + "Authenticating pairing".to_string(), + None, + ActivityState::Running, + ), + SystemEvent::PairingConnected { user_id } => self.activity( + "pairing".to_string(), + user_id.map_or_else( + || "Paired".to_string(), + |user_id| format!("Paired with {user_id}"), + ), + None, + ActivityState::Succeeded, + ), + SystemEvent::PairingDisconnected => { + if self.connection != "disconnected" { + self.notice(NoticeTone::Info, "Pairing ended".to_string(), None); + } + } + SystemEvent::PairingFailed { reason } => self.activity( + "pairing".to_string(), + "Pairing failed".to_string(), + Some(reason), + ActivityState::Failed, + ), + SystemEvent::ScriptStarted => self.activity( + "script".to_string(), + "Script running".to_string(), + None, + ActivityState::Running, + ), + SystemEvent::ScriptExit { code: 0 } => self.activity( + "script".to_string(), + "Script finished".to_string(), + None, + ActivityState::Succeeded, + ), + SystemEvent::ScriptExit { code } => self.activity( + "script".to_string(), + "Script failed".to_string(), + Some(format!("Exit code {code}")), + ActivityState::Failed, + ), + SystemEvent::SessionStatus { + name, + path, + user_id, + } => { + let detail = Some(format!("User {user_id}\nPath {path}")); + if self.entries.iter().any(|entry| { + matches!( + entry, + FeedItem::Activity { + key, + state: ActivityState::Running, + .. + } if key == "session" + ) + }) { + self.activity( + "session".to_string(), + format!("Session {name} is active"), + detail, + ActivityState::Succeeded, + ); + } else { + self.notice(NoticeTone::Info, format!("Session {name}"), detail); + } + } + SystemEvent::SessionSwitching { from, to } => self.start_activity( + "session".to_string(), + format!("Switching from {from} to {to}"), + None, + ), + SystemEvent::SessionCreating { name } => self.activity( + "session".to_string(), + format!("Creating session {name}"), + None, + ActivityState::Running, + ), + SystemEvent::LogLevelChanged { level } => self.notice( + NoticeTone::Success, + format!("Log level set to {level}"), + None, + ), + SystemEvent::CopiedTranscript { entries } => self.notice( + NoticeTone::Success, + format!("Copied {entries} transcript entries"), + None, + ), + } + } + + fn handle_sso_event(&mut self, event: SsoEvent) { + let Some(kind) = event.kind.as_deref() else { + if let Some(summary) = event.fallback_summary { + self.notice(NoticeTone::Info, summary, None); + } + return; + }; + let key = event + .statement_request_id + .clone() + .unwrap_or_else(|| format!("sso:{}", self.next_item_id)); + let name = human_identifier(event.request.as_deref().unwrap_or("SSO request")); + let metadata = sso_metadata(&event); + let (name, state) = sso_activity_presentation(name, kind, event.outcome.as_deref()); + let updated = if let Some(FeedItem::Request { + name: current_name, + state: current_state, + metadata: current_metadata, + elapsed_ms, + reason, + .. + }) = self.entries.iter_mut().rev().find( + |entry| matches!(entry, FeedItem::Request { key: current, .. } if current == &key), + ) { + *current_name = name.clone(); + *current_state = state; + *current_metadata = metadata.clone(); + *elapsed_ms = event.elapsed_ms; + *reason = event.reason.clone(); + true + } else { + false + }; + if updated { + self.recalculate_retained(); + return; + } + self.push(FeedItem::Request { + key, + name, + state, + metadata, + elapsed_ms: event.elapsed_ms, + reason: event.reason, + }); + } + + fn handle_idle_event(&mut self, event: Event) -> Option> { + match event { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if self.pending_approval.is_some() { + self.handle_approval_key(key); + return None; + } + self.handle_common_key(key, false) + } + Event::Paste(text) => { + self.insert_paste(&text); + None + } + _ => None, + } + } + + fn handle_busy_event(&mut self, event: Event) -> bool { + match event { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if self.pending_approval.is_some() { + self.handle_approval_key(key); + return false; + } + self.handle_common_key(key, true) + .is_some_and(|value| value.is_none()) + } + Event::Paste(text) => { + self.insert_paste(&text); + false + } + _ => false, + } + } + + fn insert_paste(&mut self, text: &str) { + for character in text.chars().filter(|character| !character.is_control()) { + self.editor.insert(character); + } + } + + fn handle_common_key(&mut self, key: KeyEvent, busy: bool) -> Option> { + let control = key.modifiers.contains(KeyModifiers::CONTROL); + match (control, key.code) { + (true, KeyCode::Char('u')) => self.scroll_up(), + (true, KeyCode::Char('d')) => self.scroll_down(), + (true, KeyCode::Char('c')) => { + if !self.editor.text().is_empty() { + self.editor.clear(); + } else { + return Some(None); + } + } + (false, KeyCode::Char(character)) => self.editor.insert(character), + (false, KeyCode::Backspace) => self.editor.backspace(), + (false, KeyCode::Delete) => self.editor.delete(), + (false, KeyCode::Left) => self.editor.left(), + (false, KeyCode::Right) => self.editor.right(), + (false, KeyCode::Home) => self.editor.home(), + (false, KeyCode::End) => { + self.editor.end(); + self.scroll_from_bottom = 0; + } + (false, KeyCode::Up) => self.editor.up(), + (false, KeyCode::Down) => self.editor.down(), + (false, KeyCode::Esc) => self.editor.dismiss_completions(), + (false, KeyCode::Tab) => { + self.editor.accept_completion(); + } + (false, KeyCode::Enter) if busy => { + if !self.editor.text().trim().is_empty() { + self.notice( + NoticeTone::Warning, + "A command is already running".to_string(), + Some( + "Press Ctrl-C to cancel it before submitting another command." + .to_string(), + ), + ); + } + } + (false, KeyCode::Enter) => { + let text = self.editor.text(); + let completions = self.editor.completions(); + if let Some(completion) = completions.get(self.editor.completion_index()) + && text != completion.value + { + self.editor.accept_completion(); + return None; + } + let command = self.editor.submit(); + if !command.trim().is_empty() { + return Some(Some(command)); + } + } + _ => {} + } + None + } + + fn handle_approval_key(&mut self, key: KeyEvent) { + let control = key.modifiers.contains(KeyModifiers::CONTROL); + match (control, key.code) { + (true, KeyCode::Char('u')) => self.scroll_up(), + (true, KeyCode::Char('d')) => self.scroll_down(), + (false, KeyCode::Esc) => self.answer_approval(false), + (false, KeyCode::Char('y' | 'Y')) if self.editor.text().is_empty() => { + self.answer_approval(true); + } + (false, KeyCode::Char('n' | 'N')) if self.editor.text().is_empty() => { + self.answer_approval(false); + } + (false, KeyCode::Enter) => { + let answer = self.editor.text(); + match parse_approval(&answer) { + Some(answer) => self.answer_approval(answer), + None => { + self.editor.clear(); + self.notice(NoticeTone::Error, "Answer yes or no".to_string(), None); + } + } + } + (true, KeyCode::Char('c')) => self.editor.clear(), + (false, KeyCode::Char(character)) => self.editor.insert(character), + (false, KeyCode::Backspace) => self.editor.backspace(), + (false, KeyCode::Delete) => self.editor.delete(), + (false, KeyCode::Left) => self.editor.left(), + (false, KeyCode::Right) => self.editor.right(), + (false, KeyCode::Home) => self.editor.home(), + (false, KeyCode::End) => self.editor.end(), + _ => {} + } + } + + fn answer_approval(&mut self, approved: bool) { + let Some(pending) = self.pending_approval.take() else { + return; + }; + self.editor.clear(); + if let Some(FeedItem::Approval { outcome, .. }) = self + .entries + .iter_mut() + .rev() + .find(|entry| matches!(entry, FeedItem::Approval { id, .. } if *id == pending.id)) + { + *outcome = Some(approved); + } + self.recalculate_retained(); + self.editor.set_text(pending.saved_input); + let _ = pending.response.send(approved); + } + + fn scroll_up(&mut self) { + self.scroll_from_bottom = self + .scroll_from_bottom + .saturating_add((self.transcript_height / 2).max(1)); + } + + fn scroll_down(&mut self) { + self.scroll_from_bottom = self + .scroll_from_bottom + .saturating_sub((self.transcript_height / 2).max(1)); + } +} + +fn render(frame: &mut ratatui::Frame<'_>, app: &mut App) { + let completions = if app.pending_approval.is_some() { + Vec::new() + } else { + app.editor.completions() + }; + let height = frame.area().height; + let show_vertical_padding = height >= 7; + let show_footer = height >= 3; + let vertical_padding = u16::from(show_vertical_padding); + let base_composer_height = 1 + vertical_padding * 2 + u16::from(show_footer); + let available_completion_height = if height <= 4 { + 0 + } else { + height + .saturating_sub(1) + .saturating_sub(1) + .saturating_sub(base_composer_height) + .min(MAX_VISIBLE_COMPLETIONS as u16) + }; + let completion_range = completion_window( + completions.len(), + app.editor.completion_index(), + usize::from(available_completion_height), + ); + let completion_height = completion_range.len() as u16; + let composer_height = base_composer_height.saturating_add(completion_height); + let areas = Layout::vertical([Constraint::Min(1), Constraint::Length(composer_height)]) + .split(frame.area()); + app.transcript_height = areas[0].height as usize; + + let transcript_lines = app + .entries + .iter() + .flat_map(|item| feed_item_lines(item, usize::from(areas[0].width))) + .collect::>(); + let transcript = Paragraph::new(transcript_lines).wrap(Wrap { trim: false }); + let content_height = transcript.line_count(areas[0].width); + let top = content_height + .saturating_sub(app.transcript_height) + .saturating_sub(app.scroll_from_bottom) + .min(u16::MAX as usize) as u16; + frame.render_widget(transcript.scroll((top, 0)), areas[0]); + + let surface_area = areas[1]; + let surface_style = input_surface_style(); + let input_surface_area = Rect::new( + surface_area.x, + surface_area.y, + surface_area.width, + surface_area.height.saturating_sub(u16::from(show_footer)), + ); + frame.render_widget(Block::default().style(surface_style), input_surface_area); + let content_padding = if surface_area.width >= 4 { + COMPOSER_HORIZONTAL_PADDING + } else { + 0 + }; + let composer_content_area = Rect::new( + surface_area.x.saturating_add(content_padding), + surface_area.y, + surface_area + .width + .saturating_sub(content_padding.saturating_mul(2)), + surface_area.height, + ); + + if completion_height > 0 { + let selected = app.editor.completion_index(); + let visible_completions = completions + .iter() + .skip(completion_range.start) + .take(completion_range.len()) + .collect::>(); + let command_column_width = visible_completions + .iter() + .map(|completion| text_display_width(&completion.value)) + .max() + .unwrap_or_default(); + let items = visible_completions + .into_iter() + .map(|completion| { + let mut spans = vec![Span::styled( + completion.value.clone(), + semantic_style(Color::Cyan).add_modifier(Modifier::BOLD), + )]; + if composer_content_area.width >= 40 { + let padding = + command_column_width.saturating_sub(text_display_width(&completion.value)); + spans.push(Span::raw(" ".repeat(padding.saturating_add(2)))); + spans.push(Span::styled( + completion.description, + Style::default().add_modifier(Modifier::DIM), + )); + } + ListItem::new(Line::from(spans)) + }) + .collect::>(); + let mut state = ListState::default() + .with_selected(Some(selected.saturating_sub(completion_range.start))); + let completion_area = Rect::new( + composer_content_area.x, + surface_area.y.saturating_add(vertical_padding), + composer_content_area.width, + completion_height, + ); + frame.render_stateful_widget( + List::new(items) + .style(surface_style) + .highlight_symbol("› ") + .highlight_spacing(HighlightSpacing::Always) + .highlight_style(semantic_style(Color::Cyan).add_modifier(Modifier::BOLD)), + completion_area, + &mut state, + ); + } + + let approval = app.pending_approval.is_some(); + let input = app.editor.text(); + let prompt_area = Rect::new( + composer_content_area.x, + surface_area + .bottom() + .saturating_sub(1 + u16::from(show_footer) + vertical_padding), + composer_content_area.width, + 1, + ); + let viewport = input_viewport( + &input, + app.editor.cursor(), + prompt_area.width.saturating_sub(2), + ); + let (prompt_text, prompt_style) = if input.is_empty() && !approval { + ( + "Type / for commands".to_string(), + Style::default().add_modifier(Modifier::DIM), + ) + } else { + ( + viewport.text, + if approval { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }, + ) + }; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + "› ", + semantic_style(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::styled(prompt_text, prompt_style), + ])) + .style(surface_style), + prompt_area, + ); + let cursor_x = prompt_area + .x + .saturating_add(2) + .saturating_add(viewport.cursor_x) + .min(prompt_area.right().saturating_sub(1)); + frame.set_cursor_position((cursor_x, prompt_area.y)); + + if show_footer { + let footer_area = Rect::new( + surface_area.x, + surface_area.bottom().saturating_sub(1), + surface_area.width, + 1, + ); + frame.render_widget( + Paragraph::new(composer_status_line( + app, + approval, + completion_height > 0, + surface_area.width, + )), + footer_area, + ); + } +} + +fn composer_status_line( + app: &App, + approval: bool, + autocomplete: bool, + width: u16, +) -> Line<'static> { + let mut status = header_line(app, width); + let hint = footer_text(app, approval, autocomplete, width); + if hint.is_empty() { + return status; + } + let status_width = status + .spans + .iter() + .map(|span| text_display_width(span.content.as_ref())) + .sum::(); + let hint_width = text_display_width(&hint); + let width = usize::from(width); + if status_width.saturating_add(hint_width).saturating_add(3) <= width { + status + .spans + .push(Span::raw(" ".repeat(width - status_width - hint_width))); + status.spans.push(Span::styled( + hint, + Style::default().add_modifier(Modifier::DIM), + )); + status + } else { + status + } +} + +fn footer_text(app: &App, approval: bool, autocomplete: bool, width: u16) -> String { + if approval { + return "y approve · n deny · Esc deny".to_string(); + } + if let Some(command) = app.busy.as_deref() { + return format!("Running {command} · Ctrl-C cancel"); + } + if app.scroll_from_bottom > 0 { + return "Ctrl-D latest · Ctrl-U/D scroll".to_string(); + } + if autocomplete && width >= 70 { + return "↑↓ select · Tab/Enter complete".to_string(); + } + String::new() +} + +fn input_surface_style() -> Style { + if std::env::var_os("NO_COLOR").is_some() { + Style::default() + } else { + let background = detected_terminal_background().unwrap_or((24, 24, 32)); + let (red, green, blue) = blended_surface(background); + let color = if terminal_supports_true_color() { + Color::Rgb(red, green, blue) + } else { + Color::Indexed(rgb_to_ansi256(red, green, blue)) + }; + Style::default().bg(color) + } +} + +fn detected_terminal_background() -> Option<(u8, u8, u8)> { + let value = std::env::var("COLORFGBG").ok()?; + let index = value.rsplit(';').next()?.parse::().ok()?; + ansi256_to_rgb(index) +} + +fn blended_surface((red, green, blue): (u8, u8, u8)) -> (u8, u8, u8) { + let luminance = + (u32::from(red) * 2126 + u32::from(green) * 7152 + u32::from(blue) * 722) / 10_000; + let (target, percent) = if luminance < 128 { + (255_u8, 12_u16) + } else { + (0_u8, 4_u16) + }; + let blend = |value: u8| { + ((u16::from(value) * (100 - percent) + u16::from(target) * percent) / 100) as u8 + }; + (blend(red), blend(green), blend(blue)) +} + +fn terminal_supports_true_color() -> bool { + std::env::var("COLORTERM").is_ok_and(|value| { + value.eq_ignore_ascii_case("truecolor") || value.eq_ignore_ascii_case("24bit") + }) +} + +fn ansi256_to_rgb(index: u8) -> Option<(u8, u8, u8)> { + const BASIC: [(u8, u8, u8); 16] = [ + (0, 0, 0), + (128, 0, 0), + (0, 128, 0), + (128, 128, 0), + (0, 0, 128), + (128, 0, 128), + (0, 128, 128), + (192, 192, 192), + (128, 128, 128), + (255, 0, 0), + (0, 255, 0), + (255, 255, 0), + (0, 0, 255), + (255, 0, 255), + (0, 255, 255), + (255, 255, 255), + ]; + match index { + 0..=15 => Some(BASIC[usize::from(index)]), + 16..=231 => { + let index = index - 16; + let component = |value: u8| if value == 0 { 0 } else { 55 + value * 40 }; + Some(( + component(index / 36), + component((index % 36) / 6), + component(index % 6), + )) + } + 232..=255 => { + let value = 8 + (index - 232) * 10; + Some((value, value, value)) + } + } +} + +fn rgb_to_ansi256(red: u8, green: u8, blue: u8) -> u8 { + let component = |value: u8| ((u16::from(value) * 5 + 127) / 255) as u8; + 16 + 36 * component(red) + 6 * component(green) + component(blue) +} + +fn header_line(app: &App, width: u16) -> Line<'static> { + let user_style = match app.connection.as_str() { + "failed" => semantic_style(Color::Red), + "disconnected" | "pairing" | "authenticating" | "connected" => { + Style::default().add_modifier(Modifier::DIM) + } + _ => semantic_style(Color::Green), + }; + let role = match app.role { + HostRole::Pairing => "pairing", + HostRole::Signing => "signing", + }; + let title = format!(" TrUAPI {role} host"); + let user_prefix = " · 👤 "; + let network_prefix = " · 🌐 "; + let product_prefix = " · 📦 "; + let width = usize::from(width); + let full_prefix_width = text_display_width(&title) + .saturating_add(text_display_width(user_prefix)) + .saturating_add(text_display_width(&app.connection)) + .saturating_add(text_display_width(network_prefix)) + .saturating_add(text_display_width(&app.network)) + .saturating_add(text_display_width(product_prefix)); + + if full_prefix_width < width { + let product = ellipsize_display_width(&app.product, width - full_prefix_width); + return Line::from(vec![ + Span::styled( + title, + semantic_style(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::raw(user_prefix), + Span::styled(app.connection.clone(), user_style), + Span::raw(network_prefix), + Span::styled( + app.network.clone(), + Style::default().add_modifier(Modifier::DIM), + ), + Span::raw(product_prefix), + Span::styled(product, semantic_style(Color::Cyan)), + ]); + } + + let fixed_width = text_display_width(" 👤 ") + .saturating_add(text_display_width(network_prefix)) + .saturating_add(text_display_width(product_prefix)); + if fixed_width >= width { + return Line::from(Span::styled( + ellipsize_display_width(&title, width), + semantic_style(Color::Cyan).add_modifier(Modifier::BOLD), + )); + } + let mut remaining = width - fixed_width; + let user_budget = text_display_width(&app.connection).min(remaining / 3); + remaining = remaining.saturating_sub(user_budget); + let network_budget = text_display_width(&app.network).min(remaining / 2); + remaining = remaining.saturating_sub(network_budget); + let product_budget = remaining; + + Line::from(vec![ + Span::raw(" 👤 "), + Span::styled( + ellipsize_display_width(&app.connection, user_budget), + user_style, + ), + Span::raw(network_prefix), + Span::styled( + ellipsize_display_width(&app.network, network_budget), + Style::default().add_modifier(Modifier::DIM), + ), + Span::raw(product_prefix), + Span::styled( + ellipsize_display_width(&app.product, product_budget), + semantic_style(Color::Cyan), + ), + ]) +} + +fn ellipsize_display_width(value: &str, max_width: usize) -> String { + if text_display_width(value) <= max_width { + return value.to_string(); + } + if max_width == 0 { + return String::new(); + } + if max_width == 1 { + return "…".to_string(); + } + let content_width = max_width - 1; + let mut result = String::new(); + let mut width = 0usize; + for character in value.chars() { + let character_width = UnicodeWidthChar::width(character).unwrap_or(0); + if width.saturating_add(character_width) > content_width { + break; + } + result.push(character); + width = width.saturating_add(character_width); + } + result.push('…'); + result +} + +fn semantic_style(color: Color) -> Style { + if std::env::var_os("NO_COLOR").is_some() { + Style::default() + } else { + Style::default().fg(color) + } +} + +fn completion_window(total: usize, selected: usize, max_visible: usize) -> Range { + if max_visible == 0 { + return 0..0; + } + if total <= max_visible { + return 0..total; + } + let start = selected + .saturating_add(1) + .saturating_sub(max_visible) + .min(total - max_visible); + start..start + max_visible +} + +fn feed_item_cost(item: &FeedItem) -> (usize, usize) { + let lines = feed_item_plain_lines(item); + let bytes = lines.iter().map(String::len).sum(); + (lines.len().max(1), bytes) +} + +fn feed_item_lines(item: &FeedItem, width: usize) -> Vec> { + match item { + FeedItem::Log(text) => text + .lines() + .map(|line| { + Line::from(Span::styled( + format!(" {line}"), + Style::default().add_modifier(Modifier::DIM), + )) + }) + .collect(), + FeedItem::Notice { + tone, + title, + detail, + } => status_lines(*tone, title, detail.as_deref()), + FeedItem::Command(command) => { + vec![ + Line::default(), + command_divider_line(command, width), + Line::default(), + ] + } + FeedItem::Stream { kind, lines } => lines + .iter() + .enumerate() + .map(|(index, line)| match kind { + StreamKind::Stdout => Line::from(format!(" {line}")), + StreamKind::Stderr => Line::from(vec![ + Span::styled( + if index == 0 { "! " } else { " " }, + semantic_style(Color::Red), + ), + Span::raw(line.clone()), + ]), + }) + .collect(), + FeedItem::Activity { + label, + detail, + state, + .. + } => activity_lines(*state, label, detail.as_deref()), + FeedItem::Approval { + action, + detail, + outcome, + .. + } => match outcome { + Some(true) => status_lines(NoticeTone::Success, &format!("Approved {action}"), None), + Some(false) => status_lines(NoticeTone::Warning, &format!("Rejected {action}"), None), + None => vec![ + Line::default(), + Line::from(vec![ + Span::styled( + "! ", + semantic_style(Color::Yellow).add_modifier(Modifier::BOLD), + ), + Span::styled( + "Approval required", + Style::default().add_modifier(Modifier::BOLD), + ), + ]), + Line::default(), + Line::from(format!(" {action}")), + Line::from(Span::styled( + format!(" {detail}"), + Style::default().add_modifier(Modifier::DIM), + )), + Line::default(), + Line::from(Span::styled( + " [y] Approve [n] Reject Esc deny", + semantic_style(Color::Cyan), + )), + ], + }, + FeedItem::Request { + name, + state, + metadata, + elapsed_ms, + reason, + .. + } => { + let label = + elapsed_ms.map_or_else(|| name.clone(), |elapsed| format!("{name} · {elapsed} ms")); + let detail = match (reason, metadata) { + (Some(reason), Some(metadata)) => Some(format!("{reason}\n{metadata}")), + (Some(reason), None) => Some(reason.clone()), + (None, Some(metadata)) => Some(metadata.clone()), + (None, None) => None, + }; + activity_lines(*state, &label, detail.as_deref()) + } + } +} + +fn command_divider_line(command: &str, width: usize) -> Line<'static> { + let divider_style = Style::default().add_modifier(Modifier::DIM); + if width == 0 { + return Line::from(vec![ + Span::styled("─ ", divider_style), + Span::styled( + command.to_string(), + Style::default().add_modifier(Modifier::BOLD), + ), + ]); + } + if width <= 3 { + return Line::from(Span::styled("─".repeat(width), divider_style)); + } + let title = truncate_display_width(command, width - 3); + let used = 3 + text_display_width(&title); + Line::from(vec![ + Span::styled("─ ", divider_style), + Span::styled(title, Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + format!(" {}", "─".repeat(width.saturating_sub(used))), + divider_style, + ), + ]) +} + +fn truncate_display_width(text: &str, max_width: usize) -> String { + if text_display_width(text) <= max_width { + return text.to_string(); + } + if max_width == 0 { + return String::new(); + } + let content_width = max_width - 1; + let mut result = String::new(); + let mut width = 0; + for character in text.chars() { + let character_width = UnicodeWidthChar::width(character).unwrap_or(0); + if width + character_width > content_width { + break; + } + result.push(character); + width += character_width; + } + result.push('…'); + result +} + +fn status_lines(tone: NoticeTone, title: &str, detail: Option<&str>) -> Vec> { + let (symbol, color) = match tone { + NoticeTone::Info => ("•", Color::Cyan), + NoticeTone::Success => ("✓", Color::Green), + NoticeTone::Warning => ("!", Color::Yellow), + NoticeTone::Error => ("×", Color::Red), + }; + let mut title_lines = title.lines(); + let first = title_lines.next().unwrap_or_default(); + let mut lines = vec![Line::from(vec![ + Span::styled(format!("{symbol} "), semantic_style(color)), + Span::raw(first.to_string()), + ])]; + lines.extend(title_lines.map(|line| Line::from(format!(" {line}")))); + if let Some(detail) = detail { + lines.extend(detail.lines().map(|line| { + Line::from(Span::styled( + format!(" {line}"), + Style::default().add_modifier(Modifier::DIM), + )) + })); + } + lines +} + +fn activity_lines(state: ActivityState, label: &str, detail: Option<&str>) -> Vec> { + let tone = match state { + ActivityState::Running => NoticeTone::Info, + ActivityState::Succeeded => NoticeTone::Success, + ActivityState::Warning => NoticeTone::Warning, + ActivityState::Failed => NoticeTone::Error, + ActivityState::Cancelled => NoticeTone::Warning, + }; + let mut lines = status_lines(tone, label, detail); + if state == ActivityState::Running + && let Some(first) = lines.first_mut() + { + first.spans[0] = Span::styled("◌ ", semantic_style(Color::Cyan)); + } + if state == ActivityState::Cancelled + && let Some(first) = lines.first_mut() + { + first.spans[0] = Span::styled("– ", semantic_style(Color::Yellow)); + } + lines +} + +fn feed_item_plain_lines(item: &FeedItem) -> Vec { + feed_item_lines(item, 0) + .into_iter() + .map(|line| { + let line = line + .spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::>() + .join(""); + redact_pairing_link(&line) + }) + .collect() +} + +struct InputViewport { + text: String, + cursor_x: u16, +} + +fn input_viewport(input: &str, cursor: usize, max_width: u16) -> InputViewport { + let characters = input.chars().collect::>(); + let cursor = cursor.min(characters.len()); + let max_width = usize::from(max_width); + if max_width == 0 { + return InputViewport { + text: String::new(), + cursor_x: 0, + }; + } + + let mut start = 0; + while start < cursor { + let marker_width = usize::from(start > 0); + let before_width = display_width(&characters[start..cursor]); + if marker_width + before_width <= max_width { + break; + } + start += 1; + } + + let clipped = start > 0; + let mut text = if clipped { + "…".to_string() + } else { + String::new() + }; + let mut width = usize::from(clipped); + let cursor_x = width + display_width(&characters[start..cursor]); + for character in &characters[start..] { + let character_width = UnicodeWidthChar::width(*character).unwrap_or(0); + if width + character_width > max_width { + break; + } + text.push(*character); + width += character_width; + } + InputViewport { + text, + cursor_x: cursor_x.min(max_width) as u16, + } +} + +fn display_width(characters: &[char]) -> usize { + characters + .iter() + .map(|character| UnicodeWidthChar::width(*character).unwrap_or(0)) + .sum() +} + +fn text_display_width(text: &str) -> usize { + text.chars() + .map(|character| UnicodeWidthChar::width(character).unwrap_or(0)) + .sum() +} + +fn redact_command(command: &str) -> String { + if command.trim_start().starts_with("/pair ") { + "/pair ".to_string() + } else { + sanitize_terminal_text(command) + } +} + +fn redact_pairing_link(text: &str) -> String { + let Some(start) = text.find("polkadotapp://pair?") else { + return text.to_string(); + }; + format!("{}", &text[..start]) +} + +fn sanitize_terminal_text(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let mut characters = text.chars().peekable(); + while let Some(character) = characters.next() { + if character == '\u{1b}' { + match characters.peek() { + Some('[') => { + characters.next(); + for value in characters.by_ref() { + if ('@'..='~').contains(&value) { + break; + } + } + } + Some(']') => { + characters.next(); + while let Some(value) = characters.next() { + if value == '\u{7}' { + break; + } + if value == '\u{1b}' && characters.peek() == Some(&'\\') { + characters.next(); + break; + } + } + } + _ => {} + } + continue; + } + if character == '\n' || character == '\t' || !character.is_control() { + result.push(character); + } + } + result +} + +fn truncate_utf8(text: &str, max_bytes: usize) -> String { + if text.len() <= max_bytes { + return text.to_string(); + } + let mut end = max_bytes.saturating_sub('…'.len_utf8()); + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &text[..end]) +} + +fn allowance_name(target: &str) -> &'static str { + match target { + "wallet-sso" => "Wallet", + "device" => "Device", + _ => "Product", + } +} + +fn human_identifier(value: &str) -> String { + let value = value + .split("::") + .last() + .unwrap_or(value) + .replace(['_', '-'], " "); + let mut characters = value.chars(); + characters.next().map_or(value.clone(), |first| { + first.to_uppercase().collect::() + characters.as_str() + }) +} + +fn sso_activity_presentation( + name: String, + kind: &str, + outcome: Option<&str>, +) -> (String, ActivityState) { + match (kind, outcome) { + ("request_received", _) => (name, ActivityState::Running), + ("response_failed", _) => (format!("{name} failed"), ActivityState::Failed), + ("response_sent", None | Some("ok")) => (name, ActivityState::Succeeded), + ("response_sent", Some("partial")) => ( + format!("{name} partially completed"), + ActivityState::Warning, + ), + ("response_sent", Some("not_available")) => { + (format!("{name} unavailable"), ActivityState::Warning) + } + ("response_sent", Some("rejected")) => (format!("{name} rejected"), ActivityState::Failed), + ("response_sent", Some(_)) => (format!("{name} failed"), ActivityState::Failed), + _ => (name, ActivityState::Running), + } +} + +fn abbreviate(value: &str, max_chars: usize) -> String { + let count = value.chars().count(); + if count <= max_chars || max_chars < 2 { + return value.to_string(); + } + let side = (max_chars - 1) / 2; + let start = value.chars().take(side).collect::(); + let end = value + .chars() + .skip(count.saturating_sub(side)) + .collect::(); + format!("{start}…{end}") +} + +fn sso_metadata(event: &SsoEvent) -> Option { + let request = event + .statement_request_id + .as_deref() + .map(|value| format!("request {}", abbreviate(value, 18))); + let response = event + .response_message_id + .as_deref() + .or(event.remote_message_id.as_deref()) + .map(|value| format!("message {}", abbreviate(value, 18))); + match (request, response) { + (Some(request), Some(response)) => Some(format!("{request} · {response}")), + (Some(request), None) => Some(request), + (None, Some(response)) => Some(response), + (None, None) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::layout::Position; + use tracing_subscriber::layer::SubscriberExt; + + fn test_app() -> App { + App::new( + "testnet".to_string(), + "playground.dot".to_string(), + "default".to_string(), + vec!["default".to_string()], + LogLevel::Info, + ) + } + + #[test] + fn approval_temporarily_replaces_and_then_restores_command_draft() { + let mut app = test_app(); + app.editor.set_text("/script draft.ts"); + let (response, answer) = oneshot::channel(); + app.handle_event(UiEvent::Approval { + action: "sign request".to_string(), + detail: "payload".to_string(), + response, + }); + app.handle_approval_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + + assert_eq!(answer.blocking_recv(), Ok(true)); + assert_eq!(app.editor.text(), "/script draft.ts"); + assert!(app.pending_approval.is_none()); + } + + #[test] + fn ctrl_u_and_ctrl_d_scroll_half_a_viewport() { + let mut app = test_app(); + app.transcript_height = 20; + app.handle_common_key( + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), + false, + ); + assert_eq!(app.scroll_from_bottom, 10); + app.handle_common_key( + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), + false, + ); + assert_eq!(app.scroll_from_bottom, 0); + } + + #[test] + fn transcript_is_bounded() { + let mut app = test_app(); + for index in 0..=TRANSCRIPT_LIMIT { + app.push(FeedItem::Log(index.to_string())); + } + assert_eq!(app.entries.len(), TRANSCRIPT_LIMIT); + assert!(matches!(app.entries.front(), Some(FeedItem::Log(text)) if text == "1")); + } + + #[test] + fn transcript_copy_uses_natural_grouped_output_and_redacts_deeplinks() { + let mut app = test_app(); + app.handle_system_event(SystemEvent::SigningHostReady); + app.push_command("/pair polkadotapp://pair?handshake=secret".to_string()); + app.stream(StreamKind::Stdout, "user id: alice.dot".to_string()); + + let transcript = app.transcript_text(); + assert!(transcript.contains("✓ Signing host ready")); + assert!(transcript.contains("─ /pair ")); + assert!(transcript.contains(" user id: alice.dot")); + assert!(!transcript.contains("handshake=secret")); + assert!(!transcript.contains("SCRIPT ·")); + } + + #[test] + fn missing_signing_session_explains_how_to_connect_a_user() { + let mut app = test_app(); + + app.handle_system_event(SystemEvent::SigningHostNeedsSession); + + assert_eq!( + app.transcript_text(), + "! No connected user\n Use /session to start a session." + ); + } + + #[test] + fn keyed_activity_updates_in_place() { + let mut app = test_app(); + for attempt in 1..=90 { + app.activity( + "pairing".to_string(), + "Preparing pairing".to_string(), + Some(format!("Attempt {attempt}/90")), + ActivityState::Running, + ); + } + app.activity( + "pairing".to_string(), + "Paired with alice.dot".to_string(), + None, + ActivityState::Succeeded, + ); + + assert_eq!(app.entries.len(), 1); + assert!(app.transcript_text().contains("✓ Paired with alice.dot")); + } + + #[test] + fn repeated_activity_keeps_completed_history() { + let mut app = test_app(); + app.start_activity("pairing".to_string(), "Preparing pairing".to_string(), None); + app.activity( + "pairing".to_string(), + "Paired with alice.dot".to_string(), + None, + ActivityState::Succeeded, + ); + app.start_activity( + "pairing".to_string(), + "Preparing another pairing".to_string(), + None, + ); + app.activity( + "pairing".to_string(), + "Paired with bob.dot".to_string(), + None, + ActivityState::Succeeded, + ); + + assert_eq!(app.entries.len(), 2); + let transcript = app.transcript_text(); + assert!(transcript.contains("✓ Paired with alice.dot")); + assert!(transcript.contains("✓ Paired with bob.dot")); + } + + #[test] + fn failed_operation_finalizes_new_running_activities() { + let mut app = test_app(); + let checkpoint = app.next_item_id; + app.activity( + "signer".to_string(), + "Setting up signer".to_string(), + Some("Waiting for identity".to_string()), + ActivityState::Running, + ); + app.finish_running_activities_since( + checkpoint, + ActivityState::Failed, + "Stopped after an error", + ); + + assert!(app.transcript_text().contains("× Setting up signer")); + assert!(app.transcript_text().contains("Stopped after an error")); + assert!(!app.entries.iter().any(|entry| matches!( + entry, + FeedItem::Activity { + state: ActivityState::Running, + .. + } + ))); + } + + #[test] + fn script_streams_group_lines_and_preserve_blank_lines() { + let mut app = test_app(); + app.stream(StreamKind::Stdout, "first".to_string()); + app.stream(StreamKind::Stdout, String::new()); + app.stream(StreamKind::Stdout, "third".to_string()); + app.stream(StreamKind::Stderr, "failed".to_string()); + + assert_eq!(app.entries.len(), 2); + assert!(matches!( + app.entries.front(), + Some(FeedItem::Stream { lines, .. }) if lines == &["first", "", "third"] + )); + assert_eq!(app.transcript_text(), " first\n \n third\n! failed"); + } + + #[test] + fn large_script_output_is_chunked_and_bounded() { + let mut app = test_app(); + for index in 0..(TRANSCRIPT_LINE_LIMIT + 500) { + app.stream(StreamKind::Stdout, format!("line {index}")); + } + + assert!(app.retained_lines <= TRANSCRIPT_LINE_LIMIT); + assert!(app.retained_bytes <= TRANSCRIPT_BYTE_LIMIT); + assert!(app.entries.iter().all(|entry| match entry { + FeedItem::Stream { lines, .. } => lines.len() <= STREAM_CHUNK_LINE_LIMIT, + _ => true, + })); + assert!(app.transcript_text().contains("line 10499")); + assert!(!app.transcript_text().contains("line 0\n")); + } + + #[test] + fn autocomplete_shows_all_current_commands_and_scrolls_larger_menus() { + let mut app = test_app(); + app.editor.set_text("/"); + let completions = app.editor.completions(); + let visible = completion_window( + completions.len(), + app.editor.completion_index(), + MAX_VISIBLE_COMPLETIONS, + ); + assert!( + completions[visible] + .iter() + .any(|completion| completion.value == "/copy") + ); + + assert_eq!(completion_window(12, 0, 8), 0..8); + assert_eq!(completion_window(12, 11, 8), 4..12); + assert_eq!(completion_window(12, 11, 0), 0..0); + } + + #[test] + fn autocomplete_descriptions_share_one_column() -> Result<()> { + let mut app = test_app(); + app.editor.set_text("/"); + + let (screen, _) = render_app(&mut app, 80, 16)?; + let deeplink = screen + .lines() + .find(|line| line.contains("answer a Polkadot")) + .context("render deeplink completion")?; + let script = screen + .lines() + .find(|line| line.contains("edit the last")) + .context("render script completion")?; + let clear = screen + .lines() + .find(|line| line.contains("clear the visible")) + .context("render clear completion")?; + + let column = |line: &str, description: &str| { + line.find(description) + .map(|index| text_display_width(&line[..index])) + }; + assert_eq!(column(deeplink, "answer"), column(script, "edit")); + assert_eq!(column(script, "edit"), column(clear, "clear the visible")); + Ok(()) + } + + #[test] + fn session_event_updates_status_state_and_completions() { + let mut app = test_app(); + app.handle_event(UiEvent::Session { + name: "alice".to_string(), + available: vec!["alice".to_string(), "bob".to_string()], + }); + app.editor.set_text("/session b"); + + assert_eq!(app.session, "alice"); + assert_eq!(app.editor.completions()[0].value, "/session bob"); + } + + #[test] + fn pairing_host_status_names_the_role_without_product_or_log_noise() -> Result<()> { + let mut app = App::new_pairing( + "paseo-next-v2".to_string(), + "playground.dot".to_string(), + LogLevel::Info, + ); + app.editor.set_text("/"); + + let (screen, _) = render_app(&mut app, 120, 14)?; + let status = line_text(header_line(&app, 120)); + + assert_eq!( + status, + " TrUAPI pairing host · 👤 disconnected · 🌐 paseo-next-v2 · 📦 playground.dot" + ); + assert!(!screen.contains("log info")); + assert!(screen.contains("/script")); + assert!(!screen.contains("/pair")); + assert!(!screen.contains("/session")); + Ok(()) + } + + #[test] + fn signing_host_status_shows_user_network_and_product_without_labels() -> Result<()> { + let mut app = test_app(); + app.connection = "alice.dot".to_string(); + + let (screen, _) = render_app(&mut app, 120, 8)?; + let status = line_text(header_line(&app, 120)); + + assert_eq!( + status, + " TrUAPI signing host · 👤 alice.dot · 🌐 testnet · 📦 playground.dot" + ); + assert!(!screen.contains("session default")); + assert!(!screen.contains("log info")); + Ok(()) + } + + #[test] + fn long_product_name_is_ellipsized_in_the_status_bar() -> Result<()> { + let mut app = test_app(); + app.product = "product-name-that-is-far-too-long-for-the-status-bar".to_string(); + + let status = line_text(header_line(&app, 80)); + + assert!(status.contains('…')); + assert!(!status.contains(&app.product)); + assert!(text_display_width(&status) <= 80); + for width in 1..=120 { + let status = line_text(header_line(&app, width)); + assert!(text_display_width(&status) <= usize::from(width)); + } + Ok(()) + } + + #[test] + fn idle_command_guidance_is_an_input_placeholder() -> Result<()> { + let mut app = test_app(); + + let (idle, _) = render_app(&mut app, 80, 7)?; + assert!(idle.contains("› Type / for commands")); + assert!(!idle.contains("↑↓ history")); + + app.editor.set_text("/session"); + let (editing, _) = render_app(&mut app, 80, 7)?; + assert!(!editing.contains("Type / for commands")); + Ok(()) + } + + #[test] + fn sso_summary_bypasses_the_adjustable_log_filter() { + let (sender, mut receiver) = mpsc::unbounded_channel(); + *active_ui().lock().expect("lock active test UI") = Some(sender); + let filtered_logs = tracing_subscriber::fmt::layer() + .with_writer(io::sink) + .with_filter(tracing_subscriber::EnvFilter::new("error")); + let subscriber = tracing_subscriber::registry() + .with(SsoTranscriptLayer) + .with(filtered_logs); + + tracing::subscriber::with_default(subscriber, || { + tracing::event!( + target: SSO_TRANSCRIPT_TARGET, + tracing::Level::INFO, + cli_summary = "SSO response sent · get_account_alias · ok", + cli_event = "response_sent", + request = "get_account_alias", + statement_request_id = "req:1", + response_message_id = "msg:2", + outcome = "ok", + elapsed_ms = 84_u64, + ); + }); + *active_ui().lock().expect("unlock active test UI") = None; + + let UiEvent::Sso(event) = receiver.try_recv().expect("summary transcript event") else { + panic!("expected SSO transcript event"); + }; + assert_eq!(event.kind.as_deref(), Some("response_sent")); + assert_eq!(event.request.as_deref(), Some("get_account_alias")); + assert_eq!(event.elapsed_ms, Some(84)); + } + + #[test] + fn system_events_share_copy_between_streaming_and_tui_renderers() { + let event = SystemEvent::FramesListening { + url: "ws://127.0.0.1:9956".to_string(), + }; + assert_eq!( + event.human(), + "• Listening for product frames\n ws://127.0.0.1:9956" + ); + + let mut app = test_app(); + app.handle_system_event(event); + assert_eq!( + app.transcript_text(), + "• Listening for product frames\n ws://127.0.0.1:9956" + ); + } + + #[test] + fn streaming_pairing_event_keeps_the_actionable_link() { + let event = SystemEvent::PairingDeeplink { + url: "polkadotapp://pair?handshake=0123".to_string(), + }; + + assert!(event.human().contains("polkadotapp://pair?handshake=0123")); + } + + #[test] + fn operator_login_copies_only_the_typed_pairing_deeplink_event() { + let event = UiEvent::System(SystemEvent::PairingDeeplink { + url: "polkadotapp://pair?handshake=0123".to_string(), + }); + + assert_eq!( + pairing_deeplink_to_copy(true, &event), + Some("polkadotapp://pair?handshake=0123") + ); + assert_eq!(pairing_deeplink_to_copy(false, &event), None); + assert_eq!( + pairing_deeplink_to_copy(true, &UiEvent::Connection("pairing".to_string())), + None + ); + } + + #[test] + fn prompt_cursor_is_after_ascii_and_wide_input() -> Result<()> { + let mut app = test_app(); + app.editor.set_text("/d"); + let (screen, cursor) = render_app(&mut app, 80, 12)?; + assert!( + screen + .lines() + .find(|line| line.contains("/d")) + .is_some_and(|line| line.starts_with(" › /d")) + ); + assert_eq!(cursor.x, 5); + + app.editor.set_text("/界"); + let (_, cursor) = render_app(&mut app, 80, 12)?; + assert_eq!(cursor.x, 6); + Ok(()) + } + + #[test] + fn long_input_viewport_keeps_cursor_visible() { + let viewport = input_viewport("/script a/very/long/path.ts", 27, 10); + assert!(viewport.text.starts_with('…')); + assert!(viewport.cursor_x <= 10); + assert!(viewport.text.ends_with("path.ts")); + } + + #[test] + fn rendered_command_and_script_output_use_a_quiet_transcript() -> Result<()> { + let mut app = test_app(); + app.push_command("/script demo.ts".to_string()); + app.handle_system_event(SystemEvent::ScriptStarted); + app.stream(StreamKind::Stdout, "head follow event {".to_string()); + app.stream(StreamKind::Stdout, " tag: Initialized".to_string()); + app.handle_system_event(SystemEvent::ScriptExit { code: 0 }); + + let (screen, _) = render_app(&mut app, 80, 14)?; + let divider = screen + .lines() + .find(|line| line.contains("/script demo.ts")) + .expect("render command divider"); + assert!(divider.starts_with("─ /script demo.ts ")); + assert!(divider.ends_with('─')); + assert!(screen.contains(" head follow event {")); + assert!(screen.contains("✓ Script finished")); + assert!(!screen.contains("SCRIPT ·")); + assert!(!screen.contains("┌ command")); + Ok(()) + } + + #[test] + fn script_lifecycle_replaces_running_label_with_outcome() -> Result<()> { + let mut app = test_app(); + app.handle_system_event(SystemEvent::ScriptStarted); + + let (running, _) = render_app(&mut app, 80, 8)?; + assert!(running.contains("◌ Script running")); + + app.handle_system_event(SystemEvent::ScriptExit { code: 0 }); + let (finished, _) = render_app(&mut app, 80, 8)?; + assert!(finished.contains("✓ Script finished")); + assert!(!finished.contains("Script running")); + Ok(()) + } + + #[test] + fn renderer_stays_usable_at_narrow_normal_and_wide_sizes() -> Result<()> { + for width in [40, 80, 120] { + let mut app = test_app(); + app.push_command("/script scripts/a-long-product-script.ts".to_string()); + app.stream( + StreamKind::Stdout, + "finalized block 0x44119d48ae19d342a58828de9fce45f39bb".to_string(), + ); + app.editor.set_text("/session production"); + + let (screen, cursor) = render_app(&mut app, width, 12)?; + + assert!(screen.contains("👤")); + assert!(screen.contains("🌐")); + assert!(screen.contains("📦")); + if width >= 80 { + assert!(screen.contains("TrUAPI signing host")); + assert!(screen.contains("disconnected")); + } + assert!(cursor.x < width); + } + Ok(()) + } + + #[test] + fn host_status_is_rendered_below_the_composer() -> Result<()> { + let mut app = test_app(); + + let (short, short_cursor) = render_app(&mut app, 80, 4)?; + assert_eq!(short_cursor.y, 2); + assert!( + short + .lines() + .nth(3) + .is_some_and(|line| line.contains("TrUAPI")) + ); + + let (medium, medium_cursor) = render_app(&mut app, 80, 5)?; + assert_eq!(medium_cursor.y, 3); + assert!( + medium + .lines() + .nth(4) + .is_some_and(|line| line.contains("TrUAPI")) + ); + + let (normal, normal_cursor) = render_app(&mut app, 80, 7)?; + assert_eq!(normal_cursor.y, 4); + assert_eq!(normal.lines().nth(5), Some("")); + assert!( + normal + .lines() + .nth(6) + .is_some_and(|line| line.contains("TrUAPI")) + ); + Ok(()) + } + + #[test] + fn auto_follow_uses_ratatuis_word_wrapping() -> Result<()> { + let mut app = test_app(); + for index in 0..8 { + app.stream( + StreamKind::Stdout, + format!( + "wrapped row {index} has several words that Ratatui moves between visual lines" + ), + ); + } + app.notice(NoticeTone::Success, "Newest result".to_string(), None); + + let (screen, _) = render_app(&mut app, 40, 10)?; + assert!(screen.contains("Newest result")); + Ok(()) + } + + #[test] + fn approval_text_is_sanitized_before_storage() { + let mut app = test_app(); + let (response, _answer) = oneshot::channel(); + app.handle_event(UiEvent::Approval { + action: "\u{1b}[31msign request\u{1b}[0m".to_string(), + detail: "\u{1b}]0;unsafe title\u{7}safe detail".to_string(), + response, + }); + + let transcript = app.transcript_text(); + assert!(transcript.contains("sign request")); + assert!(transcript.contains("safe detail")); + assert!(!transcript.contains('\u{1b}')); + } + + #[test] + fn sso_request_and_response_collapse_to_one_human_row() { + let mut app = test_app(); + app.handle_sso_event(SsoEvent { + kind: Some("request_received".to_string()), + request: Some("get_account_alias".to_string()), + statement_request_id: Some("req:1".to_string()), + remote_message_id: Some("msg:1".to_string()), + ..SsoEvent::default() + }); + app.handle_sso_event(SsoEvent { + kind: Some("response_sent".to_string()), + request: Some("get_account_alias".to_string()), + statement_request_id: Some("req:1".to_string()), + response_message_id: Some("msg:2".to_string()), + elapsed_ms: Some(84), + ..SsoEvent::default() + }); + + assert_eq!(app.entries.len(), 1); + let transcript = app.transcript_text(); + assert!(transcript.contains("✓ Get account alias · 84 ms")); + assert!(transcript.contains("request req:1 · message msg:2")); + } + + #[test] + fn streaming_sso_summary_keeps_ids_as_one_detail_line() { + let text = sso_event_text(SsoEvent { + kind: Some("response_sent".to_string()), + request: Some("resource_allocation".to_string()), + statement_request_id: Some("yiBKUPOF".to_string()), + response_message_id: Some("yiBKUPOF:response".to_string()), + outcome: Some("ok".to_string()), + elapsed_ms: Some(901), + fallback_summary: Some("unused fallback".to_string()), + ..SsoEvent::default() + }); + + assert_eq!( + text, + "✓ Resource allocation · 901 ms\n request yiBKUPOF · message yiBKUPOF:response" + ); + assert!(!text.contains("statement_request_id=")); + assert!(!text.contains("responding_to=")); + } + + #[test] + fn rejected_resource_allocation_is_visibly_failed_with_its_reason() { + let mut app = test_app(); + app.handle_sso_event(SsoEvent { + kind: Some("request_received".to_string()), + request: Some("resource_allocation".to_string()), + statement_request_id: Some("UaLEyWid".to_string()), + remote_message_id: Some("UaLEyWid".to_string()), + ..SsoEvent::default() + }); + app.handle_sso_event(SsoEvent { + kind: Some("response_sent".to_string()), + request: Some("resource_allocation".to_string()), + statement_request_id: Some("UaLEyWid".to_string()), + response_message_id: Some("UaLEyWid:response".to_string()), + outcome: Some("rejected".to_string()), + elapsed_ms: Some(63_583), + reason: Some( + "Requested resource was rejected: timed out waiting for Bulletin authorization" + .to_string(), + ), + ..SsoEvent::default() + }); + + let transcript = app.transcript_text(); + assert!(transcript.contains("× Resource allocation rejected · 63583 ms")); + assert!(transcript.contains( + "Requested resource was rejected: timed out waiting for Bulletin authorization" + )); + assert!(transcript.contains("request UaLEyWid · message UaLEyWid:response")); + assert!(!transcript.contains("✓ Resource allocation")); + } + + #[test] + fn partial_and_unavailable_sso_results_use_warning_presentation() { + assert_eq!( + sso_activity_presentation( + "Resource allocation".to_string(), + "response_sent", + Some("partial") + ), + ( + "Resource allocation partially completed".to_string(), + ActivityState::Warning + ) + ); + assert_eq!( + sso_activity_presentation( + "Resource allocation".to_string(), + "response_sent", + Some("not_available") + ), + ( + "Resource allocation unavailable".to_string(), + ActivityState::Warning + ) + ); + } + + #[test] + fn child_output_sanitizer_removes_terminal_control_sequences() { + assert_eq!( + sanitize_terminal_text("\u{1b}[31mred\u{1b}[0m\u{1b}]0;title\u{7}"), + "red" + ); + } + + #[test] + fn composer_surface_blends_for_dark_and_light_terminals() { + assert_eq!(blended_surface((0, 0, 0)), (30, 30, 30)); + assert_eq!(blended_surface((255, 255, 255)), (244, 244, 244)); + assert_eq!(ansi256_to_rgb(16), Some((0, 0, 0))); + assert_eq!(ansi256_to_rgb(231), Some((255, 255, 255))); + assert_eq!(rgb_to_ansi256(255, 255, 255), 231); + } + + fn render_app(app: &mut App, width: u16, height: u16) -> Result<(String, Position)> { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|frame| render(frame, app))?; + let cursor = terminal.backend().cursor_position(); + let screen = terminal + .backend() + .buffer() + .content() + .chunks(usize::from(width)) + .map(|row| { + row.iter() + .map(|cell| cell.symbol()) + .collect::>() + .join("") + .trim_end() + .to_string() + }) + .collect::>() + .join("\n"); + Ok((screen, cursor)) + } + + fn line_text(line: Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } +} diff --git a/rust/crates/truapi-host-cli/tests/signing_host_cli.rs b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs new file mode 100644 index 00000000..d4badeaf --- /dev/null +++ b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs @@ -0,0 +1,191 @@ +//! Process-boundary smoke tests for signing-host invocation modes. + +use std::process::{Command, Stdio}; + +fn command() -> Command { + Command::new(env!("CARGO_BIN_EXE_truapi-host")) +} + +#[test] +fn interactive_mode_rejects_non_tty_stdio_with_usage_exit() { + let output = command() + .args(["signing-host", "--frame-listen", "127.0.0.1:0"]) + .stdin(Stdio::null()) + .output() + .expect("run signing-host without a TTY"); + + assert_eq!(output.status.code(), Some(2)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("interactive signing-host requires a TTY") + ); + assert!(!output.stdout.contains(&0x1b)); +} + +#[test] +fn exec_help_is_plain_and_exits_successfully() { + let base_path = + std::env::temp_dir().join(format!("truapi-host-cli-exec-help-{}", std::process::id())); + let output = command() + .args(["signing-host", "--frame-listen", "127.0.0.1:0"]) + .arg("--base-path") + .arg(&base_path) + .args(["exec", "/help"]) + .stdin(Stdio::null()) + .output() + .expect("run signing-host exec /help"); + let _ = std::fs::remove_dir_all(base_path); + + assert!(output.status.success()); + assert!(!String::from_utf8_lossy(&output.stdout).contains("/whoami")); + assert!(String::from_utf8_lossy(&output.stdout).contains("/script")); + assert!(String::from_utf8_lossy(&output.stdout).contains("/copy")); + assert!(String::from_utf8_lossy(&output.stdout).contains("/product")); + assert!(String::from_utf8_lossy(&output.stdout).contains("/session")); + assert!(!output.stdout.contains(&0x1b)); + assert!(!output.stderr.contains(&0x1b)); +} + +#[test] +fn exec_product_reports_the_normalized_selected_product() { + let temporary = tempfile::tempdir().expect("create temporary session root"); + let output = command() + .args([ + "signing-host", + "--frame-listen", + "127.0.0.1:0", + "--product-id", + "Dotli.DOT", + ]) + .arg("--base-path") + .arg(temporary.path()) + .args(["exec", "/product"]) + .stdin(Stdio::null()) + .output() + .expect("run signing-host exec /product"); + + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("dotli.dot")); + assert!(!output.stdout.contains(&0x1b)); + assert!(!output.stderr.contains(&0x1b)); +} + +#[test] +fn bare_script_in_non_tty_exec_mode_fails_without_opening_an_editor() { + let temporary = tempfile::tempdir().expect("create temporary session root"); + let output = command() + .args(["signing-host", "--frame-listen", "127.0.0.1:0"]) + .arg("--base-path") + .arg(temporary.path()) + .args(["exec", "/script"]) + .stdin(Stdio::null()) + .output() + .expect("run bare script without a TTY"); + + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("/script without a path requires an interactive terminal") + ); + assert!( + !temporary + .path() + .join("paseo-next-v2/signing-host/scripts") + .exists() + ); +} + +#[test] +fn startup_session_is_reported_and_restored() { + let temporary = tempfile::tempdir().expect("create temporary session root"); + let base_path = temporary.path(); + let first = command() + .args(["signing-host", "--frame-listen", "127.0.0.1:0"]) + .arg("--base-path") + .arg(base_path) + .args(["--session", "alice", "exec", "/session"]) + .stdin(Stdio::null()) + .output() + .expect("run signing-host in alice session"); + assert!(first.status.success()); + let first_stdout = String::from_utf8_lossy(&first.stdout); + assert!(first_stdout.contains("Session alice")); + assert!(first_stdout.contains("alice_signing_host")); + assert!(first_stdout.contains("User ")); + assert!(first_stdout.contains("No connected user")); + assert!(first_stdout.contains("Use /session to start a session.")); + + let restored = command() + .args(["signing-host", "--frame-listen", "127.0.0.1:0"]) + .arg("--base-path") + .arg(base_path) + .args(["exec", "/session --list"]) + .stdin(Stdio::null()) + .output() + .expect("restore signing-host session"); + assert!(restored.status.success()); + let restored_stdout = String::from_utf8_lossy(&restored.stdout); + assert!(restored_stdout.contains("* alice")); + assert!(!restored_stdout.contains("default")); + assert!(!restored.stdout.contains(&0x1b)); +} + +#[test] +fn default_session_is_not_user_selectable() { + let temporary = tempfile::tempdir().expect("create temporary session root"); + let output = command() + .args(["signing-host", "--frame-listen", "127.0.0.1:0"]) + .arg("--base-path") + .arg(temporary.path()) + .args(["exec", "/session default"]) + .stdin(Stdio::null()) + .output() + .expect("reject the internal default session"); + + assert_eq!(output.status.code(), Some(2)); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("session name `default` is reserved for bootstrap state") + ); +} + +#[test] +fn existing_local_signer_is_activated_and_cached_at_startup() { + let temporary = tempfile::tempdir().expect("create temporary session root"); + let base_path = temporary.path(); + std::fs::write( + base_path.join("accounts.json"), + r#"{ + "version": 1, + "accounts": [{ + "name": "auto-1", + "network": "paseo-next-v2", + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "lite_username": "cachedalice.01", + "public_key_hex": "0x00", + "address": "5GrwvaEF5zXb26Fz9rcQpDWSKfwVwqNxyvE9uZunJMtBEw2s", + "created_at_unix": 1, + "attested": true + }] +}"#, + ) + .expect("seed local account store"); + + let output = command() + .args(["signing-host", "--frame-listen", "127.0.0.1:0"]) + .arg("--base-path") + .arg(base_path) + .args(["exec", "/session"]) + .stdin(Stdio::null()) + .output() + .expect("run signing-host with a cached signer"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Signing host ready")); + assert!(stdout.contains("User cachedalice.01")); + let metadata = std::fs::read_to_string( + base_path.join("paseo-next-v2/cachedalice.01_signing_host/session.json"), + ) + .expect("read persisted session identity"); + assert!(metadata.contains("cachedalice.01")); +} diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 6de95ba5..7846d55f 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -251,10 +251,85 @@ pub enum RuntimeConfigValidationError { }, } +const PRODUCT_STORAGE_KEY_PREFIX: &str = "truapi:product-storage:v1:"; + +/// Decoded product scope and product-owned key used by [`ProductStorage`]. +/// +/// The string representation remains the host callback ABI. Native hosts that +/// need separate backing stores can decode it without duplicating the wire +/// format. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProductStorageKey { + product_id: String, + key: String, +} + +impl ProductStorageKey { + /// Build a key with a validated, normalized product id. + pub fn new( + product_id: &str, + key: impl Into, + ) -> Result { + Ok(Self { + product_id: normalize_product_identifier(product_id)?, + key: key.into(), + }) + } + + /// Decode the opaque key passed through [`ProductStorage`]. + pub fn decode(value: &str) -> Result { + let remainder = value + .strip_prefix(PRODUCT_STORAGE_KEY_PREFIX) + .ok_or_else(|| "product storage key has an unknown format".to_string())?; + let (length, scoped) = remainder + .split_once(':') + .ok_or_else(|| "product storage key is missing its scope length".to_string())?; + let product_length = length + .parse::() + .map_err(|_| "product storage key has an invalid scope length".to_string())?; + let product_id = scoped + .get(..product_length) + .ok_or_else(|| "product storage key has a truncated product id".to_string())?; + let separator = scoped + .as_bytes() + .get(product_length) + .copied() + .ok_or_else(|| "product storage key is missing its key separator".to_string())?; + if separator != b':' { + return Err("product storage key has an invalid key separator".to_string()); + } + let key = scoped + .get(product_length + 1..) + .ok_or_else(|| "product storage key splits a UTF-8 character".to_string())?; + Self::new(product_id, key.to_string()).map_err(|error| error.to_string()) + } + + /// Product identifier owning this storage key. + pub fn product_id(&self) -> &str { + &self.product_id + } + + /// Product-local key without the host scope prefix. + pub fn key(&self) -> &str { + &self.key + } + + /// Encode the stable opaque key used by existing host callbacks. + pub fn encode(&self) -> String { + format!( + "{PRODUCT_STORAGE_KEY_PREFIX}{}:{}:{}", + self.product_id.len(), + self.product_id, + self.key + ) + } +} + /// 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. +/// implementations may treat `key` as opaque or decode it with +/// [`ProductStorageKey`] when their physical storage is separated by product. #[async_trait] pub trait ProductStorage: Send + Sync { /// Read a value by key. diff --git a/rust/crates/truapi-platform/tests/bounds.rs b/rust/crates/truapi-platform/tests/bounds.rs index e6973351..3aba26ca 100644 --- a/rust/crates/truapi-platform/tests/bounds.rs +++ b/rust/crates/truapi-platform/tests/bounds.rs @@ -4,7 +4,7 @@ use truapi_platform::{ HostInfo, HostRuntimeConfig, PairingHostConfig, Platform, PlatformInfo, ProductContext, - RuntimeConfigValidationError, + ProductStorageKey, RuntimeConfigValidationError, }; fn _assert_platform_bounds() {} @@ -134,3 +134,15 @@ fn product_context_validation_cases() { }) ); } + +#[test] +fn product_storage_key_round_trips_scopes_and_arbitrary_keys() { + let key = ProductStorageKey::new("Tést.DOT", "settings:theme").expect("valid product key"); + let encoded = key.encode(); + let decoded = ProductStorageKey::decode(&encoded).expect("decode product key"); + + assert_eq!(decoded.product_id(), "tést.dot"); + assert_eq!(decoded.key(), "settings:theme"); + assert_eq!(decoded, key); + assert!(ProductStorageKey::decode("unknown:key").is_err()); +} diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index d1e8ccc5..2e684951 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -118,6 +118,33 @@ impl PairingHostRuntime { self.pairing_host.disconnect().await; } + /// Log out and discard the old pairing keypair. + /// + /// The next product login request generates a fresh pairing identity and + /// presents a new deeplink suitable for another signing host. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.logout"))] + pub async fn logout(&self) -> Result<(), v01::GenericError> { + self.pairing_host + .logout_and_reset_pairing() + .await + .map_err(|reason| v01::GenericError { reason }) + } + + /// Start or join the pairing-host login flow for one product. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.login", %product_id))] + pub async fn login( + &self, + product_id: &str, + ) -> Result { + let product = product_context(product_id)?; + match self.pairing_host.request_login(&product).await { + Ok(truapi::versioned::account::HostRequestLoginResponse::V1(response)) => Ok(response), + Err(error) => Err(v01::GenericError { + reason: pairing_login_error_reason(error), + }), + } + } + /// Cancel an in-flight SSO pairing request. A no-op when no pairing is /// active. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.cancel_pairing"))] @@ -170,6 +197,20 @@ impl PairingHostRuntime { } } +fn pairing_login_error_reason( + error: truapi::CallError, +) -> String { + match error { + truapi::CallError::Domain(truapi::versioned::account::HostRequestLoginError::V1( + v01::HostRequestLoginError::Unknown { reason }, + )) + | truapi::CallError::HostFailure { reason } + | truapi::CallError::MalformedFrame { reason } => reason, + truapi::CallError::Denied => "login denied".to_string(), + truapi::CallError::Unsupported => "login unsupported".to_string(), + } +} + impl PairingHostAdmin for PairingHostRuntime { fn cancel_pairing(&self) { PairingHostRuntime::cancel_pairing(self); diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index c012751d..3420b837 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -154,8 +154,8 @@ use truapi_platform::Platform; use truapi_platform::{ AccountAccessReview, CreateTransactionReview, IdentityDisclosureReview, PermissionAuthorizationRequest, PermissionAuthorizationStatus, PreimageSubmitReview, - ProductContext, ResourceAllocationReview, SessionUiInfo, SignPayloadReview, SignRawReview, - UserConfirmationReview, normalize_product_identifier, + ProductContext, ProductStorageKey, ResourceAllocationReview, SessionUiInfo, SignPayloadReview, + SignRawReview, UserConfirmationReview, normalize_product_identifier, }; /// Error reason surfaced to products when a remote permission is not granted. @@ -447,7 +447,9 @@ impl ProductRuntimeHost { } fn product_storage_key(&self, key: String) -> String { - product_storage_key(self.product.product_id.as_str(), &key) + ProductStorageKey::new(self.product.product_id.as_str(), key) + .expect("product runtime context was already validated") + .encode() } fn follow_id(&self, id: &str) -> String { @@ -674,15 +676,6 @@ fn parse_legacy_signer_hex(signer: &str) -> Option<[u8; 32]> { hex::decode(raw).ok()?.try_into().ok() } -fn product_storage_key(product_id: &str, key: &str) -> String { - format!( - "truapi:product-storage:v1:{}:{}:{}", - product_id.len(), - product_id, - key - ) -} - fn runtime_failure_to_call_error(failure: RuntimeFailure) -> CallError { CallError::HostFailure { reason: failure.reason(), @@ -4342,6 +4335,42 @@ mod tests { )); } + #[test] + fn pairing_logout_clears_session_and_bootstrap_identity() { + let platform = Arc::new(StubPlatform::default()); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + host.test_session_state().set_session(sso_session_info()); + { + let mut storage = platform + .local_storage + .lock() + .expect("local storage mutex poisoned"); + storage.insert( + core_storage_test_key(CoreStorageKey::PairingDeviceIdentity), + vec![1, 2, 3], + ); + storage.insert( + core_storage_test_key(CoreStorageKey::LastProcessedPairingStatement), + vec![4, 5, 6], + ); + } + + futures::executor::block_on(pairing_host.logout_and_reset_pairing()).unwrap(); + + assert!(host.test_session_state().current().is_none()); + let storage = platform + .local_storage + .lock() + .expect("local storage mutex poisoned"); + assert!(!storage.contains_key(&core_storage_test_key( + CoreStorageKey::PairingDeviceIdentity + ))); + assert!(!storage.contains_key(&core_storage_test_key( + CoreStorageKey::LastProcessedPairingStatement + ))); + } + #[test] fn disconnect_clears_session_store_and_broadcasts_disconnected() { let platform = Arc::new(StubPlatform::default()); diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index 65964ebd..e265eebd 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -370,6 +370,30 @@ impl PairingHost { } } + /// Disconnect and discard pairing bootstrap material so the next login + /// generates a new device keypair and topic. + pub(crate) async fn logout_and_reset_pairing(&self) -> Result<(), String> { + self.disconnect().await; + self.platform + .clear_core_storage(CoreStorageKey::PairingDeviceIdentity) + .await + .map_err(|error| { + format!( + "session disconnected, but pairing identity reset failed: {}", + error.reason + ) + })?; + self.platform + .clear_core_storage(CoreStorageKey::LastProcessedPairingStatement) + .await + .map_err(|error| { + format!( + "session disconnected and pairing identity reset, but pairing history reset failed: {}", + error.reason + ) + }) + } + /// Invalidate in-flight login attempts and emit the cancelled auth state. #[instrument(skip_all, fields(runtime.method = "account.cancel_login"))] pub(crate) fn cancel_login(&self) { From 95a68df07a62229ae174c8eb54aea54f77d0956a Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 24 Jul 2026 18:09:24 +0200 Subject: [PATCH 18/34] chore(cli): allow Boost license --- deny.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/deny.toml b/deny.toml index 7acc3441..3c039293 100644 --- a/deny.toml +++ b/deny.toml @@ -8,6 +8,7 @@ allow = [ "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", + "BSL-1.0", "CC0-1.0", "CDLA-Permissive-2.0", "ISC", From e36daf9304e8d401f7cdb9d4b7b28b01fcb946b1 Mon Sep 17 00:00:00 2001 From: pg Date: Mon, 27 Jul 2026 17:04:29 +0200 Subject: [PATCH 19/34] Address signing host review feedback --- rust/crates/truapi-platform/src/lib.rs | 188 +++++++++++++++- rust/crates/truapi-platform/tests/bounds.rs | 2 +- .../truapi-server/src/host_logic/alias.rs | 81 ------- .../src/host_logic/attestation.rs | 33 ++- .../truapi-server/src/host_logic/entropy.rs | 2 +- .../src/host_logic/permissions.rs | 176 ++------------- .../src/host_logic/sso/messages.rs | 10 +- .../src/host_logic/sso/pairing.rs | 53 +++-- .../src/host_logic/statement_store.rs | 10 +- .../host_logic/statement_store/statement.rs | 85 ++++++-- .../src/host_logic/transaction.rs | 62 ++++-- rust/crates/truapi-server/src/runtime.rs | 22 +- .../src/runtime/pairing_host/sso_channel.rs | 11 +- .../truapi-server/src/runtime/signing_host.rs | 21 +- .../src/runtime/signing_host/sso_responder.rs | 144 ++++++++++--- .../truapi-server/src/runtime/sso_pairing.rs | 2 +- .../src/runtime/statement_allowance.rs | 204 ++++++++++++------ .../runtime/statement_allowance/extension.rs | 192 ++++++++++++++--- .../runtime/statement_allowance/extrinsic.rs | 15 +- .../src/runtime/statement_allowance/proof.rs | 53 ++++- .../src/runtime/statement_allowance/ring.rs | 131 +++++++---- .../src/runtime/statement_allowance/rpc.rs | 136 +++++++++--- .../src/runtime/statement_allowance/slot.rs | 129 ++++++++--- .../src/runtime/statement_store.rs | 4 +- .../src/runtime/statement_store_rpc.rs | 33 ++- 25 files changed, 1195 insertions(+), 604 deletions(-) delete mode 100644 rust/crates/truapi-server/src/host_logic/alias.rs diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 6de95ba5..2fc9b388 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -21,7 +21,7 @@ use truapi::latest::{ HostNavigateToError, HostPushNotificationRequest, HostPushNotificationResponse, HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, NotificationId, ProductAccountId, - ProductAccountTxPayload, ProductProofContext, RemotePermissionRequest, + ProductAccountTxPayload, ProductProofContext, RemotePermission, RemotePermissionRequest, RemotePermissionResponse, RingLocation, ThemeVariant, }; use url::Url; @@ -115,10 +115,8 @@ impl HostRuntimeConfig { ) -> Result { 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(), - })?; + let parsed = Url::parse(icon) + .map_err(|source| RuntimeConfigValidationError::InvalidHostIcon { source })?; if parsed.scheme() != "https" { return Err(RuntimeConfigValidationError::InsecureHostIcon { scheme: parsed.scheme().to_string(), @@ -226,10 +224,10 @@ pub enum RuntimeConfigValidationError { field: &'static str, }, /// Host icon URL could not be parsed as an absolute HTTPS URL. - #[display("host_info.icon must be an absolute HTTPS URL: {reason}")] + #[display("host_info.icon must be an absolute HTTPS URL: {source}")] InvalidHostIcon { - /// Parse failure reason. - reason: String, + /// Parse failure. + source: url::ParseError, }, /// Host icon URL used a non-HTTPS scheme. #[display("host_info.icon must use https scheme, got {scheme:?}")] @@ -459,6 +457,180 @@ pub enum CoreStorageKey { LastProcessedPairingStatement, } +impl CoreStorageKey { + /// Persisted authorization key for one product-scoped device permission. + pub fn device_permission_authorization( + product_id: &str, + permission: &HostDevicePermissionRequest, + ) -> Self { + Self::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::Device(permission.clone()), + } + } + + /// Persisted authorization key for one product-scoped remote permission. + pub fn remote_permission_authorization( + product_id: &str, + request: &RemotePermissionRequest, + ) -> Self { + Self::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::Remote(canonical_remote_request(request)), + } + } + + /// Persisted authorization key for product-scoped identity disclosure. + pub fn identity_disclosure_authorization(product_id: &str) -> Self { + Self::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::IdentityDisclosure, + } + } + + /// Persisted authorization key for one product accessing another product's + /// account context. + pub fn account_access_authorization(product_id: &str, target_product_id: &str) -> Self { + Self::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::AccountAccess { + target_product_id: target_product_id.to_string(), + }, + } + } +} + +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(|domain| domain.to_ascii_lowercase()) + .collect(); + canonical.sort(); + canonical.dedup(); + RemotePermission::Remote { domains: canonical } + } + other => other.clone(), + }; + RemotePermissionRequest { permission } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn permission_authorization_keys_separate_product_and_request_variants() { + let camera = CoreStorageKey::device_permission_authorization( + "product.dot", + &HostDevicePermissionRequest::Camera, + ); + let other_product = CoreStorageKey::device_permission_authorization( + "other.dot", + &HostDevicePermissionRequest::Camera, + ); + let remote = CoreStorageKey::remote_permission_authorization( + "product.dot", + &RemotePermissionRequest { + permission: RemotePermission::ChainSubmit, + }, + ); + let identity = CoreStorageKey::identity_disclosure_authorization("product.dot"); + let other_product_identity = CoreStorageKey::identity_disclosure_authorization("other.dot"); + let account_access = + CoreStorageKey::account_access_authorization("product.dot", "target.dot"); + let other_target = CoreStorageKey::account_access_authorization("product.dot", "other.dot"); + + assert_ne!(camera, other_product); + assert_ne!(camera, remote); + assert_ne!(camera, identity); + assert_ne!(remote, identity); + assert_ne!(identity, other_product_identity); + assert_ne!(account_access, other_target); + assert_ne!(account_access, camera); + } + + #[test] + fn remote_permission_authorization_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!( + CoreStorageKey::remote_permission_authorization("product.dot", &unsorted), + CoreStorageKey::remote_permission_authorization("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!( + CoreStorageKey::remote_permission_authorization("product.dot", &mixed), + CoreStorageKey::remote_permission_authorization("product.dot", &canonical) + ); + } + + #[test] + fn remote_permission_authorization_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 = + CoreStorageKey::remote_permission_authorization("product.dot", &injecting); + let benign_key = + CoreStorageKey::remote_permission_authorization("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, + CoreStorageKey::remote_permission_authorization("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, + CoreStorageKey::remote_permission_authorization("product.dot", &injecting_reordered) + ); + } +} + /// Host-private persistence for core-owned state. #[async_trait] pub trait CoreStorage: Send + Sync { diff --git a/rust/crates/truapi-platform/tests/bounds.rs b/rust/crates/truapi-platform/tests/bounds.rs index e6973351..9af2ab61 100644 --- a/rust/crates/truapi-platform/tests/bounds.rs +++ b/rust/crates/truapi-platform/tests/bounds.rs @@ -40,7 +40,7 @@ fn runtime_config_validation_cases() { host_name: "Polkadot Web", host_icon: Some("/dotli.png"), expected: Err(RuntimeConfigValidationError::InvalidHostIcon { - reason: "relative URL without a base".to_string(), + source: url::ParseError::RelativeUrlWithoutBase, }), }, TestCase { diff --git a/rust/crates/truapi-server/src/host_logic/alias.rs b/rust/crates/truapi-server/src/host_logic/alias.rs deleted file mode 100644 index 3b6874e1..00000000 --- a/rust/crates/truapi-server/src/host_logic/alias.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Bandersnatch ring-VRF product-account aliases (signing host). -//! -//! Mirrors the mobile app's `ProductAccountHolder.deriveAlias`: the alias is a -//! thin bandersnatch VRF output over a per-product context, using a -//! bandersnatch secret derived from the wallet's BIP-39 entropy. No ring -//! commitment or SRS is involved (that machinery is only for membership -//! proofs, which this path does not use). -//! -//! Reference: polkadot-app-ios-v2 `Packages/Products/.../ProductAccountHolder.swift` -//! and `verifiable-swift` over `paritytech/verifiable`. - -use verifiable::GenerateVerifiable; -use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; - -/// A product-account contextual alias. -pub struct ProductAlias { - /// 32-byte context identifier (blake2b-256 of the derivation path). - pub context: [u8; 32], - /// 32-byte ring-VRF alias output. - pub alias: [u8; 32], -} - -/// Derive the contextual alias for a product account from the wallet entropy. -/// -/// - `context = blake2b_256("/product/{product_id}/{derivation_index}")` -/// - `bandersnatch_entropy = blake2b_256(root_entropy)` -/// - `alias = BandersnatchVrf::alias_in_context(new_secret(bandersnatch_entropy), context)` -pub fn derive_product_alias( - root_entropy: &[u8], - product_id: &str, - derivation_index: u32, -) -> Result { - let derivation_path = format!("/product/{product_id}/{derivation_index}"); - let context = blake2b256(derivation_path.as_bytes()); - let bandersnatch_entropy = blake2b256(root_entropy); - let secret = BandersnatchVrfVerifiable::new_secret(bandersnatch_entropy); - let alias = BandersnatchVrfVerifiable::alias_in_context(&secret, &context) - .map_err(|err| format!("ring-VRF alias derivation failed: {err:?}"))?; - Ok(ProductAlias { context, alias }) -} - -fn blake2b256(message: &[u8]) -> [u8; 32] { - blake2b_simd::Params::new() - .hash_length(32) - .hash(message) - .as_bytes() - .try_into() - .expect("BLAKE2b-256 returns 32 bytes") -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The alias is deterministic in the entropy, product id, and index, and - /// the context is the blake2b-256 of the derivation path. - #[test] - fn alias_is_deterministic_with_expected_context() { - let entropy = [0xABu8; 16]; - let first = derive_product_alias(&entropy, "truapi-playground.dot", 0).unwrap(); - let again = derive_product_alias(&entropy, "truapi-playground.dot", 0).unwrap(); - - assert_eq!(first.context, again.context); - assert_eq!(first.alias, again.alias); - assert_eq!( - first.context, - blake2b256(b"/product/truapi-playground.dot/0") - ); - } - - #[test] - fn alias_varies_by_product_and_index() { - let entropy = [0xABu8; 16]; - let base = derive_product_alias(&entropy, "a.dot", 0).unwrap(); - let other_product = derive_product_alias(&entropy, "b.dot", 0).unwrap(); - let other_index = derive_product_alias(&entropy, "a.dot", 1).unwrap(); - - assert_ne!(base.alias, other_product.alias); - assert_ne!(base.alias, other_index.alias); - } -} diff --git a/rust/crates/truapi-server/src/host_logic/attestation.rs b/rust/crates/truapi-server/src/host_logic/attestation.rs index f0ef86ee..195882fb 100644 --- a/rust/crates/truapi-server/src/host_logic/attestation.rs +++ b/rust/crates/truapi-server/src/host_logic/attestation.rs @@ -11,13 +11,16 @@ //! paired host resolves the username from `Resources.Consumers[that account]`. use parity_scale_codec::{Decode, Encode}; +use thiserror::Error; +use verifiable::Error as VerifiableError; use verifiable::GenerateVerifiable; use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; use crate::host_logic::product_account::{ - SR25519_SIGNING_CONTEXT, derive_sr25519_hard_path, product_public_key_to_address, + ProductAccountError, SR25519_SIGNING_CONTEXT, derive_sr25519_hard_path, + product_public_key_to_address, }; -use crate::host_logic::sso::pairing::derive_p256_keypair_from_entropy; +use crate::host_logic::sso::pairing::{PairingBootstrapError, derive_p256_keypair_from_entropy}; /// sr25519 proof-of-ownership message prefix (exact bytes; one space). const REGISTER_PREFIX: &[u8] = b"pop:people-lite:register using"; @@ -55,17 +58,30 @@ pub struct LiteRegistration { pub consumer_registration_signature: [u8; 64], } +/// Error while building lite-person registration parameters. +#[derive(Debug, Error)] +pub enum LiteRegistrationError { + /// Candidate statement-account derivation failed. + #[error("//wallet//sso derivation failed: {0}")] + CandidateDerivation(#[from] ProductAccountError), + /// Ring-VRF proof-of-ownership failed. + #[error("ring-VRF proof-of-ownership failed: {0:?}")] + ProofOfOwnership(VerifiableError), + /// P-256 identifier key derivation failed. + #[error("identifier key derivation failed: {0}")] + IdentifierKey(#[from] PairingBootstrapError), +} + /// Build the lite-person registration parameters for `username_base` /// (6+ lowercase letters, no digit suffix) against the backend `verifier`. pub fn build_lite_registration( entropy: &[u8], verifier_account_id: [u8; 32], username_base: &str, -) -> Result { +) -> Result { // The candidate is the `//wallet//sso` statement account, matching the // account the SSO responder presents as `identity_account_id`. - let candidate = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) - .map_err(|err| format!("//wallet//sso derivation failed: {err}"))?; + let candidate = derive_sr25519_hard_path(entropy, &["wallet", "sso"])?; let candidate_public_key = candidate.public.to_bytes(); let vrf_entropy = blake2b256(entropy); @@ -82,11 +98,10 @@ pub fn build_lite_registration( .sign_simple(SR25519_SIGNING_CONTEXT, &proof_message, &candidate.public) .to_bytes(); let proof_of_ownership = BandersnatchVrfVerifiable::sign(&vrf_secret, &proof_message) - .map_err(|err| format!("ring-VRF proof-of-ownership failed: {err:?}"))?; + .map_err(LiteRegistrationError::ProofOfOwnership)?; let (_identifier_secret, identifier_key) = - derive_p256_keypair_from_entropy(entropy, IDENTIFIER_KEY_LABEL) - .map_err(|err| format!("identifier key derivation failed: {err}"))?; + derive_p256_keypair_from_entropy(entropy, IDENTIFIER_KEY_LABEL)?; let consumer_message = ConsumerRegistrationSigningPayload { account: candidate_public_key, @@ -122,7 +137,7 @@ fn blake2b256(message: &[u8]) -> [u8; 32] { .hash(message) .as_bytes() .try_into() - .expect("BLAKE2b-256 returns 32 bytes") + .expect("hash_length(32) configures BLAKE2b output to exactly 32 bytes; qed") } #[cfg(test)] diff --git a/rust/crates/truapi-server/src/host_logic/entropy.rs b/rust/crates/truapi-server/src/host_logic/entropy.rs index 49eb386d..318d75de 100644 --- a/rust/crates/truapi-server/src/host_logic/entropy.rs +++ b/rust/crates/truapi-server/src/host_logic/entropy.rs @@ -59,7 +59,7 @@ fn blake2b256_keyed(message: &[u8], key: &[u8]) -> [u8; 32] { .hash(message) .as_bytes() .try_into() - .expect("BLAKE2b-256 returns 32 bytes") + .expect("hash_length(32) configures BLAKE2b output to exactly 32 bytes; qed") } #[cfg(test)] diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs index 7db47cdf..443666b3 100644 --- a/rust/crates/truapi-server/src/host_logic/permissions.rs +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -14,7 +14,7 @@ use parity_scale_codec::{Decode, Encode}; use truapi::latest::{ - GenericError, HostDevicePermissionRequest, HostDevicePermissionResponse, RemotePermission, + GenericError, HostDevicePermissionRequest, HostDevicePermissionResponse, RemotePermissionRequest, RemotePermissionResponse, }; use truapi_platform::{ @@ -78,7 +78,7 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a ) -> Result { authorization_status( self.storage, - device_core_storage_key(self.product_id, permission), + CoreStorageKey::device_permission_authorization(self.product_id, permission), ) .await } @@ -91,7 +91,7 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a ) -> Result { authorization_status( self.storage, - remote_core_storage_key(self.product_id, request), + CoreStorageKey::remote_permission_authorization(self.product_id, request), ) .await } @@ -110,14 +110,17 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a PermissionAuthorizationRequest::IdentityDisclosure => { authorization_status( self.storage, - identity_disclosure_core_storage_key(self.product_id), + CoreStorageKey::identity_disclosure_authorization(self.product_id), ) .await } PermissionAuthorizationRequest::AccountAccess { target_product_id } => { authorization_status( self.storage, - account_access_core_storage_key(self.product_id, target_product_id), + CoreStorageKey::account_access_authorization( + self.product_id, + target_product_id, + ), ) .await } @@ -148,16 +151,16 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a ) -> Result<(), GenericError> { let key = match request { PermissionAuthorizationRequest::Device(permission) => { - device_core_storage_key(self.product_id, permission) + CoreStorageKey::device_permission_authorization(self.product_id, permission) } PermissionAuthorizationRequest::Remote(request) => { - remote_core_storage_key(self.product_id, request) + CoreStorageKey::remote_permission_authorization(self.product_id, request) } PermissionAuthorizationRequest::IdentityDisclosure => { - identity_disclosure_core_storage_key(self.product_id) + CoreStorageKey::identity_disclosure_authorization(self.product_id) } PermissionAuthorizationRequest::AccountAccess { target_product_id } => { - account_access_core_storage_key(self.product_id, target_product_id) + CoreStorageKey::account_access_authorization(self.product_id, target_product_id) } }; set_authorization_status(self.storage, key, status).await @@ -169,7 +172,7 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a &self, permission: HostDevicePermissionRequest, ) -> Result { - let key = device_core_storage_key(self.product_id, &permission); + let key = CoreStorageKey::device_permission_authorization(self.product_id, &permission); if let Some(cached) = peek_stored(self.storage, key.clone()).await? { return Ok(cached.into()); } @@ -189,7 +192,7 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a &self, request: RemotePermissionRequest, ) -> Result { - let key = remote_core_storage_key(self.product_id, &request); + let key = CoreStorageKey::remote_permission_authorization(self.product_id, &request); if let Some(cached) = peek_stored(self.storage, key.clone()).await? { return Ok(cached.into()); } @@ -254,62 +257,13 @@ fn status_into_stored(status: PermissionAuthorizationStatus) -> Option 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 identity_disclosure_core_storage_key(product_id: &str) -> CoreStorageKey { - CoreStorageKey::PermissionAuthorization { - product_id: product_id.to_string(), - request: PermissionAuthorizationRequest::IdentityDisclosure, - } -} - -fn account_access_core_storage_key(product_id: &str, target_product_id: &str) -> CoreStorageKey { - CoreStorageKey::PermissionAuthorization { - product_id: product_id.to_string(), - request: PermissionAuthorizationRequest::AccountAccess { - target_product_id: target_product_id.to_string(), - }, - } -} - -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::latest::RemotePermission; use truapi::v01; use truapi::v01::GenericError; @@ -393,101 +347,6 @@ mod tests { } } - #[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, - }, - ); - let identity = identity_disclosure_core_storage_key("product.dot"); - let other_product_identity = identity_disclosure_core_storage_key("other.dot"); - - assert_ne!(camera, other_product); - assert_ne!(camera, remote); - assert_ne!(camera, identity); - assert_ne!(remote, identity); - assert_ne!(identity, other_product_identity); - } - - #[test] - 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(); @@ -770,7 +629,10 @@ mod tests { 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), + CoreStorageKey::device_permission_authorization( + "product.dot", + &HostDevicePermissionRequest::Camera, + ), vec![0xff, 0xfe, 0xfd], )) .unwrap(); 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 26d5ddd9..d4473131 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -1053,7 +1053,7 @@ mod tests { fn raw_sign_request_uses_remote_message_variant_indices() { let message = sign_raw_message( "m1".to_string(), - truapi::v01::HostSignRawRequest { + truapi::latest::HostSignRawRequest { account: account(), payload: RawPayload::Bytes { bytes: vec![0xde, 0xad], @@ -1294,7 +1294,7 @@ mod tests { #[test] fn option_bool_matches_host_papp_option_bool_encoding() { - let mut request = truapi::v01::HostSignPayloadRequest { + let mut request = truapi::latest::HostSignPayloadRequest { account: account(), payload: HostSignPayloadData { block_hash: vec![], @@ -1361,7 +1361,7 @@ mod tests { let session = session(); let remote_message = sign_raw_message( "remote-1".to_string(), - truapi::v01::HostSignRawRequest { + truapi::latest::HostSignRawRequest { account: account(), payload: RawPayload::Payload { payload: "hello".to_string(), @@ -1401,7 +1401,7 @@ mod tests { let session = session(); let remote_message = sign_raw_message( "remote-1".to_string(), - truapi::v01::HostSignRawRequest { + truapi::latest::HostSignRawRequest { account: account(), payload: RawPayload::Payload { payload: "hello".to_string(), @@ -1477,7 +1477,7 @@ mod tests { let (host_session, responder_session) = host_and_responder_sessions(); let request = sign_raw_message( "remote-1".to_string(), - truapi::v01::HostSignRawRequest { + truapi::latest::HostSignRawRequest { account: account(), payload: RawPayload::Payload { payload: "hello".to_string(), 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 1918a26f..46770a67 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -19,7 +19,7 @@ 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 schnorrkel::{ExpansionMode, MiniSecretKey, SignatureError}; use sha2::Sha256; use thiserror::Error; use truapi_platform::PairingHostConfig; @@ -70,14 +70,31 @@ pub struct PairingDeviceIdentity { /// Errors that can occur while generating pairing bootstrap material. #[derive(Debug, Error, PartialEq, Eq)] pub enum PairingBootstrapError { - /// The platform randomness source or sr25519 key expansion failed. + /// The platform randomness source failed. #[error("failed to generate random pairing material: {0}")] - Random(String), + Random(#[source] getrandom::Error), + /// The generated sr25519 seed could not be expanded. + #[error("failed to expand sr25519 pairing key: {0:?}")] + Sr25519Key(SignatureError), /// No valid P-256 secret was found within the attempt budget. #[error("failed to generate P-256 pairing key")] InvalidP256Secret, } +/// Error while decoding a pairing deeplink or bare handshake payload. +#[derive(Debug, Error)] +pub enum PairingDeeplinkDecodeError { + /// The `handshake` payload is not valid hex. + #[error("invalid pairing deeplink hex: {0}")] + InvalidHex(#[source] hex::FromHexError), + /// The decoded bytes are not a valid handshake proposal. + #[error("invalid pairing handshake proposal: {0}")] + InvalidHandshakeProposal(#[source] parity_scale_codec::Error), + /// The proposal decoded successfully but extra bytes remained. + #[error("invalid pairing handshake proposal: trailing bytes")] + TrailingBytes, +} + /// Versioned SCALE payload embedded in the pairing deeplink. /// /// Host-spec B.1.1 defines the deeplink as lowercase hex of this SCALE payload: @@ -135,18 +152,20 @@ pub enum SsoStatementData { /// Decode a pairing deeplink (or its bare handshake hex) into the advertised /// handshake proposal. Inverse of [`build_pairing_deeplink`]. -pub fn decode_pairing_deeplink(deeplink: &str) -> Result { +pub fn decode_pairing_deeplink( + deeplink: &str, +) -> Result { let hex_payload = match deeplink.split_once("?handshake=") { Some((_, hex_payload)) => hex_payload, None => deeplink, }; - let encoded = hex::decode(hex_payload.trim()) - .map_err(|err| format!("invalid pairing deeplink hex: {err}"))?; + let encoded = + hex::decode(hex_payload.trim()).map_err(PairingDeeplinkDecodeError::InvalidHex)?; let mut input = encoded.as_slice(); let proposal = VersionedHandshakeProposal::decode(&mut input) - .map_err(|err| format!("invalid pairing handshake proposal: {err}"))?; + .map_err(PairingDeeplinkDecodeError::InvalidHandshakeProposal)?; if !input.is_empty() { - return Err("invalid pairing handshake proposal: trailing bytes".to_string()); + return Err(PairingDeeplinkDecodeError::TrailingBytes); } Ok(proposal) } @@ -479,7 +498,7 @@ fn blake2b256_keyed(message: &[u8], key: &[u8]) -> [u8; 32] { .hash(message) .as_bytes() .try_into() - .expect("BLAKE2b-256 returns 32 bytes") + .expect("hash_length(32) configures BLAKE2b output to exactly 32 bytes; qed") } /// Create one-shot pairing bootstrap material from runtime config. @@ -592,10 +611,9 @@ pub fn bootstrap_topic( 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()))?; + getrandom::getrandom(&mut seed).map_err(PairingBootstrapError::Random)?; + let mini_secret = + MiniSecretKey::from_bytes(&seed).map_err(PairingBootstrapError::Sr25519Key)?; let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); Ok((keypair.secret.to_bytes(), keypair.public.to_bytes())) } @@ -603,8 +621,7 @@ fn generate_statement_store_keypair() -> Result<([u8; 64], [u8; 32]), PairingBoo 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()))?; + getrandom::getrandom(&mut candidate).map_err(PairingBootstrapError::Random)?; let Ok(secret) = SecretKey::from_slice(&candidate) else { continue; }; @@ -861,7 +878,11 @@ mod tests { let err = decode_pairing_deeplink(&format!("{deeplink}00")).unwrap_err(); - assert_eq!(err, "invalid pairing handshake proposal: trailing bytes"); + assert!(matches!(err, PairingDeeplinkDecodeError::TrailingBytes)); + assert_eq!( + err.to_string(), + "invalid pairing handshake proposal: trailing bytes" + ); } #[test] 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 8431f241..efd163f9 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store.rs @@ -19,11 +19,11 @@ pub use rpc::{ }; 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_public_key_from_secret, statement_signing_payload, + StatementField, StatementProof, StatementSigningPayloadError, 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_public_key_from_secret, statement_signing_payload, unsigned_statement_signing_payload, validate_unsigned_statement_signing_payload, }; 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 4ae778dd..7a644b61 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 @@ -1,5 +1,6 @@ use parity_scale_codec::{Compact, Decode, Encode}; use schnorrkel::{PublicKey, Signature}; +use thiserror::Error; use truapi::v01; use super::StatementStoreParseError; @@ -7,6 +8,32 @@ use crate::host_logic::extrinsic::sr25519_secret_from_bytes; use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; use crate::host_logic::session::SsoSessionInfo; +/// Error validating an exact unsigned statement-store signing payload. +#[derive(Debug, Error)] +pub enum StatementSigningPayloadError { + /// Payload bytes are not a sequence of SCALE statement fields. + #[error("invalid statement signing payload: {0}")] + InvalidScale(#[source] parity_scale_codec::Error), + /// Payload contains no fields. + #[error("statement signing payload has no fields")] + Empty, + /// Payload contains a proof field, but only unsigned payloads are accepted. + #[error("statement signing payload must not include proof")] + ContainsProof, + /// Payload contains the same singleton field more than once. + #[error("statement signing payload has duplicate {field}")] + DuplicateField { + /// Duplicated field name. + field: &'static str, + }, + /// Topic fields are not Topic1, Topic2, ... without gaps. + #[error("statement signing payload topics are not contiguous")] + NonContiguousTopics, + /// Payload decodes to a statement but is not the canonical field encoding. + #[error("statement signing payload is not canonical")] + NonCanonical, +} + /// Verified statement payload plus the sr25519 signer recovered from proof. /// /// SSO receivers verify incoming statement proofs against the paired @@ -246,15 +273,21 @@ pub fn unsigned_statement_signing_payload( /// without the surrounding SCALE vector length. Accept only payloads that /// round-trip from the public unsigned statement shape, so a paired peer cannot /// reuse the statement-signing path as a generic product-account signing oracle. -pub fn validate_unsigned_statement_signing_payload(payload: &[u8]) -> Result<(), String> { +pub fn validate_unsigned_statement_signing_payload( + payload: &[u8], +) -> Result<(), StatementSigningPayloadError> { let fields = decode_unsigned_statement_signing_payload_fields(payload)?; if fields.is_empty() { - return Err("statement signing payload has no fields".to_string()); + return Err(StatementSigningPayloadError::Empty); } let statement = unsigned_statement_from_fields(fields)?; - let canonical = unsigned_statement_signing_payload(statement_fields_from_v01(statement)?)?; + let fields = statement_fields_from_v01(statement) + .expect("unsigned_statement_from_fields emits at most four topics and no proof; qed"); + let canonical = unsigned_statement_signing_payload(fields).expect( + "statement_fields_from_v01 emitted unsigned fields, and SCALE vector encoding is decodable; qed", + ); if canonical != payload { - return Err("statement signing payload is not canonical".to_string()); + return Err(StatementSigningPayloadError::NonCanonical); } Ok(()) } @@ -271,12 +304,12 @@ pub fn statement_signing_payload(fields: &[StatementField]) -> Result, S fn decode_unsigned_statement_signing_payload_fields( mut input: &[u8], -) -> Result, String> { +) -> Result, StatementSigningPayloadError> { let mut fields = Vec::new(); while !input.is_empty() { fields.push( StatementField::decode(&mut input) - .map_err(|err| format!("invalid statement signing payload: {err}"))?, + .map_err(StatementSigningPayloadError::InvalidScale)?, ); } Ok(fields) @@ -361,7 +394,9 @@ fn verify_statement_proof( Ok(signer) } -fn unsigned_statement_from_fields(fields: Vec) -> Result { +fn unsigned_statement_from_fields( + fields: Vec, +) -> Result { let mut decryption_key = None; let mut expiry = None; let mut channel = None; @@ -371,23 +406,23 @@ fn unsigned_statement_from_fields(fields: Vec) -> Result { - return Err("statement signing payload must not include proof".to_string()); + return Err(StatementSigningPayloadError::ContainsProof); } StatementField::DecryptionKey(value) => { if decryption_key.replace(value).is_some() { - return Err( - "statement signing payload has duplicate decryption key".to_string() - ); + return Err(StatementSigningPayloadError::DuplicateField { + field: "decryption key", + }); } } StatementField::Expiry(value) => { if expiry.replace(value).is_some() { - return Err("statement signing payload has duplicate expiry".to_string()); + return Err(StatementSigningPayloadError::DuplicateField { field: "expiry" }); } } StatementField::Channel(value) => { if channel.replace(value).is_some() { - return Err("statement signing payload has duplicate channel".to_string()); + return Err(StatementSigningPayloadError::DuplicateField { field: "channel" }); } } StatementField::Topic1(value) => push_unsigned_statement_topic(&mut topics, 0, value)?, @@ -396,7 +431,7 @@ fn unsigned_statement_from_fields(fields: Vec) -> Result push_unsigned_statement_topic(&mut topics, 3, value)?, StatementField::Data(value) => { if data.replace(value).is_some() { - return Err("statement signing payload has duplicate data".to_string()); + return Err(StatementSigningPayloadError::DuplicateField { field: "data" }); } } } @@ -416,9 +451,9 @@ fn push_unsigned_statement_topic( topics: &mut Vec<[u8; 32]>, expected_index: usize, value: [u8; 32], -) -> Result<(), String> { +) -> Result<(), StatementSigningPayloadError> { if topics.len() != expected_index { - return Err("statement signing payload topics are not contiguous".to_string()); + return Err(StatementSigningPayloadError::NonContiguousTopics); } topics.push(value); Ok(()) @@ -752,7 +787,11 @@ mod tests { let err = validate_unsigned_statement_signing_payload(&tx_preimage).unwrap_err(); - assert!(err.contains("invalid statement signing payload")); + assert!(matches!(err, StatementSigningPayloadError::InvalidScale(_))); + assert!( + err.to_string() + .contains("invalid statement signing payload") + ); } #[test] @@ -764,10 +803,10 @@ mod tests { })]) .unwrap(); - assert_eq!( + assert!(matches!( validate_unsigned_statement_signing_payload(&payload).unwrap_err(), - "statement signing payload must not include proof" - ); + StatementSigningPayloadError::ContainsProof + )); } #[test] @@ -778,10 +817,10 @@ mod tests { ]) .unwrap(); - assert_eq!( + assert!(matches!( validate_unsigned_statement_signing_payload(&payload).unwrap_err(), - "statement signing payload topics are not contiguous" - ); + StatementSigningPayloadError::NonContiguousTopics + )); } #[test] diff --git a/rust/crates/truapi-server/src/host_logic/transaction.rs b/rust/crates/truapi-server/src/host_logic/transaction.rs index 7fc6e68f..66552c0c 100644 --- a/rust/crates/truapi-server/src/host_logic/transaction.rs +++ b/rust/crates/truapi-server/src/host_logic/transaction.rs @@ -9,17 +9,44 @@ use parity_scale_codec::Encode; use sp_crypto_hashing::blake2_256; +use thiserror::Error; use truapi::latest::{HostSignPayloadData, TxPayloadExtension}; /// Preimages longer than this are hashed before signing. const MAX_SIGNED_PREIMAGE_LEN: usize = 256; +/// Error assembling a Substrate extrinsic signing preimage. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ExtrinsicPayloadError { + /// Payload lists a signed extension this host cannot encode from + /// `HostSignPayloadData`. + #[error( + "unsupported signed extension `{id}`: its encoded fields are not present in HostSignPayloadData" + )] + UnsupportedSignedExtension { + /// Extension identifier from `signed_extensions`. + id: String, + }, + /// `CheckMetadataHash.mode` is SCALE-encoded as a single byte. + #[error("CheckMetadataHash mode {mode} does not fit in a u8")] + MetadataHashModeOutOfRange { + /// Supplied mode value. + mode: u32, + }, + /// `CheckMetadataHash.metadata_hash` must be exactly 32 bytes when present. + #[error("CheckMetadataHash metadata hash is {len} bytes, expected 32")] + InvalidMetadataHashLength { + /// Supplied metadata hash length. + len: usize, + }, +} + /// Encode the standard signed extensions in the order declared by the target /// runtime. Unknown extensions are rejected because this wire payload has no /// field carrying their extra or implicit bytes. pub(crate) fn extrinsic_payload_extensions( payload: &HostSignPayloadData, -) -> Result, String> { +) -> Result, ExtrinsicPayloadError> { payload .signed_extensions .iter() @@ -42,28 +69,23 @@ pub(crate) fn extrinsic_payload_extensions( } "CheckMetadataHash" => { let mode = payload.mode.unwrap_or(0); - let mode = u8::try_from(mode).map_err(|_| { - format!("CheckMetadataHash mode {mode} does not fit in a u8") - })?; + let mode = u8::try_from(mode) + .map_err(|_| ExtrinsicPayloadError::MetadataHashModeOutOfRange { mode })?; let metadata_hash = payload .metadata_hash .as_deref() .map(|hash| { <[u8; 32]>::try_from(hash).map_err(|_| { - format!( - "CheckMetadataHash metadata hash is {} bytes, expected 32", - hash.len() - ) + ExtrinsicPayloadError::InvalidMetadataHashLength { len: hash.len() } }) }) .transpose()?; (mode.encode(), metadata_hash.encode()) } unsupported => { - return Err(format!( - "unsupported signed extension `{unsupported}`: its encoded fields are not \ - present in HostSignPayloadData" - )); + return Err(ExtrinsicPayloadError::UnsupportedSignedExtension { + id: unsupported.to_string(), + }); } }; Ok(TxPayloadExtension { @@ -78,7 +100,9 @@ pub(crate) fn extrinsic_payload_extensions( /// Signing preimage for an extrinsic payload: /// `method ++ Σextension.extra ++ Σextension.additional_signed`, with both /// extension sequences following the declared runtime order. -pub fn extrinsic_payload_preimage(payload: &HostSignPayloadData) -> Result, String> { +pub fn extrinsic_payload_preimage( + payload: &HostSignPayloadData, +) -> Result, ExtrinsicPayloadError> { let extensions = extrinsic_payload_extensions(payload)?; let mut preimage = Vec::new(); @@ -242,11 +266,9 @@ mod tests { assert_eq!( extrinsic_payload_preimage(&payload), - Err( - "unsupported signed extension `CustomExtension`: its encoded fields are not \ - present in HostSignPayloadData" - .to_string() - ) + Err(ExtrinsicPayloadError::UnsupportedSignedExtension { + id: "CustomExtension".to_string() + }) ); } @@ -258,7 +280,7 @@ mod tests { assert_eq!( extrinsic_payload_preimage(&payload), - Err("CheckMetadataHash mode 256 does not fit in a u8".to_string()) + Err(ExtrinsicPayloadError::MetadataHashModeOutOfRange { mode: 256 }) ); } @@ -270,7 +292,7 @@ mod tests { assert_eq!( extrinsic_payload_preimage(&payload), - Err("CheckMetadataHash metadata hash is 31 bytes, expected 32".to_string()) + Err(ExtrinsicPayloadError::InvalidMetadataHashLength { len: 31 }) ); } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index a98814b9..2a8996e6 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -622,7 +622,7 @@ async fn account_access_authorization( services: &RuntimeServices, requesting_product_id: &str, target_product_id: &str, -) -> Result { +) -> Result { if requesting_product_id == target_product_id { return Ok(PermissionAuthorizationStatus::Authorized); } @@ -638,7 +638,7 @@ async fn account_access_authorization( let cached = service .authorization_status(&request) .await - .map_err(|err| format!("permission storage failed: {err:?}"))?; + .map_err(AccountAccessAuthorizationError::PermissionStorage)?; if cached != PermissionAuthorizationStatus::NotDetermined { return Ok(cached); } @@ -650,7 +650,7 @@ async fn account_access_authorization( target_product_id: target_product_id.to_string(), })) .await - .map_err(|err| format!("account access confirmation failed: {err:?}"))?; + .map_err(AccountAccessAuthorizationError::Confirmation)?; let status = if confirmed { PermissionAuthorizationStatus::Authorized } else { @@ -659,10 +659,18 @@ async fn account_access_authorization( service .set_authorization_status(&request, status) .await - .map_err(|err| format!("permission storage failed: {err:?}"))?; + .map_err(AccountAccessAuthorizationError::PermissionStorage)?; Ok(status) } +#[derive(Debug, thiserror::Error)] +enum AccountAccessAuthorizationError { + #[error("permission storage failed: {0:?}")] + PermissionStorage(v01::GenericError), + #[error("account access confirmation failed: {0:?}")] + Confirmation(v01::GenericError), +} + fn parse_legacy_signer_hex(signer: &str) -> Option<[u8; 32]> { let raw = signer .strip_prefix("0x") @@ -893,7 +901,11 @@ impl Account for ProductRuntimeHost { v01::HostAccountGetError::Rejected, ))); } - Err(reason) => return Err(CallError::HostFailure { reason }), + Err(err) => { + return Err(CallError::HostFailure { + reason: err.to_string(), + }); + } } } diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index 80cb4f55..3fa962ac 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -207,7 +207,11 @@ impl PairingHost { fresh_statement_expiry(), ) .map_err(SsoRemoteResponseError::Failure)?; - let rpc_client = self.statement_store.client("SSO statement-store").await?; + let rpc_client = self + .statement_store + .client("SSO statement-store") + .await + .map_err(|err| SsoRemoteResponseError::Failure(err.to_string()))?; let own_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_own) .await .map_err(|err| { @@ -696,7 +700,10 @@ async fn wait_for_sso_peer_disconnect( statement_store: StatementStoreRpc, session: SsoSessionInfo, ) -> Result<(), String> { - let rpc_client = statement_store.client("SSO disconnect monitor").await?; + let rpc_client = statement_store + .client("SSO disconnect monitor") + .await + .map_err(|err| err.to_string())?; let mut subscription = statement_store_rpc::subscribe_match_all(&rpc_client, &[session.session_id_peer]) .await diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index bd8bf4fa..74969145 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -365,7 +365,11 @@ impl ProductAuthority for SigningHost { PermissionAuthorizationStatus::Denied | PermissionAuthorizationStatus::NotDetermined, ) => return Err(RingVrfError::Rejected), - Err(reason) => return Err(RingVrfError::Unknown { reason }), + Err(err) => { + return Err(RingVrfError::Unknown { + reason: err.to_string(), + }); + } } let collection = self.ring_resolver.validate(&request.ring_location).await?; let context = context_bytes(&request.context); @@ -475,7 +479,7 @@ impl ProductAuthority for SigningHost { OnExistingAllowancePolicy::Ignore, ) .await - .map_err(allocation_authority_error)?; + .map_err(sso_responder::AllowanceAllocationError::into_authority_error)?; StatementStoreAllowanceKey::from_secret_bytes(secret) } @@ -493,7 +497,7 @@ impl ProductAuthority for SigningHost { OnExistingAllowancePolicy::Ignore, ) .await - .map_err(allocation_authority_error)?; + .map_err(sso_responder::AllowanceAllocationError::into_authority_error)?; BulletinAllowanceKey::from_secret_bytes(secret) } @@ -511,7 +515,7 @@ impl ProductAuthority for SigningHost { OnExistingAllowancePolicy::Increase, ) .await - .map_err(allocation_authority_error)?; + .map_err(sso_responder::AllowanceAllocationError::into_authority_error)?; BulletinAllowanceKey::from_secret_bytes(secret) } @@ -558,8 +562,9 @@ fn sign_extrinsic_payload( ), }); } - let preimage = extrinsic_payload_preimage(&payload) - .map_err(|reason| AuthorityError::Unknown { reason })?; + let preimage = extrinsic_payload_preimage(&payload).map_err(|err| AuthorityError::Unknown { + reason: err.to_string(), + })?; let raw_signature = keypair .secret .sign_simple(SR25519_SIGNING_CONTEXT, &preimage, &keypair.public) @@ -587,10 +592,6 @@ fn product_authority_error(err: ProductAccountError) -> AuthorityError { } } -fn allocation_authority_error(reason: String) -> AuthorityError { - AuthorityError::Unavailable { reason } -} - /// Assemble and sign a transaction locally from caller-supplied, pre-encoded /// parts. Only Extrinsic V4 (`tx_ext_version == 0`) is supported; the caller's /// extension bytes carry the whole chain binding, so no metadata is consulted. diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index 2ed3ca6c..46c2131f 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -24,6 +24,8 @@ use truapi_platform::{ }; use super::SigningHost; +#[cfg(not(target_arch = "wasm32"))] +use crate::chain_runtime::RuntimeFailure; use crate::host_logic::entropy::root_entropy_source; #[cfg(not(target_arch = "wasm32"))] use crate::host_logic::product_account::ProductAccountError; @@ -49,12 +51,17 @@ use crate::host_logic::statement_store::{ validate_unsigned_statement_signing_payload, }; use crate::runtime::authority::{ - AccountAliasAuthorityRequest, CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, - ProductAuthority, SignPayloadAuthorityRequest, SignRawAuthorityRequest, + AccountAliasAuthorityRequest, AuthorityError, CreateProofAuthorityRequest, + CreateTransactionAuthorityRequest, ProductAuthority, SignPayloadAuthorityRequest, + SignRawAuthorityRequest, }; use crate::runtime::services::RuntimeServices; use crate::runtime::sso_remote::fresh_statement_expiry; +#[cfg(not(target_arch = "wasm32"))] +use crate::runtime::statement_allowance::StatementAllowanceError; use crate::runtime::statement_store_rpc; +#[cfg(not(target_arch = "wasm32"))] +use crate::runtime::statement_store_rpc::StatementStoreRpcClientError; /// Domain label for the responder's persistent P-256 encryption key. const SSO_ENCRYPTION_KEY_LABEL: &[u8] = b"sso-encryption"; @@ -112,6 +119,78 @@ pub enum ResponderExit { SubscriptionEnded, } +/// Failure while deriving or allocating a Statement Store/Bulletin allowance. +#[derive(Debug, thiserror::Error)] +pub(super) enum AllowanceAllocationError { + /// Signing host session or authority state was unavailable. + #[error("{0}")] + Authority(AuthorityError), + /// Product-account key derivation failed. + #[cfg(not(target_arch = "wasm32"))] + #[error("{0}")] + ProductAccount(ProductAccountError), + /// Chain state, metadata, ring, slot, proof, or extrinsic allocation failed. + #[cfg(not(target_arch = "wasm32"))] + #[error("{0}")] + StatementAllowance(#[from] StatementAllowanceError), + /// Runtime service could not open the required Statement Store RPC client. + #[cfg(not(target_arch = "wasm32"))] + #[error("{0}")] + StatementStoreRpcClient(#[from] StatementStoreRpcClientError), + /// Runtime service could not open the required Bulletin RPC client. + #[cfg(not(target_arch = "wasm32"))] + #[error("{context}: {source}")] + BulletinRpcClient { + /// Client context. + context: &'static str, + /// Chain runtime failure. + #[source] + source: RuntimeFailure, + }, + /// Allocation helper is unavailable for this target. + #[cfg(target_arch = "wasm32")] + #[error("signing host: {resource} allowance allocation is native-only")] + NativeOnly { + /// Resource name. + resource: &'static str, + }, + /// System time cannot be converted into a UNIX timestamp. + #[cfg(not(target_arch = "wasm32"))] + #[error("system clock before UNIX epoch")] + SystemClockBeforeUnixEpoch, + /// The signing account is not in the required LitePeople ring. + #[cfg(not(target_arch = "wasm32"))] + #[error("signing account is not a LitePeople ring member; cannot grant {resource} allowance")] + MissingLitePeopleMembership { + /// Resource name. + resource: &'static str, + }, +} + +impl From for AllowanceAllocationError { + fn from(err: AuthorityError) -> Self { + Self::Authority(err) + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl From for AllowanceAllocationError { + fn from(err: ProductAccountError) -> Self { + Self::ProductAccount(err) + } +} + +impl AllowanceAllocationError { + pub(super) fn into_authority_error(self) -> AuthorityError { + match self { + Self::Authority(err) => err, + other => AuthorityError::Unavailable { + reason: other.to_string(), + }, + } + } +} + /// Answer `deeplink` and serve the resulting SSO session until it ends. #[instrument(skip_all, fields(runtime.method = "sso_responder.respond_to_pairing"))] pub(crate) async fn respond_to_pairing( @@ -119,7 +198,8 @@ pub(crate) async fn respond_to_pairing( signing_host: Arc, deeplink: &str, ) -> Result { - let VersionedHandshakeProposal::V2(proposal) = decode_pairing_deeplink(deeplink)?; + let VersionedHandshakeProposal::V2(proposal) = + decode_pairing_deeplink(deeplink).map_err(|err| err.to_string())?; let entropy = signing_host .root_entropy() .map_err(|err| format!("signing host has no active local session: {err}"))?; @@ -185,7 +265,8 @@ async fn serve_session( let rpc_client = services .statement_store .client("sso-responder session") - .await?; + .await + .map_err(|err| err.to_string())?; let mut subscription = statement_store_rpc::subscribe_match_all(&rpc_client, &[session.session_id_peer]) .await @@ -708,7 +789,8 @@ async fn resource_allocation_response( }; match outcome { Ok(outcome) => outcomes.push(outcome), - Err(reason) => { + Err(err) => { + let reason = err.to_string(); warn!(%reason, "resource allocation item failed"); item_failures.push(reason); outcomes.push(SsoAllocationOutcome::NotAvailable); @@ -740,16 +822,15 @@ pub(super) async fn allocate_statement_store_allowance( signing_host: &SigningHost, product_id: &str, policy: OnExistingAllowancePolicy, -) -> Result, String> { +) -> Result, AllowanceAllocationError> { use crate::runtime::statement_allowance::{ self, RegistrationParams, fetch_chain_state, fetch_metadata, find_including_ring, register_statement_account, }; - let entropy = signing_host.root_entropy().map_err(|err| err.to_string())?; + let entropy = signing_host.root_entropy()?; let allowance = - derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id]) - .map_err(product_account_error)?; + derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id])?; let target = allowance.public.to_bytes(); let bandersnatch = statement_allowance::bandersnatch_entropy(&entropy); let rpc = statement_allowance::rpc::RpcClient::new( @@ -763,9 +844,8 @@ pub(super) async fn allocate_statement_store_allowance( let current = statement_allowance::ring::read_current_ring_index(&rpc).await?; let ring = find_including_ring(&rpc, &metadata, bandersnatch, current) .await? - .ok_or_else(|| { - "signing account is not a LitePeople ring member; cannot grant statement-store allowance" - .to_string() + .ok_or(AllowanceAllocationError::MissingLitePeopleMembership { + resource: "statement-store", })?; let period = statement_allowance::slot::current_period(current_unix_secs()?); let outcome = register_statement_account( @@ -812,15 +892,14 @@ pub(super) async fn allocate_bulletin_allowance( signing_host: &SigningHost, product_id: &str, policy: OnExistingAllowancePolicy, -) -> Result, String> { +) -> Result, AllowanceAllocationError> { use crate::runtime::statement_allowance::{ self, claim_long_term_storage, fetch_bulletin_allowance, fetch_chain_state, fetch_metadata, find_including_ring, wait_bulletin_authorization, }; - let entropy = signing_host.root_entropy().map_err(|err| err.to_string())?; - let allowance = derive_sr25519_hard_path(&entropy, &["allowance", "bulletin", product_id]) - .map_err(product_account_error)?; + let entropy = signing_host.root_entropy()?; + let allowance = derive_sr25519_hard_path(&entropy, &["allowance", "bulletin", product_id])?; let target = allowance.public.to_bytes(); let bulletin_rpc = statement_allowance::rpc::RpcClient::new( @@ -828,7 +907,10 @@ pub(super) async fn allocate_bulletin_allowance( .bulletin .client("bulletin allowance") .await - .map_err(|err| err.reason())?, + .map_err(|source| AllowanceAllocationError::BulletinRpcClient { + context: "bulletin allowance client", + source, + })?, ); let current_allowance = fetch_bulletin_allowance(&bulletin_rpc, &target).await?; if matches!(policy, OnExistingAllowancePolicy::Ignore) @@ -849,9 +931,8 @@ pub(super) async fn allocate_bulletin_allowance( let current = statement_allowance::ring::read_current_ring_index(&people_rpc).await?; let ring = find_including_ring(&people_rpc, &metadata, bandersnatch, current) .await? - .ok_or_else(|| { - "signing account is not a LitePeople ring member; cannot grant Bulletin allowance" - .to_string() + .ok_or(AllowanceAllocationError::MissingLitePeopleMembership { + resource: "Bulletin", })?; let period_duration = statement_allowance::slot::long_term_storage_period_duration(&metadata)?; let period = statement_allowance::slot::current_long_term_storage_period( @@ -903,8 +984,10 @@ pub(super) async fn allocate_statement_store_allowance( _signing_host: &SigningHost, _product_id: &str, _policy: OnExistingAllowancePolicy, -) -> Result, String> { - Err("signing host: statement-store allowance allocation is native-only".to_string()) +) -> Result, AllowanceAllocationError> { + Err(AllowanceAllocationError::NativeOnly { + resource: "statement-store", + }) } #[cfg(target_arch = "wasm32")] @@ -913,21 +996,18 @@ pub(super) async fn allocate_bulletin_allowance( _signing_host: &SigningHost, _product_id: &str, _policy: OnExistingAllowancePolicy, -) -> Result, String> { - Err("signing host: Bulletin allowance allocation is native-only".to_string()) +) -> Result, AllowanceAllocationError> { + Err(AllowanceAllocationError::NativeOnly { + resource: "Bulletin", + }) } #[cfg(not(target_arch = "wasm32"))] -fn current_unix_secs() -> Result { +fn current_unix_secs() -> Result { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs()) - .map_err(|_| "system clock before UNIX epoch".to_string()) -} - -#[cfg(not(target_arch = "wasm32"))] -fn product_account_error(err: ProductAccountError) -> String { - err.to_string() + .map_err(|_| AllowanceAllocationError::SystemClockBeforeUnixEpoch) } /// Confirm and serve a payload or raw signing request. @@ -1023,7 +1103,7 @@ async fn statement_store_product_sign_response( signing_host: &Arc, request: messages::StatementStoreProductSignRequest, ) -> Result, String> { - validate_unsigned_statement_signing_payload(&request.payload)?; + validate_unsigned_statement_signing_payload(&request.payload).map_err(|err| err.to_string())?; confirm( services, UserConfirmationReview::StatementStoreProductSign(StatementStoreProductSignReview { diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index 7d214d6f..24667e23 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -191,7 +191,7 @@ impl<'a> SsoPairingFlow<'a> { let rpc_client = futures::select! { _ = cancel => return Ok(SsoPairingOutcome::Cancelled), - connect_result = statement_store_connect => connect_result?, + connect_result = statement_store_connect => connect_result.map_err(|err| err.to_string())?, }; let subscribe_client = rpc_client.clone(); let live_topics = [bootstrap.topic]; diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index 0774f270..4f70d000 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -19,12 +19,79 @@ use futures::FutureExt; use parity_scale_codec::{Decode, Encode}; use serde_json::{Value, json}; use sp_crypto_hashing::twox_128; +use thiserror::Error; use tracing::{debug, warn}; -use extension::{ChainState, Metadata}; +use extension::{ChainState, Metadata, MetadataError}; use ring::RingParams; use rpc::RpcClient; -use slot::SlotSelection; +use slot::{SlotError, SlotSelection}; + +/// Error while reading chain state, building allowance extrinsics, or waiting +/// for allowance authorization. +#[derive(Debug, Error)] +pub enum StatementAllowanceError { + /// JSON-RPC transport, request, subscription, or storage hex failure. + #[error(transparent)] + Rpc(#[from] rpc::RpcError), + /// Runtime metadata was missing an expected pallet, call, type, extension, + /// constant, or could not be decoded. + #[error(transparent)] + Metadata(#[from] extension::MetadataError), + /// Chain RPC returned a value with an unexpected shape. + #[error(transparent)] + ChainState(#[from] ChainStateError), + /// Ring lookup or ring storage decoding failed. + #[error(transparent)] + Ring(#[from] ring::RingError), + /// Slot context, alias, or free-slot selection failed. + #[error(transparent)] + Slot(#[from] slot::SlotError), + /// Ring-VRF proof construction failed. + #[error(transparent)] + Proof(#[from] proof::ProofError), + /// Bulletin allowance polling timed out. + #[error("timed out waiting for Bulletin authorization")] + BulletinAuthorizationTimeout, +} + +/// Error while decoding generic chain state used by allowance registration. +#[derive(Debug, Error)] +pub enum ChainStateError { + /// `chain_getBlockHash(0)` did not return a hex string. + #[error("chain_getBlockHash returned non-string")] + GenesisHashNotString, + /// `chain_getBlockHash(0)` returned invalid hex. + #[error("genesis hex: {0}")] + GenesisHex(#[source] hex::FromHexError), + /// Decoded genesis hash was not 32 bytes. + #[error("genesis hash is {len} bytes, expected 32")] + GenesisHashLength { + /// Actual decoded length. + len: usize, + }, + /// Runtime JSON lacked an expected u32 field. + #[error("missing/invalid {field}")] + MissingU32Field { + /// Field name. + field: &'static str, + }, + /// Bulletin authorization storage failed to decode a field. + #[error("authorization {field}: {source}")] + AuthorizationFieldDecode { + /// Field name. + field: &'static str, + /// SCALE decode failure. + #[source] + source: parity_scale_codec::Error, + }, + /// `chain_getHeader` did not contain a block number string. + #[error("chain_getHeader returned no number")] + HeaderNumberMissing, + /// `chain_getHeader.number` was not valid hex. + #[error("chain_getHeader number: {0}")] + HeaderNumberParse(#[source] std::num::ParseIntError), +} /// Bandersnatch entropy for a bip39 entropy: `blake2b256(bip39_entropy)`. pub fn bandersnatch_entropy(bip39_entropy: &[u8]) -> [u8; 32] { @@ -33,20 +100,17 @@ pub fn bandersnatch_entropy(bip39_entropy: &[u8]) -> [u8; 32] { .hash(bip39_entropy) .as_bytes() .try_into() - .expect("BLAKE2b-256 returns 32 bytes") + .expect("hash_length(32) configures BLAKE2b output to exactly 32 bytes; qed") } /// Fetch and decode the runtime metadata (`state_getMetadata`). -pub async fn fetch_metadata(rpc: &RpcClient) -> Result { - let value = rpc - .call("state_getMetadata", json!([])) - .await - .map_err(|e| e.to_string())?; +pub async fn fetch_metadata(rpc: &RpcClient) -> Result { + let value = rpc.call("state_getMetadata", json!([])).await?; let hex_str = value .as_str() - .ok_or_else(|| "state_getMetadata returned non-string".to_string())?; + .ok_or(MetadataError::MetadataResultNotString)?; let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(hex_str)) - .map_err(|e| format!("metadata hex: {e}"))?; + .map_err(MetadataError::MetadataHex)?; // `state_getMetadata` may return either the raw `RuntimeMetadataPrefixed` // (starts with the `meta` magic) or an OpaqueMetadata wrapper // (`Vec` = compact(len) ‖ bytes). Strip the wrapper only when present. @@ -54,31 +118,25 @@ pub async fn fetch_metadata(rpc: &RpcClient) -> Result { if bytes.get(..4) == Some(&META_MAGIC) { Metadata::decode(&bytes) } else { - let inner = - Vec::::decode(&mut &bytes[..]).map_err(|e| format!("opaque metadata: {e}"))?; + let inner = Vec::::decode(&mut &bytes[..]).map_err(MetadataError::OpaqueMetadata)?; Metadata::decode(&inner) } } /// Fetch the chain state needed to fill the signed extensions. -pub async fn fetch_chain_state(rpc: &RpcClient) -> Result { - let genesis_hex = rpc - .call("chain_getBlockHash", json!([0])) - .await - .map_err(|e| e.to_string())?; +pub async fn fetch_chain_state(rpc: &RpcClient) -> Result { + let genesis_hex = rpc.call("chain_getBlockHash", json!([0])).await?; let genesis_str = genesis_hex .as_str() - .ok_or_else(|| "chain_getBlockHash returned non-string".to_string())?; + .ok_or(ChainStateError::GenesisHashNotString)?; let genesis = hex::decode(genesis_str.strip_prefix("0x").unwrap_or(genesis_str)) - .map_err(|e| format!("genesis hex: {e}"))?; + .map_err(ChainStateError::GenesisHex)?; + let len = genesis.len(); let genesis_hash: [u8; 32] = genesis .try_into() - .map_err(|_| "genesis hash is not 32 bytes".to_string())?; + .map_err(|_| ChainStateError::GenesisHashLength { len })?; - let runtime = rpc - .call("state_getRuntimeVersion", json!([])) - .await - .map_err(|e| e.to_string())?; + let runtime = rpc.call("state_getRuntimeVersion", json!([])).await?; let spec_version = json_u32(&runtime, "specVersion")?; let transaction_version = json_u32(&runtime, "transactionVersion")?; @@ -91,12 +149,12 @@ pub async fn fetch_chain_state(rpc: &RpcClient) -> Result { } /// Read a u32 field from a JSON object. -fn json_u32(value: &Value, field: &str) -> Result { +fn json_u32(value: &Value, field: &'static str) -> Result { value .get(field) .and_then(Value::as_u64) .and_then(|v| u32::try_from(v).ok()) - .ok_or_else(|| format!("missing/invalid {field}")) + .ok_or_else(|| ChainStateError::MissingU32Field { field }.into()) } /// Result of a statement-store allowance registration attempt. @@ -182,7 +240,7 @@ pub async fn find_including_ring( metadata: &Metadata, entropy: [u8; 32], lookback: u32, -) -> Result, String> { +) -> Result, StatementAllowanceError> { let member = proof::member_key(entropy); let at = rpc.finalized_head().await?; let exponent = ring::read_ring_exponent(rpc, metadata, &at).await?; @@ -210,7 +268,7 @@ pub async fn register_statement_account( chain_state: &ChainState, entropy: [u8; 32], params: RegistrationParams<'_>, -) -> Result { +) -> Result { let mut skipped_duplicate_slots = Vec::new(); loop { let seq = match slot::scan_slot_excluding( @@ -251,11 +309,12 @@ pub async fn register_statement_account( if slot::read_slot_account_at(rpc, entropy, params.period, seq, &block_hash).await? != Some(*params.target) { - return Err(format!( - "registration reached block {block_hash} but slot (period {}, \ - seq {seq}) is not held by the target account", - params.period - )); + return Err(SlotError::RegistrationVerificationMismatch { + block_hash, + period: params.period, + seq, + } + .into()); } return Ok(RegistrationOutcome::Registered { block_hash, @@ -263,10 +322,10 @@ pub async fn register_statement_account( ring_index: params.ring.ring_index, }); } - Err(err) if duplicate_submit_error(&err) => { + Err(err) if duplicate_submit_error(&err.to_string()) => { skipped_duplicate_slots.push(seq); } - Err(err) => return Err(err.to_string()), + Err(err) => return Err(err), } } } @@ -281,7 +340,7 @@ pub async fn claim_long_term_storage( target: &[u8; 32], period: u32, ring: &RingParams, -) -> Result { +) -> Result { let revision = ring::read_ring_revision(rpc, metadata, ring.ring_index, &ring.block_hash).await?; let mut skipped_duplicate_counters = Vec::new(); @@ -325,7 +384,7 @@ pub async fn claim_long_term_storage( ring_index: ring.ring_index, }); } - Err(err) if duplicate_submit_error(&err) => { + Err(err) if duplicate_submit_error(&err.to_string()) => { skipped_duplicate_counters.push(counter); } Err(err) => { @@ -337,7 +396,7 @@ pub async fn claim_long_term_storage( %err, "Bulletin long-term-storage claim failed" ); - return Err(err.to_string()); + return Err(err); } } } @@ -347,12 +406,8 @@ pub async fn claim_long_term_storage( pub async fn fetch_bulletin_allowance( rpc: &RpcClient, target: &[u8; 32], -) -> Result, String> { - let Some(bytes) = rpc - .get_storage(&bulletin_authorization_key(target)) - .await - .map_err(|e| e.to_string())? - else { +) -> Result, StatementAllowanceError> { + let Some(bytes) = rpc.get_storage(&bulletin_authorization_key(target)).await? else { return Ok(None); }; let fetched_at = fetch_block_number(rpc).await?; @@ -365,7 +420,7 @@ pub async fn wait_bulletin_authorization( target: &[u8; 32], current: Option, timeout: Duration, -) -> Result { +) -> Result { let started = Instant::now(); let baseline = current.filter(|info| info.available()); loop { @@ -383,9 +438,9 @@ pub async fn wait_bulletin_authorization( async fn wait_before_next_bulletin_authorization_poll( started: Instant, timeout: Duration, -) -> Result<(), String> { +) -> Result<(), StatementAllowanceError> { let Some(remaining) = timeout.checked_sub(started.elapsed()) else { - return Err("timed out waiting for Bulletin authorization".to_string()); + return Err(StatementAllowanceError::BulletinAuthorizationTimeout); }; let delay = futures_timer::Delay::new(remaining.min(Duration::from_secs(2))).fuse(); futures::pin_mut!(delay); @@ -424,20 +479,38 @@ fn bulletin_authorization_key(target: &[u8; 32]) -> Vec { fn decode_bulletin_allowance( bytes: &[u8], fetched_at: u32, -) -> Result { +) -> Result { let mut input = bytes; let transactions = - u32::decode(&mut input).map_err(|err| format!("authorization transactions: {err}"))?; - let transactions_allowance = u32::decode(&mut input) - .map_err(|err| format!("authorization transactions_allowance: {err}"))?; + u32::decode(&mut input).map_err(|err| ChainStateError::AuthorizationFieldDecode { + field: "transactions", + source: err, + })?; + let transactions_allowance = + u32::decode(&mut input).map_err(|err| ChainStateError::AuthorizationFieldDecode { + field: "transactions_allowance", + source: err, + })?; let bytes_used = - u64::decode(&mut input).map_err(|err| format!("authorization bytes: {err}"))?; + u64::decode(&mut input).map_err(|err| ChainStateError::AuthorizationFieldDecode { + field: "bytes", + source: err, + })?; let _bytes_permanent = - u64::decode(&mut input).map_err(|err| format!("authorization bytes_permanent: {err}"))?; + u64::decode(&mut input).map_err(|err| ChainStateError::AuthorizationFieldDecode { + field: "bytes_permanent", + source: err, + })?; let bytes_allowance = - u64::decode(&mut input).map_err(|err| format!("authorization bytes_allowance: {err}"))?; + u64::decode(&mut input).map_err(|err| ChainStateError::AuthorizationFieldDecode { + field: "bytes_allowance", + source: err, + })?; let expires_in = - u32::decode(&mut input).map_err(|err| format!("authorization expiration: {err}"))?; + u32::decode(&mut input).map_err(|err| ChainStateError::AuthorizationFieldDecode { + field: "expiration", + source: err, + })?; Ok(BulletinAllowanceInfo { remained_size: bytes_allowance.saturating_sub(bytes_used), remained_transactions: transactions_allowance.saturating_sub(transactions), @@ -446,17 +519,14 @@ fn decode_bulletin_allowance( }) } -async fn fetch_block_number(rpc: &RpcClient) -> Result { - let header = rpc - .call("chain_getHeader", json!([])) - .await - .map_err(|err| err.to_string())?; +async fn fetch_block_number(rpc: &RpcClient) -> Result { + let header = rpc.call("chain_getHeader", json!([])).await?; let number = header .get("number") .and_then(Value::as_str) - .ok_or_else(|| "chain_getHeader returned no number".to_string())?; + .ok_or(ChainStateError::HeaderNumberMissing)?; u32::from_str_radix(number.trim_start_matches("0x"), 16) - .map_err(|err| format!("chain_getHeader number: {err}")) + .map_err(|err| ChainStateError::HeaderNumberParse(err).into()) } /// Pool responses meaning an equivalent claim already occupies the pool, so @@ -556,7 +626,10 @@ mod tests { /// read at that block returns `verified_entry`. fn scripted_registration( verified_entry: &str, - ) -> (Result, ScriptedRpc) { + ) -> ( + Result, + ScriptedRpc, + ) { let metadata = Metadata::decode(FIXTURE).unwrap(); let chain_state = ChainState { spec_version: 1_000_000, @@ -615,6 +688,9 @@ mod tests { let (outcome, _scripted) = scripted_registration(&slot_entry([0x99; 32])); let err = outcome.unwrap_err(); - assert!(err.contains("0xb10c"), "unexpected error: {err}"); + assert!( + err.to_string().contains("0xb10c"), + "unexpected error: {err}" + ); } } diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs index 637e7897..9deccdc4 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs @@ -20,10 +20,114 @@ use frame_metadata::RuntimeMetadataPrefixed; use parity_scale_codec::{Compact, Decode, Encode}; use scale_info::form::PortableForm; use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive, TypeDefVariant}; +use thiserror::Error; + +use super::StatementAllowanceError; /// Signed-extension identifier that carries the `AsResources` authorization. pub const AS_RESOURCES: &str = "AsResources"; +/// Error while decoding runtime metadata or resolving allowance-specific +/// metadata entries. +#[derive(Debug, Error)] +pub enum MetadataError { + /// `state_getMetadata` did not return a hex string. + #[error("state_getMetadata returned non-string")] + MetadataResultNotString, + /// Metadata hex payload was invalid. + #[error("metadata hex: {0}")] + MetadataHex(#[source] hex::FromHexError), + /// Opaque metadata wrapper could not be decoded. + #[error("opaque metadata: {0}")] + OpaqueMetadata(#[source] parity_scale_codec::Error), + /// Runtime metadata prefix could not be decoded. + #[error("metadata decode failed: {0}")] + Decode(#[source] parity_scale_codec::Error), + /// Runtime metadata version is not supported by this encoder. + #[error("unsupported metadata version {version}")] + UnsupportedVersion { + /// Runtime metadata version. + version: u32, + }, + /// Pallet has no call enum. + #[error("pallet `{pallet}` has no calls in metadata")] + MissingPalletCalls { + /// Pallet name. + pallet: String, + }, + /// Named pallet call was not found. + #[error("call `{pallet}.{call}` not found in metadata")] + MissingCall { + /// Pallet name. + pallet: String, + /// Call name. + call: String, + }, + /// `AsResources` extension is absent from metadata. + #[error("{AS_RESOURCES} extension not found in metadata")] + MissingAsResourcesExtension, + /// `AsResources` extra type did not contain the expected `Option`. + #[error("{AS_RESOURCES} extra is not an Option")] + AsResourcesExtraNotOption, + /// Named `AsResourcesInfo` variant was not found. + #[error("AsResourcesInfo::{variant} not found in metadata")] + MissingAsResourcesInfoVariant { + /// Variant name. + variant: String, + }, + /// Named `AsResourcesInfo` variant did not carry a membership collection. + #[error("AsResourcesInfo::{variant} carries no MembershipCollection field")] + MissingMembershipCollection { + /// Variant name. + variant: String, + }, + /// `MembershipCollection::LitePeople` variant was not found. + #[error("MembershipCollection::LitePeople not found in metadata")] + MissingLitePeopleCollection, + /// Type id did not resolve in the portable registry. + #[error("unknown type id {type_id}")] + UnknownTypeId { + /// Missing type id. + type_id: u32, + }, + /// Type id did not resolve to an enum. + #[error("type {type_id} is not an enum")] + TypeNotEnum { + /// Type id. + type_id: u32, + }, + /// Type id did not resolve to a composite. + #[error("type {type_id} is not a composite")] + TypeNotComposite { + /// Type id. + type_id: u32, + }, + /// Composite type had the wrong field count. + #[error("type {type_id} has {actual} fields, expected 1")] + CompositeFieldCount { + /// Type id. + type_id: u32, + /// Actual field count. + actual: usize, + }, + /// Storage value type was missing from metadata. + #[error("{pallet}.{entry} type not in metadata")] + MissingStorageType { + /// Pallet name. + pallet: &'static str, + /// Storage entry name. + entry: &'static str, + }, + /// Pallet constant was missing from metadata. + #[error("{pallet}.{constant} constant missing")] + MissingConstant { + /// Pallet name. + pallet: &'static str, + /// Constant name. + constant: &'static str, + }, +} + /// Chain state needed to fill the standard signed extensions. #[derive(Debug, Clone, Copy)] pub struct ChainState { @@ -162,14 +266,19 @@ impl Metadata { /// Decode `state_getMetadata` bytes (a `RuntimeMetadataPrefixed`, V14 /// through V16) into the ordered signed-extension defs, type registry, /// storage value types, constants, and call enums. - pub fn decode(bytes: &[u8]) -> Result { - let prefixed = RuntimeMetadataPrefixed::decode(&mut &bytes[..]) - .map_err(|err| format!("metadata decode failed: {err}"))?; + pub fn decode(bytes: &[u8]) -> Result { + let prefixed = + RuntimeMetadataPrefixed::decode(&mut &bytes[..]).map_err(MetadataError::Decode)?; let (extensions, registry, storage_values, constants, calls) = match prefixed.1 { RuntimeMetadata::V14(m) => collect_metadata!(m, frame_metadata::v14::StorageEntryType), RuntimeMetadata::V15(m) => collect_metadata!(m, frame_metadata::v15::StorageEntryType), RuntimeMetadata::V16(m) => collect_metadata_v16!(m), - other => return Err(format!("unsupported metadata version {}", other.version())), + other => { + return Err(MetadataError::UnsupportedVersion { + version: other.version(), + } + .into()); + } }; Ok(Self { extensions, @@ -201,30 +310,42 @@ impl Metadata { /// Resolve `pallet::call` by name to its `[pallet_index, call_index]` /// dispatch bytes. - pub fn call_indices(&self, pallet: &str, call: &str) -> Result<[u8; 2], String> { - let (pallet_index, call_type) = self - .calls - .get(pallet) - .copied() - .ok_or_else(|| format!("pallet `{pallet}` has no calls in metadata"))?; + pub fn call_indices( + &self, + pallet: &str, + call: &str, + ) -> Result<[u8; 2], StatementAllowanceError> { + let (pallet_index, call_type) = + self.calls + .get(pallet) + .copied() + .ok_or_else(|| MetadataError::MissingPalletCalls { + pallet: pallet.to_string(), + })?; let variants = self.resolve_variant(call_type)?; let variant = variants .variants .iter() .find(|v| v.name == call) - .ok_or_else(|| format!("call `{pallet}.{call}` not found in metadata"))?; + .ok_or_else(|| MetadataError::MissingCall { + pallet: pallet.to_string(), + call: call.to_string(), + })?; Ok([pallet_index, variant.index]) } /// Resolve `AsResourcesInfo::` and the /// `MembershipCollection::LitePeople` index it carries, by name, from the /// `AsResources` extension type. - pub fn as_resources_variant_indices(&self, info_variant: &str) -> Result<(u8, u8), String> { + pub fn as_resources_variant_indices( + &self, + info_variant: &str, + ) -> Result<(u8, u8), StatementAllowanceError> { let ext = self .extensions .iter() .find(|e| e.identifier == AS_RESOURCES) - .ok_or_else(|| format!("{AS_RESOURCES} extension not found in metadata"))?; + .ok_or(MetadataError::MissingAsResourcesExtension)?; // extra = `AsResources(Option)`, with or without the // struct wrapper. let option_type = match &self.resolve_type(ext.extra_type)?.type_def { @@ -240,13 +361,15 @@ impl Metadata { [field] => Some(field.ty.id), _ => None, }) - .ok_or_else(|| format!("{AS_RESOURCES} extra is not an Option"))?; + .ok_or(MetadataError::AsResourcesExtraNotOption)?; let variant = self .resolve_variant(info_type)? .variants .iter() .find(|v| v.name == info_variant) - .ok_or_else(|| format!("AsResourcesInfo::{info_variant} not found in metadata"))?; + .ok_or_else(|| MetadataError::MissingAsResourcesInfoVariant { + variant: info_variant.to_string(), + })?; let collection_type = variant .fields .iter() @@ -257,44 +380,51 @@ impl Metadata { ty.path.segments.last().map(String::as_str) == Some("MembershipCollection") }) }) - .ok_or_else(|| { - format!("AsResourcesInfo::{info_variant} carries no MembershipCollection field") + .ok_or_else(|| MetadataError::MissingMembershipCollection { + variant: info_variant.to_string(), })?; let lite_people = self .resolve_variant(collection_type)? .variants .iter() .find(|v| v.name == "LitePeople") - .ok_or_else(|| "MembershipCollection::LitePeople not found in metadata".to_string())?; + .ok_or(MetadataError::MissingLitePeopleCollection)?; Ok((variant.index, lite_people.index)) } /// Resolve a type id in the registry. - fn resolve_type(&self, type_id: u32) -> Result<&scale_info::Type, String> { + fn resolve_type( + &self, + type_id: u32, + ) -> Result<&scale_info::Type, StatementAllowanceError> { self.registry .resolve(type_id) - .ok_or_else(|| format!("unknown type id {type_id}")) + .ok_or(MetadataError::UnknownTypeId { type_id }.into()) } /// Resolve `type_id` as an enum definition. - fn resolve_variant(&self, type_id: u32) -> Result<&TypeDefVariant, String> { + fn resolve_variant( + &self, + type_id: u32, + ) -> Result<&TypeDefVariant, StatementAllowanceError> { match &self.resolve_type(type_id)?.type_def { TypeDef::Variant(variant) => Ok(variant), - _ => Err(format!("type {type_id} is not an enum")), + _ => Err(MetadataError::TypeNotEnum { type_id }.into()), } } /// The field type of a one-field composite. - fn single_field_type(&self, type_id: u32) -> Result { + fn single_field_type(&self, type_id: u32) -> Result { let TypeDef::Composite(composite) = &self.resolve_type(type_id)?.type_def else { - return Err(format!("type {type_id} is not a composite")); + return Err(MetadataError::TypeNotComposite { type_id }.into()); }; match composite.fields.as_slice() { [field] => Ok(field.ty.id), - fields => Err(format!( - "type {type_id} has {} fields, expected 1", - fields.len() - )), + fields => Err(MetadataError::CompositeFieldCount { + type_id, + actual: fields.len(), + } + .into()), } } @@ -417,12 +547,12 @@ pub fn build_proof_message( metadata: &Metadata, call_data: &[u8], state: &ChainState, -) -> Result<[u8; 32], String> { +) -> Result<[u8; 32], StatementAllowanceError> { let all = metadata.encode_signed_extensions(state); let tail_start = metadata .as_resources_index() .map(|i| i + 1) - .ok_or_else(|| format!("{AS_RESOURCES} extension not found in metadata"))?; + .ok_or(MetadataError::MissingAsResourcesExtension)?; let tail = &all[tail_start..]; let mut payload = Vec::with_capacity(1 + call_data.len()); @@ -444,7 +574,7 @@ pub fn blake2b256(message: &[u8]) -> [u8; 32] { .hash(message) .as_bytes() .try_into() - .expect("BLAKE2b-256 returns 32 bytes") + .expect("hash_length(32) configures BLAKE2b output to exactly 32 bytes; qed") } #[cfg(test)] diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs index 5c700d81..c6e03dd3 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs @@ -6,7 +6,8 @@ use parity_scale_codec::{Decode, Encode}; -use super::extension::{ChainState, Metadata}; +use super::StatementAllowanceError; +use super::extension::{ChainState, Metadata, MetadataError}; /// General-transaction preamble byte: `0b01` (General) | version 5. const GENERAL_V5_PREAMBLE: u8 = 0x45; @@ -52,7 +53,7 @@ pub fn build_set_statement_store_account_call( period: u32, seq: u32, target: &[u8; 32], -) -> Result, String> { +) -> Result, StatementAllowanceError> { let indices = metadata.call_indices("Resources", "set_statement_store_account")?; let mut call = Vec::with_capacity(2 + 4 + 4 + 32); call.extend_from_slice(&indices); @@ -73,7 +74,7 @@ pub fn build_claim_long_term_storage_call( period: u32, counter: u8, account_id: &[u8; 32], -) -> Result, String> { +) -> Result, StatementAllowanceError> { let indices = metadata.call_indices("Resources", "claim_long_term_storage")?; let mut call = Vec::with_capacity(2 + 4 + 1 + 32); call.extend_from_slice(&indices); @@ -93,7 +94,7 @@ pub fn build_as_resources_extra( metadata: &Metadata, proof: &[u8], ring_index: u32, -) -> Result, String> { +) -> Result, StatementAllowanceError> { let (info_index, lite_people) = metadata.as_resources_variant_indices("RegisterStatementStoreAllowance")?; let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 1); @@ -116,7 +117,7 @@ pub fn build_long_term_storage_extra( proof: &[u8], ring_index: u32, revision: u32, -) -> Result, String> { +) -> Result, StatementAllowanceError> { let (info_index, lite_people) = metadata.as_resources_variant_indices("ClaimLongTermStorage")?; let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 4 + 1); @@ -139,11 +140,11 @@ pub fn build_unsigned_extrinsic( state: &ChainState, call_data: &[u8], as_resources_extra: &[u8], -) -> Result, String> { +) -> Result, StatementAllowanceError> { let all = metadata.encode_signed_extensions(state); let as_resources_index = metadata .as_resources_index() - .ok_or_else(|| "AsResources extension not found in metadata".to_string())?; + .ok_or(MetadataError::MissingAsResourcesExtension)?; let mut body = vec![GENERAL_V5_PREAMBLE, EXTENSION_VERSION]; for (i, ext) in all.iter().enumerate() { diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs index b9dc567f..7b91287e 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs @@ -5,21 +5,50 @@ //! in the LitePeople ring, bound to a slot `context` and the extrinsic proof //! `message`. Mirrors signing-bot `ring-proof.ts` `oneShotProof`. +use thiserror::Error; +use verifiable::Error as VerifiableError; use verifiable::GenerateVerifiable; use verifiable::ring::RingDomainSize; use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; +use super::StatementAllowanceError; + /// A single-context ring-VRF signature is exactly 785 bytes. pub const RING_VRF_PROOF_LEN: usize = 785; +/// Error while constructing a ring-VRF allowance proof. +#[derive(Debug, Error)] +pub enum ProofError { + /// On-chain ring exponent does not map to a supported proof domain. + #[error("unsupported ring exponent {exponent}")] + UnsupportedRingExponent { + /// Ring exponent. + exponent: u8, + }, + /// Prover could not open the ring for the member key. + #[error("ring-VRF open failed: {0:?}")] + OpenFailed(VerifiableError), + /// Prover could not create the proof. + #[error("ring-VRF create failed: {0:?}")] + CreateFailed(VerifiableError), + /// Proof encoded to an unexpected length. + #[error("ring-VRF proof is {actual} bytes, expected {expected}")] + InvalidProofLength { + /// Actual byte length. + actual: usize, + /// Expected byte length. + expected: usize, + }, +} + /// Map an on-chain `RingExponent` (9 / 10 / 14) to the FFT domain size /// (power = exponent + 2). -pub fn domain_for_ring_exponent(exponent: u8) -> Result { +pub fn domain_for_ring_exponent(exponent: u8) -> Result { match exponent { 9 => Ok(RingDomainSize::Domain11), 10 => Ok(RingDomainSize::Domain12), 14 => Ok(RingDomainSize::Domain16), - other => Err(format!("unsupported ring exponent {other}")), + other => Err(ProofError::UnsupportedRingExponent { exponent: other }.into()), } } @@ -40,19 +69,20 @@ pub fn ring_vrf_proof( members: &[[u8; 32]], context: &[u8], message: &[u8], -) -> Result, String> { +) -> Result, StatementAllowanceError> { let secret = BandersnatchVrfVerifiable::new_secret(entropy); let member = BandersnatchVrfVerifiable::member_from_secret(&secret); let commitment = BandersnatchVrfVerifiable::open(domain, &member, members.iter().copied()) - .map_err(|err| format!("ring-VRF open failed: {err:?}"))?; + .map_err(ProofError::OpenFailed)?; let (proof, _alias) = BandersnatchVrfVerifiable::create(commitment, &secret, context, message) - .map_err(|err| format!("ring-VRF create failed: {err:?}"))?; + .map_err(ProofError::CreateFailed)?; let bytes = proof.into_inner(); if bytes.len() != RING_VRF_PROOF_LEN { - return Err(format!( - "ring-VRF proof is {} bytes, expected {RING_VRF_PROOF_LEN}", - bytes.len() - )); + return Err(ProofError::InvalidProofLength { + actual: bytes.len(), + expected: RING_VRF_PROOF_LEN, + } + .into()); } Ok(bytes) } @@ -106,6 +136,9 @@ mod tests { &[0x42; 32], ) .unwrap_err(); - assert!(err.contains("open failed"), "unexpected error: {err}"); + assert!( + err.to_string().contains("open failed"), + "unexpected error: {err}" + ); } } diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs index 1a37ae5a..aabd53fa 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs @@ -7,10 +7,44 @@ use parity_scale_codec::{Compact, Decode}; use scale_decode::DecodeAsType; use sp_crypto_hashing::{blake2_128, twox_64, twox_128}; +use thiserror::Error; -use super::extension::Metadata; +use super::StatementAllowanceError; +use super::extension::{Metadata, MetadataError}; use super::rpc::RpcClient; +/// Error while reading or decoding LitePeople ring storage. +#[derive(Debug, Error)] +pub enum RingError { + /// Current ring index storage failed to decode. + #[error("ring index: {0}")] + RingIndex(#[source] parity_scale_codec::Error), + /// LitePeople collection info was absent. + #[error("Members.Collections[LitePeople] missing")] + LitePeopleCollectionMissing, + /// Metadata-aware storage decode failed. + #[error("{context}: {source}")] + DecodeAsType { + /// Decode context. + context: &'static str, + /// Metadata-aware decode failure. + #[source] + source: scale_decode::Error, + }, + /// Ring key page compact length failed to decode. + #[error("ring keys len: {0}")] + RingKeysLen(#[source] parity_scale_codec::Error), + /// Ring key page did not contain all advertised members. + #[error("ring keys page truncated")] + RingKeysPageTruncated, + /// Ring status did not contain the included field. + #[error("ring status truncated before included field")] + RingStatusTruncated, + /// Ring status included field failed to decode. + #[error("ring status: {0}")] + RingStatus(#[source] parity_scale_codec::Error), +} + /// LitePeople collection identifier: ASCII, exactly 32 bytes. const LITE_PEOPLE_IDENTIFIER: &[u8; 32] = b"pop:polkadot.network/people-lite"; /// Ring member public key length. @@ -125,27 +159,22 @@ fn twox_64_concat(x: &[u8]) -> Vec { /// Read the current LitePeople ring index at the current best block /// (absent => 0). -pub async fn read_current_ring_index(rpc: &RpcClient) -> Result { - decode_ring_index( - rpc.get_storage(¤t_ring_index_key()) - .await - .map_err(|e| e.to_string())?, - ) +pub async fn read_current_ring_index(rpc: &RpcClient) -> Result { + decode_ring_index(rpc.get_storage(¤t_ring_index_key()).await?) } /// Read the current LitePeople ring index pinned to block `at` (absent => 0). -pub async fn read_current_ring_index_at(rpc: &RpcClient, at: &str) -> Result { - decode_ring_index( - rpc.get_storage_at(¤t_ring_index_key(), at) - .await - .map_err(|e| e.to_string())?, - ) +pub async fn read_current_ring_index_at( + rpc: &RpcClient, + at: &str, +) -> Result { + decode_ring_index(rpc.get_storage_at(¤t_ring_index_key(), at).await?) } /// Decode a `CurrentRingIndex` storage value (absent => 0). -fn decode_ring_index(bytes: Option>) -> Result { +fn decode_ring_index(bytes: Option>) -> Result { match bytes { - Some(bytes) => u32::decode(&mut &bytes[..]).map_err(|e| format!("ring index: {e}")), + Some(bytes) => u32::decode(&mut &bytes[..]).map_err(|err| RingError::RingIndex(err).into()), None => Ok(0), } } @@ -157,19 +186,27 @@ pub async fn read_ring_exponent( rpc: &RpcClient, metadata: &Metadata, at: &str, -) -> Result { +) -> Result { let collection = rpc .get_storage_at(&collections_key(), at) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "Members.Collections[LitePeople] missing".to_string())?; + .await? + .ok_or(RingError::LitePeopleCollectionMissing)?; let value_type = metadata .storage_value_type("Members", "Collections") - .ok_or_else(|| "Members.Collections type not in metadata".to_string())?; + .ok_or(MetadataError::MissingStorageType { + pallet: "Members", + entry: "Collections", + })?; let mut input = collection.as_slice(); CollectionInfo::decode_as_type(&mut input, value_type, metadata.registry()) .map(|collection| collection.ring_size.exponent()) - .map_err(|err| format!("Members.Collections: {err}")) + .map_err(|err| { + RingError::DecodeAsType { + context: "Members.Collections", + source: err, + } + .into() + }) } /// Read the members of `ring_index`, sliced to the baked-in `included` @@ -179,20 +216,18 @@ pub async fn read_ring_members_at( rpc: &RpcClient, ring_index: u32, at: &str, -) -> Result, String> { +) -> Result, StatementAllowanceError> { // 1. Page through RingKeys collecting raw 32-byte members. let mut members = Vec::new(); for page in 0.. { let Some(bytes) = rpc .get_storage_at(&ring_keys_key(ring_index, page), at) - .await - .map_err(|e| e.to_string())? + .await? else { break; }; let mut cursor = &bytes[..]; - let Compact(len) = - Compact::::decode(&mut cursor).map_err(|e| format!("ring keys len: {e}"))?; + let Compact(len) = Compact::::decode(&mut cursor).map_err(RingError::RingKeysLen)?; if len == 0 { break; } @@ -200,9 +235,9 @@ pub async fn read_ring_members_at( let start = i * MEMBER_LEN; let member: [u8; 32] = cursor .get(start..start + MEMBER_LEN) - .ok_or_else(|| "ring keys page truncated".to_string())? + .ok_or(RingError::RingKeysPageTruncated)? .try_into() - .expect("slice is 32 bytes"); + .expect("range end uses start + MEMBER_LEN where MEMBER_LEN is 32; qed"); members.push(member); } } @@ -210,15 +245,11 @@ pub async fn read_ring_members_at( // 2. Slice to the baked-in `included` prefix (absent status => all included). if let Some(status) = rpc .get_storage_at(&ring_keys_status_key(ring_index), at) - .await - .map_err(|e| e.to_string())? + .await? { // RingStatus = { total: u32 LE, included: u32 LE, .. }. - let included_bytes = status - .get(4..) - .ok_or_else(|| "ring status truncated before included field".to_string())?; - let included = - u32::decode(&mut &included_bytes[..]).map_err(|e| format!("ring status: {e}"))?; + let included_bytes = status.get(4..).ok_or(RingError::RingStatusTruncated)?; + let included = u32::decode(&mut &included_bytes[..]).map_err(RingError::RingStatus)?; members.truncate(included as usize); } @@ -232,20 +263,25 @@ pub async fn read_ring_revision( metadata: &Metadata, ring_index: u32, at: &str, -) -> Result { - match rpc - .get_storage_at(&ring_root_key(ring_index), at) - .await - .map_err(|e| e.to_string())? - { +) -> Result { + match rpc.get_storage_at(&ring_root_key(ring_index), at).await? { Some(bytes) => { - let value_type = metadata - .storage_value_type("Members", "Root") - .ok_or_else(|| "Members.Root type not in metadata".to_string())?; + let value_type = metadata.storage_value_type("Members", "Root").ok_or( + MetadataError::MissingStorageType { + pallet: "Members", + entry: "Root", + }, + )?; let mut input = bytes.as_slice(); RingRoot::decode_as_type(&mut input, value_type, metadata.registry()) .map(|root| root.revision) - .map_err(|err| format!("ring revision: {err}")) + .map_err(|err| { + RingError::DecodeAsType { + context: "ring revision", + source: err, + } + .into() + }) } None => Ok(0), } @@ -271,8 +307,9 @@ mod tests { .id; let registry: scale_info::PortableRegistry = registry.into(); let encoded = source.encode(); - Target::decode_as_type(&mut encoded.as_slice(), type_id, ®istry) - .expect("metadata-aware partial decode succeeds") + Target::decode_as_type(&mut encoded.as_slice(), type_id, ®istry).expect( + "source metadata is registered and target projection matches by field name; qed", + ) } #[test] diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs index bd51be99..5aae13b1 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs @@ -6,10 +6,60 @@ use futures::{FutureExt, pin_mut}; use serde_json::{Value, json}; use subxt_rpcs::RpcClient as HostRpcClient; use subxt_rpcs::client::{RpcClient as NativeRpcClient, RpcParams, rpc_params}; +use thiserror::Error; + +use super::StatementAllowanceError; /// Timeout for an allowance registration extrinsic to reach a block. const SUBMIT_TIMEOUT: Duration = Duration::from_secs(120); +/// Error from the native JSON-RPC surface used by allowance allocation. +#[derive(Debug, Error)] +pub enum RpcError { + /// Opening a direct RPC URL failed. + #[error("connect {url}: {source}")] + Connect { + /// RPC URL. + url: String, + /// RPC failure. + #[source] + source: subxt_rpcs::Error, + }, + /// JSON-RPC request failed. + #[error("{method}: {source}")] + Request { + /// RPC method. + method: String, + /// RPC failure. + #[source] + source: subxt_rpcs::Error, + }, + /// RPC params were not supplied as a JSON array. + #[error("RPC params must be a JSON array")] + ParamsNotArray, + /// Encoding one JSON-RPC param failed. + #[error("RPC param encode failed: {0}")] + ParamEncode(#[source] subxt_rpcs::Error), + /// `state_getStorage` returned invalid hex. + #[error("decode hex storage value: {0}")] + StorageHex(#[source] hex::FromHexError), + /// `chain_getFinalizedHead` did not return a hash string. + #[error("chain_getFinalizedHead returned non-string")] + FinalizedHeadNotString, + /// Extrinsic status subscription ended before inclusion. + #[error("author_submitAndWatchExtrinsic subscription ended")] + SubmitSubscriptionEnded, + /// Extrinsic status subscription timed out before inclusion. + #[error("timed out waiting for author_submitAndWatchExtrinsic inclusion")] + SubmitTimeout, + /// Extrinsic status subscription yielded a terminal rejection status. + #[error("extrinsic {status}")] + ExtrinsicRejected { + /// Terminal status key. + status: String, + }, +} + /// Thin adapter matching the allowance allocator's minimal RPC surface. #[derive(Clone)] pub struct RpcClient { @@ -18,10 +68,13 @@ pub struct RpcClient { impl RpcClient { /// Open a native JSON-RPC connection to `url`. - pub async fn connect(url: &str) -> Result { + pub async fn connect(url: &str) -> Result { let inner = NativeRpcClient::from_insecure_url(url) .await - .map_err(|err| format!("connect {url}: {err}"))?; + .map_err(|err| RpcError::Connect { + url: url.to_string(), + source: err, + })?; Ok(Self { inner }) } @@ -31,22 +84,39 @@ impl RpcClient { } /// Call `method` with JSON-array `params`, returning the result value. - pub async fn call(&self, method: &str, params: Value) -> Result { + pub async fn call( + &self, + method: &str, + params: Value, + ) -> Result { self.inner .request(method, value_to_params(params)?) .await - .map_err(rpc_error_message) + .map_err(|err| { + RpcError::Request { + method: method.to_string(), + source: err, + } + .into() + }) } /// `state_getStorage(key)` at the current best block -> raw value bytes, /// or `None` if absent. - pub async fn get_storage(&self, key: &[u8]) -> Result>, String> { + pub async fn get_storage( + &self, + key: &[u8], + ) -> Result>, StatementAllowanceError> { self.get_storage_maybe_at(key, None).await } /// `state_getStorage(key, at)` pinned to block `at` -> raw value bytes, /// or `None` if absent. - pub async fn get_storage_at(&self, key: &[u8], at: &str) -> Result>, String> { + pub async fn get_storage_at( + &self, + key: &[u8], + at: &str, + ) -> Result>, StatementAllowanceError> { self.get_storage_maybe_at(key, Some(at)).await } @@ -54,7 +124,7 @@ impl RpcClient { &self, key: &[u8], at: Option<&str>, - ) -> Result>, String> { + ) -> Result>, StatementAllowanceError> { let key_hex = format!("0x{}", hex::encode(key)); let params = match at { Some(at) => rpc_params![key_hex, at], @@ -64,24 +134,29 @@ impl RpcClient { .inner .request::("state_getStorage", params) .await - .map_err(rpc_error_message)? - { + .map_err(|err| RpcError::Request { + method: "state_getStorage".to_string(), + source: err, + })? { Value::String(hex_value) => Ok(Some(decode_hex(&hex_value)?)), _ => Ok(None), } } /// `chain_getFinalizedHead` -> hash of the latest finalized block. - pub async fn finalized_head(&self) -> Result { + pub async fn finalized_head(&self) -> Result { let value = self.call("chain_getFinalizedHead", json!([])).await?; value .as_str() .map(str::to_owned) - .ok_or_else(|| "chain_getFinalizedHead returned non-string".to_string()) + .ok_or_else(|| RpcError::FinalizedHeadNotString.into()) } /// Submit an extrinsic and wait for `inBlock` or `finalized`; returns the block hash. - pub async fn submit_and_watch(&self, extrinsic: &[u8]) -> Result { + pub async fn submit_and_watch( + &self, + extrinsic: &[u8], + ) -> Result { let extrinsic_hex = format!("0x{}", hex::encode(extrinsic)); let mut subscription = self .inner @@ -91,7 +166,10 @@ impl RpcClient { "author_unwatchExtrinsic", ) .await - .map_err(rpc_error_message)?; + .map_err(|err| RpcError::Request { + method: "author_submitAndWatchExtrinsic".to_string(), + source: err, + })?; let timeout = futures_timer::Delay::new(SUBMIT_TIMEOUT).fuse(); pin_mut!(timeout); @@ -100,16 +178,19 @@ impl RpcClient { pin_mut!(next); let status = futures::select! { item = next => item.ok_or_else(|| { - "author_submitAndWatchExtrinsic subscription ended".to_string() - })?.map_err(rpc_error_message)?, - () = timeout => return Err( - "timed out waiting for author_submitAndWatchExtrinsic inclusion".to_string() - ), + RpcError::SubmitSubscriptionEnded + })?.map_err(|err| RpcError::Request { + method: "author_submitAndWatchExtrinsic".to_string(), + source: err, + })?, + () = timeout => return Err(RpcError::SubmitTimeout.into()), }; tracing::debug!(?status, "allowance extrinsic status"); match extrinsic_status(&status) { ExtrinsicStatus::Included(hash) => return Ok(hash), - ExtrinsicStatus::Rejected(reason) => return Err(format!("extrinsic {reason}")), + ExtrinsicStatus::Rejected(reason) => { + return Err(RpcError::ExtrinsicRejected { status: reason }.into()); + } ExtrinsicStatus::Pending => {} } } @@ -143,27 +224,20 @@ fn extrinsic_status(status: &Value) -> ExtrinsicStatus { ExtrinsicStatus::Pending } -fn value_to_params(value: Value) -> Result { +fn value_to_params(value: Value) -> Result { let Value::Array(values) = value else { - return Err("RPC params must be a JSON array".to_string()); + return Err(RpcError::ParamsNotArray.into()); }; let mut params = RpcParams::new(); for value in values { - params.push(value).map_err(rpc_error_message)?; + params.push(value).map_err(RpcError::ParamEncode)?; } Ok(params) } -fn decode_hex(value: &str) -> Result, String> { +fn decode_hex(value: &str) -> Result, StatementAllowanceError> { hex::decode(value.strip_prefix("0x").unwrap_or(value)) - .map_err(|err| format!("decode hex storage value: {err}")) -} - -fn rpc_error_message(error: subxt_rpcs::Error) -> String { - match error { - subxt_rpcs::Error::User(error) => error.message, - other => other.to_string(), - } + .map_err(|err| RpcError::StorageHex(err).into()) } #[cfg(test)] diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs index 2bdd35a9..3ba81bf6 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -8,10 +8,13 @@ use parity_scale_codec::{Decode, Encode}; use sp_crypto_hashing::twox_128; +use thiserror::Error; +use verifiable::Error as VerifiableError; use verifiable::GenerateVerifiable; use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; -use super::extension::Metadata; +use super::StatementAllowanceError; +use super::extension::{Metadata, MetadataError}; use super::ring::blake2_128_concat; use super::rpc::RpcClient; @@ -20,6 +23,50 @@ pub const STATEMENT_STORE_PERIOD_SECONDS: u64 = 86_400; /// Bulletin long-term-storage claim context prefix. const LONG_TERM_STORAGE_CONTEXT_PREFIX: &[u8] = b"pop:polkadot.net/rsc-lts"; +/// Error while deriving aliases or selecting allowance slots. +#[derive(Debug, Error)] +pub enum SlotError { + /// Long-term storage period duration constant was zero. + #[error("Resources.LongTermStoragePeriodDuration is zero")] + LongTermStoragePeriodDurationZero, + /// Bandersnatch alias derivation failed. + #[error("{context} alias_in_context failed: {error:?}")] + AliasInContext { + /// Alias context name. + context: &'static str, + /// Alias derivation failure. + error: VerifiableError, + }, + /// No free statement-store slot was found. + #[error("no free StatementStore slot in period {period} (max {max})")] + NoFreeStatementStoreSlot { + /// Period scanned. + period: u32, + /// Maximum slot count. + max: u32, + }, + /// No free long-term-storage slot was found. + #[error("no free long-term-storage slot in period {period} (max {max})")] + NoFreeLongTermStorageSlot { + /// Period scanned. + period: u32, + /// Maximum slot count. + max: u8, + }, + /// Registration reached a block but the slot was not held by the target. + #[error( + "registration reached block {block_hash} but slot (period {period}, seq {seq}) is not held by the target account" + )] + RegistrationVerificationMismatch { + /// Block hash the registration reached. + block_hash: String, + /// Registration period. + period: u32, + /// Slot sequence. + seq: u32, + }, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] struct StatementStoreAllowanceEntry { account_id: [u8; 32], @@ -36,9 +83,9 @@ pub fn current_period(now_seconds: u64) -> u32 { pub fn current_long_term_storage_period( now_seconds: u64, period_duration: u32, -) -> Result { +) -> Result { if period_duration == 0 { - return Err("Resources.LongTermStoragePeriodDuration is zero".to_string()); + return Err(SlotError::LongTermStoragePeriodDurationZero.into()); } Ok((now_seconds / u64::from(period_duration)) as u32) } @@ -65,11 +112,20 @@ pub fn derive_long_term_storage_context(period: u32, counter: u8) -> [u8; 32] { } /// The slot alias for our `entropy` at `(period, seq)`. -pub fn slot_alias(entropy: [u8; 32], period: u32, seq: u32) -> Result<[u8; 32], String> { +pub fn slot_alias( + entropy: [u8; 32], + period: u32, + seq: u32, +) -> Result<[u8; 32], StatementAllowanceError> { let secret = BandersnatchVrfVerifiable::new_secret(entropy); let context = derive_slot_context(period, seq); - BandersnatchVrfVerifiable::alias_in_context(&secret, &context) - .map_err(|err| format!("alias_in_context failed: {err:?}")) + BandersnatchVrfVerifiable::alias_in_context(&secret, &context).map_err(|err| { + SlotError::AliasInContext { + context: "statement-store slot", + error: err, + } + .into() + }) } /// The long-term-storage slot alias for our `entropy` at `(period, counter)`. @@ -77,11 +133,16 @@ pub fn long_term_storage_alias( entropy: [u8; 32], period: u32, counter: u8, -) -> Result<[u8; 32], String> { +) -> Result<[u8; 32], StatementAllowanceError> { let secret = BandersnatchVrfVerifiable::new_secret(entropy); let context = derive_long_term_storage_context(period, counter); - BandersnatchVrfVerifiable::alias_in_context(&secret, &context) - .map_err(|err| format!("alias_in_context failed: {err:?}")) + BandersnatchVrfVerifiable::alias_in_context(&secret, &context).map_err(|err| { + SlotError::AliasInContext { + context: "long-term-storage slot", + error: err, + } + .into() + }) } /// `Resources.StatementStoreAllowances[period][alias]` storage key. @@ -109,10 +170,13 @@ fn spent_long_term_storage_alias_key(period: u32, alias: &[u8; 32]) -> Vec { } /// Max StatementStore slots per period from `Resources.LiteStmtStoreSlotsPerPeriod`. -fn max_slots(metadata: &Metadata) -> Result { +fn max_slots(metadata: &Metadata) -> Result { let bytes = metadata .constant("Resources", "LiteStmtStoreSlotsPerPeriod") - .ok_or_else(|| "Resources.LiteStmtStoreSlotsPerPeriod constant missing".to_string())?; + .ok_or(MetadataError::MissingConstant { + pallet: "Resources", + constant: "LiteStmtStoreSlotsPerPeriod", + })?; let mut buf = [0u8; 4]; let n = bytes.len().min(4); buf[..n].copy_from_slice(&bytes[..n]); @@ -121,19 +185,30 @@ fn max_slots(metadata: &Metadata) -> Result { /// Max long-term-storage claims per period from /// `Resources.LongTermStorageClaimsPerPeriod`. -fn long_term_storage_claims_per_period(metadata: &Metadata) -> Result { +fn long_term_storage_claims_per_period(metadata: &Metadata) -> Result { metadata .constant("Resources", "LongTermStorageClaimsPerPeriod") .and_then(|bytes| bytes.first().copied()) - .ok_or_else(|| "Resources.LongTermStorageClaimsPerPeriod constant missing".to_string()) + .ok_or_else(|| { + MetadataError::MissingConstant { + pallet: "Resources", + constant: "LongTermStorageClaimsPerPeriod", + } + .into() + }) } /// Long-term-storage period duration in seconds from /// `Resources.LongTermStoragePeriodDuration`. -pub fn long_term_storage_period_duration(metadata: &Metadata) -> Result { +pub fn long_term_storage_period_duration( + metadata: &Metadata, +) -> Result { let bytes = metadata .constant("Resources", "LongTermStoragePeriodDuration") - .ok_or_else(|| "Resources.LongTermStoragePeriodDuration constant missing".to_string())?; + .ok_or(MetadataError::MissingConstant { + pallet: "Resources", + constant: "LongTermStoragePeriodDuration", + })?; let mut buf = [0u8; 4]; let n = bytes.len().min(4); buf[..n].copy_from_slice(&bytes[..n]); @@ -157,13 +232,12 @@ pub async fn read_slot_account_at( period: u32, seq: u32, block_hash: &str, -) -> Result, String> { +) -> Result, StatementAllowanceError> { let alias = slot_alias(entropy, period, seq)?; let key = statement_store_allowance_key(period, &alias); Ok(rpc .get_storage_at(&key, block_hash) - .await - .map_err(|e| e.to_string())? + .await? .and_then(|bytes| entry_account_id(&bytes))) } @@ -186,13 +260,13 @@ pub async fn scan_slot_excluding( target: &[u8; 32], excluded: &[u32], reuse_existing: bool, -) -> Result { +) -> Result { let max = max_slots(metadata)?; let mut first_free: Option = None; for seq in 0..max { let alias = slot_alias(entropy, period, seq)?; let key = statement_store_allowance_key(period, &alias); - match rpc.get_storage(&key).await.map_err(|e| e.to_string())? { + match rpc.get_storage(&key).await? { None => { if first_free.is_none() && !excluded.contains(&seq) { first_free = Some(seq); @@ -207,7 +281,7 @@ pub async fn scan_slot_excluding( } first_free .map(SlotSelection::Free) - .ok_or_else(|| format!("no free StatementStore slot in period {period} (max {max})")) + .ok_or_else(|| SlotError::NoFreeStatementStoreSlot { period, max }.into()) } /// Scan long-term-storage aliases `0..max` for `period`, returning the first @@ -218,7 +292,7 @@ pub async fn scan_long_term_storage_counter_excluding( entropy: [u8; 32], period: u32, excluded: &[u8], -) -> Result { +) -> Result { let max = long_term_storage_claims_per_period(metadata)?; for counter in 0..max { if excluded.contains(&counter) { @@ -226,18 +300,11 @@ pub async fn scan_long_term_storage_counter_excluding( } let alias = long_term_storage_alias(entropy, period, counter)?; let key = spent_long_term_storage_alias_key(period, &alias); - if rpc - .get_storage(&key) - .await - .map_err(|e| e.to_string())? - .is_none() - { + if rpc.get_storage(&key).await?.is_none() { return Ok(counter); } } - Err(format!( - "no free long-term-storage slot in period {period} (max {max})" - )) + Err(SlotError::NoFreeLongTermStorageSlot { period, max }.into()) } #[cfg(test)] diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index 7e2bca0f..33ef267e 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -59,7 +59,9 @@ impl StatementStore for ProductRuntimeHost { .await .map_err(|reason| { CallError::Domain(RemoteStatementStoreSubscribeError::V1( - latest::GenericError { reason }, + latest::GenericError { + reason: reason.to_string(), + }, )) })?; let subscription = statement_store_rpc::subscribe(&rpc_client, kind, &topics) diff --git a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs index 3abbcf48..5a1d2755 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs @@ -13,7 +13,9 @@ use serde_json::{Value, json}; use subxt_rpcs::RpcClient; use subxt_rpcs::client::{RpcSubscription, rpc_params}; +use thiserror::Error; use tracing::warn; +use truapi::latest::GenericError; use truapi_platform::{JsonRpcConnection, Platform}; use crate::host_logic::statement_store::{ @@ -26,6 +28,19 @@ use crate::subscription::Spawner; const SSO_NO_ALLOWANCE_RETRY_ATTEMPTS: usize = 5; const SSO_NO_ALLOWANCE_RETRY_DELAY: Duration = Duration::from_secs(1); +/// Error opening a statement-store RPC client over the host platform. +#[derive(Debug, Error)] +pub(crate) enum StatementStoreRpcClientError { + /// The host failed to open a People-chain JSON-RPC connection. + #[error("{label} connect failed: {error:?}")] + Connect { + /// Operation label requesting the connection. + label: &'static str, + /// Host platform error. + error: GenericError, + }, +} + /// People-chain statement-store RPC client factory. #[derive(Clone)] pub(crate) struct StatementStoreRpc { @@ -50,7 +65,10 @@ impl StatementStoreRpc { /// Open a statement-store RPC client over the host-provided People-chain /// connection. - pub(super) async fn client(&self, label: &'static str) -> Result { + pub(super) async fn client( + &self, + label: &'static str, + ) -> Result { let connection = self.connect(label).await?; Ok(RpcClient::new(HostRpcClient::new( connection, @@ -64,7 +82,7 @@ impl StatementStoreRpc { statement: Vec, label: &'static str, ) -> Result<(), String> { - let rpc_client = self.client(label).await?; + let rpc_client = self.client(label).await.map_err(|err| err.to_string())?; submit(&rpc_client, statement).await } @@ -76,7 +94,7 @@ impl StatementStoreRpc { statement: Vec, label: &'static str, ) -> Result<(), String> { - let rpc_client = self.client(label).await?; + let rpc_client = self.client(label).await.map_err(|err| err.to_string())?; submit_sso(&rpc_client, statement, label).await } @@ -86,7 +104,7 @@ impl StatementStoreRpc { statement: Vec, label: &'static str, ) -> Result<(), String> { - let connection = self.connect(label).await?; + let connection = self.connect(label).await.map_err(|err| err.to_string())?; HostRpcClient::new(connection, self.spawner.clone()) .send_fire_and_forget( SUBMIT_STATEMENT_METHOD, @@ -95,12 +113,15 @@ impl StatementStoreRpc { .map_err(rpc_error_message) } - async fn connect(&self, label: &'static str) -> Result, String> { + async fn connect( + &self, + label: &'static str, + ) -> Result, StatementStoreRpcClientError> { self.platform .connect(self.people_chain_genesis_hash) .await .map(Arc::from) - .map_err(|err| format!("{label} connect failed: {err:?}")) + .map_err(|error| StatementStoreRpcClientError::Connect { label, error }) } } From 922a97f470bfee4aa9c49a0c5231e1d55be7f611 Mon Sep 17 00:00:00 2001 From: pg Date: Mon, 27 Jul 2026 17:54:30 +0200 Subject: [PATCH 20/34] update --- CLAUDE.md | 1 + README.md | 13 ++ rust/crates/truapi-host-cli/README.md | 41 ++-- rust/crates/truapi-host-cli/SPEC.md | 7 +- scripts/battery.sh | 259 ++++++++++++++++++++++++++ 5 files changed, 308 insertions(+), 13 deletions(-) create mode 100755 scripts/battery.sh diff --git a/CLAUDE.md b/CLAUDE.md index bb360b08..aef6089f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ playground/ Next.js interactive playground; deploys to truapi-pla hosts/dotli/ dotli submodule docs/ design docs, RFCs, feature proposals scripts/codegen.sh regenerate the TS client from the Rust crate +scripts/battery.sh run the generated battery against both headless CLI host roles ``` ### Crate + binding invariants diff --git a/README.md b/README.md index e5b62cd4..fd0947e0 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ playground/ Interactive Next.js playground (truapi-playground.dot hosts/dotli/ dotli host, vendored as a submodule docs/ Design docs, RFCs, feature proposals scripts/codegen.sh Regenerate the TS client from the Rust source +scripts/battery.sh Run the generated battery against both headless CLI host roles ``` ### JS Host SDKs @@ -113,6 +114,18 @@ automation. See the [`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) for setup, controls, and examples. +`scripts/battery.sh` drives that CLI from source over every code-generated +example and writes both committed compatibility reports: +`explorer/diagnosis-reports/signing-host-cli.md` from a direct signing-host run, +and `pairing-host-cli.md` from a pairing host that the script pairs with a +signing host it starts itself. + +```bash +scripts/battery.sh # both phases +scripts/battery.sh --signing-host # direct phase only +scripts/battery.sh --pairing-host # paired phase only +``` + To run the playground locally: ```bash diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index 9db0de12..0820de7f 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -245,28 +245,46 @@ Five scripts ship under `js/scripts/`: the role-specific report under `explorer/diagnosis-reports/`, and exits nonzero if any example fails. A paired run writes `pairing-host-cli.md`; a direct signing-host run writes `signing-host-cli.md`. Override the artifact - path with `TRUAPI_BATTERY_REPORT_PATH`. Run the complete generated Playground - surface directly against the signing host: + path with `TRUAPI_BATTERY_REPORT_PATH`. + + `scripts/battery.sh` at the repo root is the supported entry point. It + prepares the codegen output and playground dependencies the battery imports, + builds the host from source, and produces both reports in one invocation: the + direct signing-host phase, then the paired phase, where it starts a pairing + host, reads the `polkadotapp://pair?...` link out of its transcript, and + answers it with a second signing host so the battery can complete: ```bash - target/debug/truapi-host signing-host \ - --product-id truapi-playground.dot \ - --script rust/crates/truapi-host-cli/js/scripts/battery.ts \ - --auto-accept + scripts/battery.sh # both phases + scripts/battery.sh --signing-host # direct phase only + scripts/battery.sh --pairing-host # paired phase only + scripts/battery.sh --release # release binary + scripts/battery.sh -- --network foo # arguments after `--` go to both hosts ``` - To exercise the paired topology, run the same script with `pairing-host`, - then answer its emitted link from a second terminal: + `BATTERY_PHASE_TIMEOUT` (default 900s) bounds each phase and + `BATTERY_PAIRING_TIMEOUT` (default 120s) bounds the wait for the pairing link. + Per-phase host transcripts land in `target/battery/`. + + The paired phase gives its pairing host a throwaway `--base-path` under + `target/battery/pairing-host-state`, so it performs a real handshake on every + run. A pairing host that restores an earlier session reports + `AlreadyConnected` and then fails every remote example, because the signing + host that session was paired with is no longer running. The signing host keeps + the default base path and reuses its attested account. + + To drive the paired topology by hand instead, start the pairing host and + answer its emitted link from a second terminal: ```bash # Terminal 1 - target/debug/truapi-host pairing-host \ + cargo run -p truapi-host-cli -- pairing-host \ --product-id truapi-playground.dot \ --script rust/crates/truapi-host-cli/js/scripts/battery.ts \ --auto-accept # Terminal 2 - target/debug/truapi-host signing-host \ + cargo run -p truapi-host-cli -- signing-host \ --deeplink '' \ --auto-accept ``` @@ -284,7 +302,8 @@ live routing enabled, `Chain/stop_transaction` uses host-owned operation ids and treats already-finished provider operations as stopped. `Preimage/*` also uses the real Bulletin Next chain and asks the signing host to claim People-chain long-term storage before returning the product-scoped Bulletin allowance key. -It needs the playground's deps (`cd playground && bun install`). Repeated live +It needs the playground's deps (`cd playground && yarn install --frozen-lockfile`; +bun does not resolve the `link:` dependency on `@parity/truapi`). Repeated live runs can exhaust the signer's per-period Statement Store or Bulletin allocation slots; the signing host rotates auto-managed signer accounts when Statement Store slots are exhausted. diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index 271139ba..f6f51f53 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -667,7 +667,10 @@ The top-level `--script` option does not update remembered `/script` state. | `preimage-smoke.ts` | Exercise Bulletin preimage submission and lookup. | `battery.ts` writes to `explorer/diagnosis-reports/-cli.md` unless -`TRUAPI_BATTERY_REPORT_PATH` overrides the destination. Its custom reporter +`TRUAPI_BATTERY_REPORT_PATH` overrides the destination. `scripts/battery.sh` in +the repository root produces both reports in one invocation: it runs the direct +signing-host phase, then starts a pairing host and answers its emitted link +with a second signing host so the paired phase can complete. Its custom reporter uses terminal color when stdout is a TTY or `FORCE_COLOR` is nonzero, unless `NO_COLOR` exists. @@ -1527,7 +1530,7 @@ The implementation is covered by: - `truapi-server` runtime, protocol, cryptographic vector, and integration tests; - script-runner/Bun diagnosis tests; -- paired and direct `battery.ts` runs; and +- paired and direct `battery.ts` runs, both driven by `scripts/battery.sh`; and - checked-in compatibility reports: - `explorer/diagnosis-reports/pairing-host-cli.md` - `explorer/diagnosis-reports/signing-host-cli.md` diff --git a/scripts/battery.sh b/scripts/battery.sh new file mode 100755 index 00000000..63ffea77 --- /dev/null +++ b/scripts/battery.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +# Run the generated full-surface battery against the headless truapi-host CLI, +# built from source, and write both committed CLI diagnosis reports: +# +# explorer/diagnosis-reports/signing-host-cli.md direct signing-host run +# explorer/diagnosis-reports/pairing-host-cli.md pairing host, paired with a +# signing host this script starts +# +# Usage: +# scripts/battery.sh # both phases +# scripts/battery.sh --signing-host # direct phase only +# scripts/battery.sh --pairing-host # paired phase only +# scripts/battery.sh --release # build and run the release binary +# scripts/battery.sh -- --network foo # arguments after `--` go to both hosts +# +# Environment: +# E2E_LIVE_CHAIN=1 route Chain/* at the network's real nodes +# HOST_CLI_SIGNER_MNEMONIC reuse a known signer instead of an auto-managed account +# BATTERY_PHASE_TIMEOUT seconds before a phase is killed (default 900) +# BATTERY_PAIRING_TIMEOUT seconds to wait for the pairing link (default 120) +# TRUAPI_BATTERY_REPORT_PATH override the report destination; single phase only +# +# Each phase exits nonzero when any generated example fails, which includes the +# services the host intentionally does not implement, so compare the reports +# against their committed versions to tell a regression from the baseline. +# +# Host transcripts land in target/battery/. The paired phase runs its pairing +# host on a throwaway identity under target/battery/pairing-host-state so every +# run performs a real handshake instead of restoring an earlier session. +# +# Run from anywhere; the script operates on its own checkout. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +# Homebrew LLVM on DYLD_LIBRARY_PATH makes rustc and rustdoc load a mismatched +# libLLVM, which dies with SIGSEGV in initialize_available_targets. +unset DYLD_LIBRARY_PATH + +SCRIPT="rust/crates/truapi-host-cli/js/scripts/battery.ts" +PRODUCT_ID="truapi-playground.dot" +REPORTS="explorer/diagnosis-reports" +LOG_DIR="target/battery" +PAIRING_STATE="target/battery/pairing-host-state" +PHASE_TIMEOUT="${BATTERY_PHASE_TIMEOUT:-900}" +PAIRING_TIMEOUT="${BATTERY_PAIRING_TIMEOUT:-120}" +PROFILE_DIR="debug" +CARGO_ARGS=() +HOST_ARGS=() +RUN_SIGNING=1 +RUN_PAIRING=1 + +usage() { + awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0" +} + +while [ $# -gt 0 ]; do + case "$1" in + --signing-host) RUN_PAIRING=0 ;; + --pairing-host) RUN_SIGNING=0 ;; + --release) CARGO_ARGS+=(--release); PROFILE_DIR="release" ;; + --product-id) + [ $# -ge 2 ] || { echo "battery: --product-id needs a value" >&2; exit 2; } + PRODUCT_ID="$2" + shift + ;; + --) shift; HOST_ARGS+=("$@"); break ;; + -h|--help) usage; exit 0 ;; + *) echo "battery: unknown option '$1' (pass host flags after '--')" >&2; exit 2 ;; + esac + shift +done + +if [ -n "${TRUAPI_BATTERY_REPORT_PATH:-}" ] && [ "$RUN_SIGNING" = 1 ] && [ "$RUN_PAIRING" = 1 ]; then + echo "battery: TRUAPI_BATTERY_REPORT_PATH would make both phases write the same file;" >&2 + echo " pass --signing-host or --pairing-host to run one of them" >&2 + exit 2 +fi + +# The battery imports the generated example manifest and the playground's +# example runner, so both the codegen output and the playground's dependency +# tree have to exist before a host starts. +generated=( + "js/packages/truapi/src/generated/client.ts" + "js/packages/truapi/src/playground/codegen/services.ts" +) +for path in "${generated[@]}"; do + if [ ! -f "$path" ]; then + echo "battery: regenerating client ($path missing)" + ./scripts/codegen.sh + break + fi +done + +# The playground resolves @parity/truapi through its built dist, so a checkout +# with generated sources but no build still needs one. +if [ ! -f "js/packages/truapi/dist/index.js" ]; then + echo "battery: building @parity/truapi" + [ -d node_modules ] || npm ci --ignore-scripts + npm run build --prefix js/packages/truapi +fi + +# The playground pulls @parity/truapi in over yarn's `link:` protocol, which +# bun does not resolve, so this install has to stay on yarn. +if [ ! -f "playground/node_modules/@parity/truapi/package.json" ]; then + echo "battery: installing playground dependencies" + ( cd playground && yarn install --frozen-lockfile ) +fi + +# The paired phase runs two hosts at once, so build once up front instead of +# letting concurrent `cargo run` invocations queue on the build lock. +cargo build ${CARGO_ARGS[@]+"${CARGO_ARGS[@]}"} -p truapi-host-cli +HOST="target/$PROFILE_DIR/truapi-host" +mkdir -p "$LOG_DIR" + +# Kill the phase's host after PHASE_TIMEOUT so a stalled pairing or a hung +# provider cannot park the script forever. The pid lands in GUARD_PID rather +# than on stdout, because a command substitution would wait for the backgrounded +# subshell to close the capture pipe. +GUARD_PID="" +start_watchdog() { + local pid="$1" label="$2" + ( + sleep "$PHASE_TIMEOUT" + if kill -0 "$pid" 2>/dev/null; then + echo "battery: $label exceeded ${PHASE_TIMEOUT}s, terminating" >&2 + kill -TERM "$pid" 2>/dev/null || true + fi + ) & + GUARD_PID=$! +} + +stop_watchdog() { + [ -n "$GUARD_PID" ] || return 0 + kill "$GUARD_PID" 2>/dev/null || true + GUARD_PID="" +} + +stop_host() { + local pid="$1" + kill -0 "$pid" 2>/dev/null || return 0 + kill -TERM "$pid" 2>/dev/null || true + for _ in $(seq 1 10); do + kill -0 "$pid" 2>/dev/null || return 0 + sleep 1 + done + kill -KILL "$pid" 2>/dev/null || true +} + +SIGNER_PID="" +cleanup() { + stop_watchdog + if [ -n "$SIGNER_PID" ]; then + stop_host "$SIGNER_PID" + fi +} +trap cleanup EXIT + +signing_phase() { + local log="$LOG_DIR/signing-host-cli.log" + local report="$ROOT/$REPORTS/signing-host-cli.md" + echo "battery: signing-host phase (report $REPORTS/signing-host-cli.md)" + TRUAPI_BATTERY_REPORT_PATH="${TRUAPI_BATTERY_REPORT_PATH:-$report}" \ + "$HOST" signing-host \ + --product-id "$PRODUCT_ID" \ + --script "$SCRIPT" \ + --auto-accept \ + ${HOST_ARGS[@]+"${HOST_ARGS[@]}"} > >(tee "$log") 2>&1 & + local host_pid=$! rc=0 + start_watchdog "$host_pid" "signing-host phase" + wait "$host_pid" || rc=$? + stop_watchdog + return "$rc" +} + +pairing_phase() { + local log="$LOG_DIR/pairing-host-cli.log" + local signer_log="$LOG_DIR/pairing-host-cli-signer.log" + local report="$ROOT/$REPORTS/pairing-host-cli.md" + echo "battery: pairing-host phase (report $REPORTS/pairing-host-cli.md)" + : > "$log" + # A pairing host that restores an earlier session skips the handshake and + # reports AlreadyConnected, then every remote example fails against the signing + # host that session was paired with and is no longer running. Start each run + # from an empty pairing identity so the handshake is always exercised. The + # signing host keeps the default base path, so it reuses its attested account. + rm -rf "$PAIRING_STATE" + TRUAPI_BATTERY_REPORT_PATH="${TRUAPI_BATTERY_REPORT_PATH:-$report}" \ + "$HOST" pairing-host \ + --base-path "$PAIRING_STATE" \ + --product-id "$PRODUCT_ID" \ + --script "$SCRIPT" \ + --auto-accept \ + ${HOST_ARGS[@]+"${HOST_ARGS[@]}"} > >(tee "$log") 2>&1 & + local host_pid=$! rc=0 + start_watchdog "$host_pid" "pairing-host phase" + + # The pairing host publishes a polkadotapp://pair link and then waits for a + # signer, so answer it with a second host from this side. + local deeplink="" waited=0 paired=0 + while [ "$waited" -lt "$PAIRING_TIMEOUT" ]; do + deeplink="$(grep -o 'polkadotapp://pair?[^[:space:]]*' "$log" | head -1 || true)" + [ -n "$deeplink" ] && break + if grep -q "Paired with" "$log"; then + paired=1 + break + fi + kill -0 "$host_pid" 2>/dev/null || break + sleep 1 + waited=$((waited + 1)) + done + + if [ -n "$deeplink" ]; then + echo "battery: answering pairing link with a signing host" + "$HOST" signing-host \ + --auto-accept \ + --frame-listen 127.0.0.1:0 \ + ${HOST_ARGS[@]+"${HOST_ARGS[@]}"} \ + exec "/pair $deeplink" > >(tee "$signer_log" | sed 's/^/[signer] /') 2>&1 & + SIGNER_PID=$! + elif [ "$paired" = 1 ]; then + echo "battery: pairing host restored a session; no link to answer" >&2 + else + echo "battery: no pairing link after ${PAIRING_TIMEOUT}s (see $log)" >&2 + stop_watchdog + stop_host "$host_pid" + wait "$host_pid" 2>/dev/null || true + return 1 + fi + + wait "$host_pid" || rc=$? + stop_watchdog + if [ -n "$SIGNER_PID" ]; then + stop_host "$SIGNER_PID" + SIGNER_PID="" + fi + return "$rc" +} + +SIGNING_RC="skipped" +PAIRING_RC="skipped" + +if [ "$RUN_SIGNING" = 1 ]; then + SIGNING_RC=0 + signing_phase || SIGNING_RC=$? +fi + +if [ "$RUN_PAIRING" = 1 ]; then + PAIRING_RC=0 + pairing_phase || PAIRING_RC=$? +fi + +echo +echo "battery: signing-host exit=$SIGNING_RC · pairing-host exit=$PAIRING_RC" +echo "battery: reports under $REPORTS/, logs under $LOG_DIR/" +[ "$SIGNING_RC" = 0 ] || [ "$SIGNING_RC" = "skipped" ] || exit "$SIGNING_RC" +[ "$PAIRING_RC" = 0 ] || [ "$PAIRING_RC" = "skipped" ] || exit "$PAIRING_RC" From 554688f8ce47d5ef95b60e615b93a3a83f2a27b5 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Mon, 27 Jul 2026 12:43:54 -0400 Subject: [PATCH 21/34] build: make `make headless` work without a prior JS install --- Makefile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Makefile b/Makefile index b6c65785..86c8a0a9 100644 --- a/Makefile +++ b/Makefile @@ -50,6 +50,11 @@ build: ## Build the Rust workspace and the TypeScript client. cd $(HOST_WASM_PKG) && npm run build headless: ## Build the truapi-host CLI and generated TypeScript client. + # The client build shells out to tsc, which `ensure-generated.sh` looks for at + # the root or in the package. Install workspace deps when neither is present so + # this target works on a checkout that has not run `make setup`. + @[ -x node_modules/.bin/tsc ] || [ -x $(TRUAPI_PKG)/node_modules/.bin/tsc ] \ + || npm ci --ignore-scripts cargo build -p truapi-host-cli cd $(TRUAPI_PKG) && npm run build From ff963a414f3245a0e5ea0f45c5bcfa20b692a503 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Mon, 27 Jul 2026 12:47:35 -0400 Subject: [PATCH 22/34] build: scope the BSL-1.0 licence allowance to the crates that need it --- deny.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/deny.toml b/deny.toml index 3c039293..43a7c265 100644 --- a/deny.toml +++ b/deny.toml @@ -8,7 +8,6 @@ allow = [ "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", - "BSL-1.0", "CC0-1.0", "CDLA-Permissive-2.0", "ISC", @@ -26,6 +25,10 @@ exceptions = [ # The Classpath exception permits linking it without changing this crate's # MIT outbound licence. { name = "verifiable", allow = ["GPL-3.0-or-later WITH Classpath-exception-2.0"] }, + # Windows clipboard support reached through the host CLI's terminal UI. + # BSL-1.0 is permissive, but scope it so it cannot spread to other crates. + { name = "clipboard-win", allow = ["BSL-1.0"] }, + { name = "error-code", allow = ["BSL-1.0"] }, { name = "uniffi", allow = ["MPL-2.0"] }, { name = "uniffi_bindgen", allow = ["MPL-2.0"] }, { name = "uniffi_core", allow = ["MPL-2.0"] }, From 3b3498f19f94a37aa2375d2dadf13d9df4275118 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Mon, 27 Jul 2026 12:59:20 -0400 Subject: [PATCH 23/34] fix(cli): redact signer secrets from Debug output --- rust/crates/truapi-host-cli/src/accounts.rs | 95 ++++++++++++++++++++- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/rust/crates/truapi-host-cli/src/accounts.rs b/rust/crates/truapi-host-cli/src/accounts.rs index 84ddb18b..b533ebb9 100644 --- a/rust/crates/truapi-host-cli/src/accounts.rs +++ b/rust/crates/truapi-host-cli/src/accounts.rs @@ -1,4 +1,5 @@ use std::collections::BTreeSet; +use std::fmt; use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; @@ -21,8 +22,11 @@ const ACCOUNT_STORE_FILE: &str = "accounts.json"; const ACCOUNT_STORE_LOCK_FILE: &str = "accounts.json.lock"; const DEFAULT_USERNAME_PREFIX: &str = "headless"; +/// Placeholder rendered by `Debug` in place of secret material. +const REDACTED: &str = ""; + /// Signer material selected for a signing-host session. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct ResolvedSigner { /// BIP-39 entropy backing the selected signer account. pub entropy: Vec, @@ -34,8 +38,20 @@ pub struct ResolvedSigner { pub auto_managed: bool, } +impl fmt::Debug for ResolvedSigner { + /// Redacts `entropy` so signer material cannot reach a log or error string. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ResolvedSigner") + .field("entropy", &REDACTED) + .field("account_name", &self.account_name) + .field("lite_username", &self.lite_username) + .field("auto_managed", &self.auto_managed) + .finish() + } +} + /// Inputs for resolving a signing-host account. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct ResolveSignerConfig<'a> { /// Directory containing the local account store. pub base_path: &'a Path, @@ -49,8 +65,21 @@ pub struct ResolveSignerConfig<'a> { pub lite_username_prefix: Option, } +impl fmt::Debug for ResolveSignerConfig<'_> { + /// Redacts `mnemonic` while keeping the non-secret resolution inputs. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ResolveSignerConfig") + .field("base_path", &self.base_path) + .field("network", &self.network) + .field("mnemonic", &self.mnemonic.as_ref().map(|_| REDACTED)) + .field("account", &self.account) + .field("lite_username_prefix", &self.lite_username_prefix) + .finish() + } +} + /// Stored signer account record. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct AccountRecord { /// Stable local account name, for example `auto-1`. pub name: String, @@ -73,6 +102,27 @@ pub struct AccountRecord { exhausted_statement_periods: BTreeSet, } +impl fmt::Debug for AccountRecord { + /// Redacts `mnemonic`. `AccountStoreData` and `AccountStore` render records + /// through this impl, so neither can print stored signer material either. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AccountRecord") + .field("name", &self.name) + .field("network", &self.network) + .field("mnemonic", &REDACTED) + .field("lite_username", &self.lite_username) + .field("public_key_hex", &self.public_key_hex) + .field("address", &self.address) + .field("created_at_unix", &self.created_at_unix) + .field("attested", &self.attested) + .field( + "exhausted_statement_periods", + &self.exhausted_statement_periods, + ) + .finish() + } +} + #[derive(Debug, Default, Serialize, Deserialize)] struct AccountStoreData { version: u32, @@ -616,6 +666,45 @@ mod tests { } } + #[test] + fn debug_output_redacts_signer_secrets() { + let signer = ResolvedSigner { + entropy: vec![0xab; 32], + account_name: Some("auto-1".to_string()), + lite_username: None, + auto_managed: true, + }; + let rendered = format!("{signer:?}"); + assert!( + !rendered.contains("171"), + "entropy bytes leaked: {rendered}" + ); + assert!(rendered.contains(REDACTED)); + assert!(rendered.contains("auto-1"), "non-secret fields dropped"); + + let config = ResolveSignerConfig { + base_path: Path::new("/tmp"), + network: crate::network::Network::PaseoNextV2.config(), + mnemonic: Some(MNEMONIC.to_string()), + account: None, + lite_username_prefix: None, + }; + let rendered = format!("{config:?}"); + assert!(!rendered.contains("abandon"), "mnemonic leaked: {rendered}"); + assert!(rendered.contains(REDACTED)); + + // AccountStoreData renders records through AccountRecord's impl, so the + // whole store is covered by redacting the record. + let mut store = AccountStore { + path: PathBuf::from("accounts.json"), + data: AccountStoreData::default(), + }; + store.upsert(record("auto-1", "paseo-next-v2", true)); + let rendered = format!("{:?}", store.data); + assert!(!rendered.contains("abandon"), "mnemonic leaked: {rendered}"); + assert!(rendered.contains(REDACTED)); + } + #[test] fn auto_candidate_skips_pending_and_exhausted_accounts() { let mut store = AccountStore { From c1a26ea2ed3f2676b4640a1495e342d2c3c54dfb Mon Sep 17 00:00:00 2001 From: tarikgul Date: Mon, 27 Jul 2026 13:04:52 -0400 Subject: [PATCH 24/34] test(cli): require every network preset to be a test network --- rust/crates/truapi-host-cli/src/network.rs | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/rust/crates/truapi-host-cli/src/network.rs b/rust/crates/truapi-host-cli/src/network.rs index 5d8a40d0..c1f90920 100644 --- a/rust/crates/truapi-host-cli/src/network.rs +++ b/rust/crates/truapi-host-cli/src/network.rs @@ -1,6 +1,11 @@ use clap::ValueEnum; /// Supported live network presets for the headless hosts. +/// +/// Every preset must be a test network. The CLI account store keeps BIP-39 +/// mnemonics in plaintext (`accounts.rs`), which is only acceptable for +/// disposable test identities, so adding a production preset means reworking +/// that storage first. The `every_preset_is_a_test_network` test enforces it. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] pub enum Network { #[value(name = "paseo-next-v2")] @@ -93,3 +98,34 @@ const fn hex_nibble(c: u8) -> u8 { _ => panic!("invalid hex digit in genesis literal"), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Guards the invariant documented on [`Network`]: the plaintext mnemonic + /// store is only safe for disposable identities, so no preset may point at a + /// production network. If this fails because a real network was added, + /// rework the account store rather than relaxing the assertion. + #[test] + fn every_preset_is_a_test_network() { + for network in Network::value_variants() { + let config = network.config(); + let mut routes = vec![ + config.identity_backend_base, + config.people_ws, + config.bulletin_ws, + ]; + routes.extend(config.live_chain_endpoints.iter().map(|chain| chain.ws)); + + for route in routes { + assert!( + route.contains("paseo") || route.contains("testnet"), + "preset `{}` routes to a host that is not a recognised test \ + network: {route}", + config.id, + ); + } + } + } +} From 3739244e83ad737b221d6c8b504cc8a22a2095d1 Mon Sep 17 00:00:00 2001 From: pg Date: Tue, 28 Jul 2026 10:05:47 +0200 Subject: [PATCH 25/34] Fix wasm runtime config error mapping --- rust/crates/truapi-server/src/wasm.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index 4c5c778f..74ea5998 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -505,8 +505,8 @@ fn runtime_config_validation_to_js(err: RuntimeConfigValidationError) -> JsValue "runtimeConfig.{} must not be empty", runtime_config_field_to_js(field) )), - RuntimeConfigValidationError::InvalidHostIcon { reason } => JsValue::from_str(&format!( - "runtimeConfig.host.icon must be an absolute HTTPS URL: {reason}" + RuntimeConfigValidationError::InvalidHostIcon { source } => JsValue::from_str(&format!( + "runtimeConfig.host.icon must be an absolute HTTPS URL: {source}" )), RuntimeConfigValidationError::InsecureHostIcon { scheme } => JsValue::from_str(&format!( "runtimeConfig.host.icon must use https scheme, got {scheme:?}" From 428cdd68f2b5d1eff1f26048330929fd3a8b2ce0 Mon Sep 17 00:00:00 2001 From: pg Date: Tue, 28 Jul 2026 10:13:09 +0200 Subject: [PATCH 26/34] Fix platform clone-on-copy clippy warning --- rust/crates/truapi-platform/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 2fc9b388..1c0789bc 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -465,7 +465,7 @@ impl CoreStorageKey { ) -> Self { Self::PermissionAuthorization { product_id: product_id.to_string(), - request: PermissionAuthorizationRequest::Device(permission.clone()), + request: PermissionAuthorizationRequest::Device(*permission), } } From 53abc3d22b8991ec35da77051478c3a990edcbe4 Mon Sep 17 00:00:00 2001 From: pg Date: Tue, 28 Jul 2026 10:43:35 +0200 Subject: [PATCH 27/34] Document People Lite registration prefix source --- rust/crates/truapi-server/src/host_logic/attestation.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/rust/crates/truapi-server/src/host_logic/attestation.rs b/rust/crates/truapi-server/src/host_logic/attestation.rs index 195882fb..7ecb008d 100644 --- a/rust/crates/truapi-server/src/host_logic/attestation.rs +++ b/rust/crates/truapi-server/src/host_logic/attestation.rs @@ -23,6 +23,11 @@ use crate::host_logic::product_account::{ use crate::host_logic::sso::pairing::{PairingBootstrapError, derive_p256_keypair_from_entropy}; /// sr25519 proof-of-ownership message prefix (exact bytes; one space). +/// +/// Canonical People Lite runtime source: +/// +/// +/// The pallet verifies `MSG_PREFIX || candidate || ring_vrf_key`. const REGISTER_PREFIX: &[u8] = b"pop:people-lite:register using"; /// Domain label for the P-256 identifier key advertised to the backend. const IDENTIFIER_KEY_LABEL: &[u8] = b"chat-encryption"; From b09aaf73ed25ba3a9c2df7b893aaaa300fc41ff4 Mon Sep 17 00:00:00 2001 From: pg Date: Wed, 29 Jul 2026 15:45:06 +0200 Subject: [PATCH 28/34] fix(rust): replace deprecated generic-array APIs --- .../truapi-server/src/host_logic/sso/pairing.rs | 16 +++++++++------- rust/crates/truapi-server/src/test_support.rs | 4 ++-- .../truapi-server/tests/wasm_crypto_vectors.rs | 4 ++-- 3 files changed, 13 insertions(+), 11 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 46770a67..0e8c3bb3 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -12,8 +12,8 @@ //! handshake codec: //! +use aes_gcm::Aes256Gcm; use aes_gcm::aead::{Aead, KeyInit}; -use aes_gcm::{Aes256Gcm, Nonce}; use hkdf::Hkdf; use p256::ecdh::diffie_hellman; use p256::elliptic_curve::sec1::ToEncodedPoint; @@ -190,7 +190,7 @@ pub fn encrypt_v2_handshake_response( let mut encrypted_message = nonce.to_vec(); encrypted_message.extend( cipher - .encrypt(Nonce::from_slice(&nonce), response.encode().as_slice()) + .encrypt((&nonce).into(), response.encode().as_slice()) .map_err(|err| format!("failed to encrypt SSO handshake response: {err}"))?, ); Ok(VersionedHandshakeResponse::V2 { @@ -377,7 +377,7 @@ pub fn encrypt_session_statement_data_with_nonce( let mut encrypted = nonce.to_vec(); encrypted.extend( cipher - .encrypt(Nonce::from_slice(&nonce), data.encode().as_slice()) + .encrypt((&nonce).into(), data.encode().as_slice()) .map_err(|err| format!("failed to encrypt SSO statement data: {err}"))?, ); Ok(encrypted) @@ -434,10 +434,13 @@ fn decrypt_aes_gcm_with_key( return Err(format!("encrypted SSO {label} is too short")); } let (nonce, ciphertext) = encrypted_message.split_at(AES_GCM_NONCE_LEN); + let nonce: &[u8; AES_GCM_NONCE_LEN] = nonce + .try_into() + .expect("nonce slice has the checked fixed length"); 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) + .decrypt(nonce.into(), ciphertext) .map_err(|err| format!("failed to decrypt SSO {label}: {err}")) } @@ -632,8 +635,7 @@ fn generate_p256_keypair() -> Result<([u8; 32], [u8; 65]), PairingBootstrapError } 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()); + let encryption_secret_key = secret.to_bytes().into(); return Ok((encryption_secret_key, encryption_public_key)); } @@ -783,7 +785,7 @@ mod tests { let mut encrypted = nonce.to_vec(); encrypted.extend( cipher - .encrypt(Nonce::from_slice(&nonce), sensitive.encode().as_slice()) + .encrypt((&nonce).into(), sensitive.encode().as_slice()) .unwrap(), ); diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index dc2291b0..be0f1cfc 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -17,8 +17,8 @@ use crate::subscription::Spawner; #[cfg(not(target_arch = "wasm32"))] use crate::subscription::thread_per_subscription_spawner; +use aes_gcm::Aes256Gcm; use aes_gcm::aead::{Aead, KeyInit}; -use aes_gcm::{Aes256Gcm, Nonce}; use futures::Stream; use futures::stream::{self, BoxStream}; use hkdf::Hkdf; @@ -567,7 +567,7 @@ fn wallet_handshake_statement_with_response( let mut encrypted_message = nonce.to_vec(); encrypted_message.extend( cipher - .encrypt(Nonce::from_slice(&nonce), answer.encode().as_slice()) + .encrypt((&nonce).into(), answer.encode().as_slice()) .unwrap(), ); let handshake = pairing::VersionedHandshakeResponse::V2 { diff --git a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs index 327d8c2c..3f312f53 100644 --- a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs +++ b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs @@ -3,8 +3,8 @@ #![cfg(target_arch = "wasm32")] +use aes_gcm::Aes256Gcm; use aes_gcm::aead::{Aead, KeyInit}; -use aes_gcm::{Aes256Gcm, Nonce}; use hkdf::Hkdf; use p256::SecretKey; use p256::ecdh::diffie_hellman; @@ -184,7 +184,7 @@ fn p256_hkdf_aes_gcm_vectors_work_on_wasm() { let mut encrypted = nonce.to_vec(); encrypted.extend( cipher - .encrypt(Nonce::from_slice(&nonce), sensitive.encode().as_slice()) + .encrypt((&nonce).into(), sensitive.encode().as_slice()) .unwrap(), ); From 14b8f25ec37aa3bfa4c2b2df6301d8228de14f0a Mon Sep 17 00:00:00 2001 From: tarikgul Date: Wed, 29 Jul 2026 19:44:24 -0400 Subject: [PATCH 29/34] fix(host-cli): do not block on the account store lock --- rust/crates/truapi-host-cli/src/accounts.rs | 48 ++++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/rust/crates/truapi-host-cli/src/accounts.rs b/rust/crates/truapi-host-cli/src/accounts.rs index b533ebb9..624cc6ec 100644 --- a/rust/crates/truapi-host-cli/src/accounts.rs +++ b/rust/crates/truapi-host-cli/src/accounts.rs @@ -135,6 +135,19 @@ pub struct AccountStore { data: AccountStoreData, } +/// Cross-process guard over the account store, held for the whole +/// provisioning flow so a concurrent instance cannot interleave writes. +/// +/// The guard is taken with a *non-blocking* `try_lock_exclusive`. A blocking +/// `lock_exclusive` here would be a blocking syscall inside async code, and it +/// is held across `attest_record` and `wait_for_ring_membership`, which poll for +/// up to ~80s (`attestation.rs` and `wait_for_ring_membership` both retry 10x +/// with 4s sleeps, on top of 30s HTTP timeouts). That combination has two +/// failure modes: a second instance waits the full provisioning window with no +/// output, and — once as many waiters as tokio worker threads are parked in the +/// syscall — the holder's timer can never be polled, so it never releases and +/// the wait never ends. Refusing immediately makes the contention visible and +/// keeps the runtime schedulable. struct AccountStoreLock { file: fs::File, } @@ -150,8 +163,14 @@ impl AccountStoreLock { .write(true) .open(&path) .with_context(|| format!("open lock {}", path.display()))?; - file.lock_exclusive() - .with_context(|| format!("lock {}", path.display()))?; + file.try_lock_exclusive().map_err(|err| { + anyhow::anyhow!( + "another truapi-host is using the account store at {} \ + ({err}); wait for it to finish or pass a different \ + --account-base-path", + base_path.display() + ) + })?; Ok(Self { file }) } } @@ -761,6 +780,31 @@ mod tests { Ok(()) } + #[test] + fn a_second_store_lock_is_refused_rather_than_waited_on() -> Result<()> { + let dir = tempdir()?; + let held = AccountStoreLock::acquire(dir.path())?; + + // With a blocking `lock_exclusive` this call parks the calling thread + // for as long as the first guard lives — inside async code that is up + // to the whole ~80s provisioning window, and it deadlocks outright once + // every tokio worker is parked here. It must fail fast instead. + let err = match AccountStoreLock::acquire(dir.path()) { + Ok(_) => panic!("a second exclusive lock must be refused"), + Err(err) => err, + }; + let message = format!("{err:#}"); + assert!( + message.contains("another truapi-host is using the account store"), + "unexpected error: {message}" + ); + + // Releasing the first guard makes the store available again. + drop(held); + let _reacquired = AccountStoreLock::acquire(dir.path())?; + Ok(()) + } + #[test] fn cached_signer_resolves_without_network_access() -> Result<()> { let dir = tempdir()?; From 9252bc5435464349cd945f371b5477a4a5ba90f9 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Wed, 29 Jul 2026 20:53:51 -0400 Subject: [PATCH 30/34] fix(host-cli): preserve unreadable storage instead of overwriting it --- rust/crates/truapi-host-cli/src/platform.rs | 144 +++++++++++++++++++- 1 file changed, 143 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index 65c6034d..e3a9b08d 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -973,12 +973,39 @@ fn product_storage_path(directory: &Path, product_id: &str) -> PathBuf { directory.join(format!("{slug}--{}.json", hex::encode(digest))) } +/// Extension appended to a storage file that could not be read, so its contents +/// survive for manual recovery. +const UNREADABLE_SUFFIX: &str = "unreadable"; + +/// Load a storage file, treating an unreadable one as empty. +/// +/// `read_string_map` collects into a `Result`, so one undecodable value fails the +/// whole file. Returning an empty map on that would be silently destructive: the +/// caller's next write persists the empty map over the file, taking every intact +/// entry with it — including keys this run never touched. So the file is first +/// moved aside to `.unreadable`, which keeps the data recoverable and makes +/// the warning actionable. fn load_string_map(path: &Path) -> HashMap> { match read_string_map(path) { Ok(values) => values, Err(err) if err.kind() == std::io::ErrorKind::NotFound => HashMap::new(), Err(err) => { - tracing::warn!(path = %path.display(), %err, "could not read CLI storage"); + let preserved = path.with_extension(UNREADABLE_SUFFIX); + match fs::rename(path, &preserved) { + Ok(()) => tracing::warn!( + path = %path.display(), + preserved = %preserved.display(), + %err, + "could not read CLI storage; moved it aside and started empty" + ), + Err(rename_err) => tracing::warn!( + path = %path.display(), + %err, + %rename_err, + "could not read CLI storage, and could not move it aside; \ + the next write will overwrite it" + ), + } HashMap::new() } } @@ -1084,6 +1111,121 @@ mod tests { use super::*; use tempfile::tempdir; + /// One undecodable value in `core-storage.json` must not cost the host the + /// entries it never touched. Before the file was moved aside, a real + /// `CliPlatform` discarded `PairingDeviceIdentity` and then overwrote it on + /// the next unrelated write. + /// + /// Keys on disk are hex of the SCALE-encoded `CoreStorageKey`, so + /// `AuthSession` is `00` and `PairingDeviceIdentity` is `01`. + #[tokio::test] + async fn cli_platform_preserves_unreadable_core_storage() { + use truapi_platform::CoreStorage; + + let dir = tempdir().expect("tempdir"); + let state_dir = dir.path().join("state"); + let product_dir = dir.path().join("products"); + std::fs::create_dir_all(&state_dir).expect("state dir"); + let core_path = state_dir.join("core-storage.json"); + + std::fs::write( + &core_path, + r#"{"values":{"00":"cafebabe","01":"deadbeef","ff":"zzzz"}}"#, + ) + .expect("seed core storage"); + + let platform = CliPlatform::new( + "", + &[], + Some(CliStoragePaths::new(state_dir, product_dir)), + ApprovalPolicy::AutoAccept, + None, + ); + + // Nothing loaded: the good entries went with the bad one. + assert_eq!( + platform + .read_core_storage(CoreStorageKey::AuthSession) + .await + .expect("read"), + None, + "AuthSession should have loaded but the file was discarded" + ); + assert_eq!( + platform + .read_core_storage(CoreStorageKey::PairingDeviceIdentity) + .await + .expect("read"), + None + ); + + // The host writes one unrelated key. That persists the whole map. + platform + .write_core_storage(CoreStorageKey::AuthSession, vec![0x42]) + .await + .expect("write"); + + let after = std::fs::read_to_string(&core_path).expect("read back"); + assert!( + after.contains("42"), + "the new AuthSession value should be persisted: {after}" + ); + + // The unreadable original is preserved beside it, so nothing is lost. + let preserved = std::fs::read_to_string(core_path.with_extension(UNREADABLE_SUFFIX)) + .expect("the unreadable file should have been moved aside"); + assert!( + preserved.contains("deadbeef") && preserved.contains("cafebabe"), + "the moved-aside file should still hold the original entries: {preserved}" + ); + } + + /// `read_string_map` collects into a `Result`, so one undecodable value fails + /// the whole file and the load yields nothing. That is survivable only + /// because the file is moved aside first — otherwise the next write persists + /// the empty map over it and every intact entry is gone. + #[test] + fn an_unreadable_storage_file_is_moved_aside_before_the_next_write() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("core-storage.json"); + + // Two entries the host cares about, plus one whose value is not hex. + let raw = r#"{"values":{ + "6175746853657373696f6e":"cafebabe", + "70616972696e674964656e74697479":"deadbeef", + "62726f6b656e":"zzzz" + }}"#; + std::fs::write(&path, raw).expect("seed"); + + // The two good entries are readable in isolation, so nothing about them + // is malformed — they are collateral. + assert!(raw.contains("cafebabe") && raw.contains("deadbeef")); + + // Load: everything is dropped, not just the bad entry. + let loaded = load_hex_key_map(&path); + assert!( + loaded.is_empty(), + "expected the whole file to be discarded, got {} entries", + loaded.len() + ); + + // A later write persists the empty map plus whatever is new. The original + // is no longer at `path`, so this must not be able to destroy it. + let mut next: HashMap, Vec> = loaded; + next.insert(b"fresh".to_vec(), b"\x01".to_vec()); + save_hex_key_map(&path, &next).expect("save"); + + let after = std::fs::read_to_string(&path).expect("read back"); + assert!(after.contains(&hex::encode(b"fresh"))); + + let preserved = std::fs::read_to_string(path.with_extension(UNREADABLE_SUFFIX)) + .expect("the unreadable file should have been moved aside"); + assert!( + preserved.contains("cafebabe") && preserved.contains("deadbeef"), + "the moved-aside file should still hold the original entries: {preserved}" + ); + } + fn test_storage_paths(root: &Path, session: &str) -> CliStoragePaths { CliStoragePaths::new(root.to_path_buf(), root.join("storage").join(session)) } From e41344d155e14c0dd6d715431f08777b3501ae5d Mon Sep 17 00:00:00 2001 From: tarikgul Date: Wed, 29 Jul 2026 21:10:25 -0400 Subject: [PATCH 31/34] fix(host-cli): back off before retrying a failed frame accept --- rust/crates/truapi-host-cli/src/frame_server.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi-host-cli/src/frame_server.rs b/rust/crates/truapi-host-cli/src/frame_server.rs index fac873fc..8cab7ed8 100644 --- a/rust/crates/truapi-host-cli/src/frame_server.rs +++ b/rust/crates/truapi-host-cli/src/frame_server.rs @@ -7,6 +7,7 @@ use std::net::SocketAddr; use std::sync::{Arc, RwLock}; +use std::time::Duration; use anyhow::{Context, Result}; use futures_util::{SinkExt, StreamExt}; @@ -19,6 +20,15 @@ use truapi_server::{ FrameSink, PairingHostRuntime, ProductContext, ProductRuntime, SigningHostRuntime, }; +/// Pause after a failed `accept()` before trying again. +/// +/// A failed accept leaves the peer in the listener's queue, so the listener stays +/// readable and an immediate retry fails the same way. Without a pause that is a +/// full-CPU loop emitting one warning per iteration. The errors that reach here +/// are either process-wide (`EMFILE`/`ENFILE`) or per-connection and transient +/// (`ECONNABORTED`); neither clears faster for being retried in a tight loop. +const ACCEPT_RETRY_DELAY: Duration = Duration::from_millis(50); + /// Process-local product selection shared by the command loop and frame server. pub struct ProductSelection { current: watch::Sender, @@ -151,7 +161,12 @@ pub async fn accept_loop( let (stream, peer) = match listener.accept().await { Ok(accepted) => accepted, Err(err) => { - warn!(%err, "product frame accept failed"); + warn!( + %err, + retry_in = ?ACCEPT_RETRY_DELAY, + "product frame accept failed" + ); + tokio::time::sleep(ACCEPT_RETRY_DELAY).await; continue; } }; From 041a468641b728d2bdda1f4ecc06904cdf871b7a Mon Sep 17 00:00:00 2001 From: tarikgul Date: Wed, 29 Jul 2026 21:59:05 -0400 Subject: [PATCH 32/34] fix(host-cli): isolate pairing storage for unusable usernames --- rust/crates/truapi-host-cli/src/platform.rs | 121 +++++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index e3a9b08d..09d3a118 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -214,7 +214,8 @@ impl CliPlatform { let Some(scope) = &self.pairing_scope else { return Ok(()); }; - crate::sessions::validate_name(user_id)?; + let user_id = pairing_storage_name(user_id); + let user_id = user_id.as_ref(); let target_state = scope.network_dir.join(format!("{user_id}_pairing_host")); let current_state = self .state_dir @@ -1074,6 +1075,26 @@ fn load_hex_key_map(path: &Path) -> HashMap, Vec> { .collect() } +/// Directory-safe name for one paired identity's storage namespace. +/// +/// The connected id is whatever the People-chain identity yields: a lite username +/// when there is one, otherwise the free-form `full_username`. Only the former is +/// guaranteed to satisfy [`crate::sessions::validate_name`], so a display name +/// like `"Tarik Gul"` is rejected on both the space and the capitals. +/// +/// Rejecting cannot mean "keep the previous namespace mounted" — that serves one +/// identity out of another's `state_dir`, core storage and product KV. So an +/// unusable id is replaced by the hex SHA-256 of its bytes, which isolates the +/// session regardless of what the chain returns. Hex is `[0-9a-f]`, so the derived +/// name is itself a legal session name and survives the `validate_name` check on +/// the read path in [`read_current_pairing_user`]. +fn pairing_storage_name(user_id: &str) -> std::borrow::Cow<'_, str> { + if crate::sessions::validate_name(user_id).is_ok() { + return std::borrow::Cow::Borrowed(user_id); + } + std::borrow::Cow::Owned(hex::encode(Sha256::digest(user_id.as_bytes()))) +} + const CURRENT_PAIRING_USER_FILE: &str = "current-user"; fn read_current_pairing_user(bootstrap_dir: &Path) -> Option { @@ -1111,6 +1132,104 @@ mod tests { use super::*; use tempfile::tempdir; + /// A user id `validate_name` rejects must not leave the previous identity's + /// storage mounted. `storage_user_id` falls back to `full_username`, a + /// free-form People-chain display name, and `validate_name` rejects uppercase, + /// spaces and non-ASCII — so `"Tarik Gul"` reaches this path. + #[test] + fn a_rejected_user_id_does_not_leave_the_previous_users_storage_mounted() { + let dir = tempdir().expect("tempdir"); + let network_dir = dir.path().join("paseo"); + std::fs::create_dir_all(&network_dir).expect("network dir"); + + let platform = CliPlatform::new( + "", + &[], + Some(CliStoragePaths::pairing(network_dir.clone())), + ApprovalPolicy::AutoAccept, + None, + ); + + // First identity: a valid lite username. + platform + .switch_pairing_user_storage("alice") + .expect("switch to alice"); + let alice_dir = platform.state_dir().expect("alice state dir"); + assert!( + alice_dir.ends_with("alice_pairing_host"), + "unexpected dir: {}", + alice_dir.display() + ); + + // Second identity arrives with a display name validate_name rejects. It + // must still get its own namespace rather than inheriting alice's. + platform + .switch_pairing_user_storage("Tarik Gul") + .expect("an unusable id must still isolate, not fail"); + + let after = platform.state_dir().expect("state dir after"); + assert_ne!( + after, + alice_dir, + "the rejected identity is still mounted on alice's storage at {}", + after.display() + ); + + // The derived name is the hex digest of the raw id, and is itself a legal + // session name so the persisted pointer survives a restart. + let expected = format!("{}_pairing_host", pairing_storage_name("Tarik Gul")); + assert!( + after.ends_with(&expected), + "expected a derived namespace, got {}", + after.display() + ); + assert!(crate::sessions::validate_name(&pairing_storage_name("Tarik Gul")).is_ok()); + } + + /// The same thing through the lifecycle the core actually drives: + /// `AuthPresenter::auth_state_changed` with a `Connected` state. That path + /// only logs a `warn!` on failure, so a rejected id silently inherits the + /// previous identity's namespace. + #[test] + fn auth_state_changed_does_not_inherit_storage_on_a_rejected_username() { + use truapi_platform::AuthPresenter; + + let dir = tempdir().expect("tempdir"); + let network_dir = dir.path().join("paseo"); + std::fs::create_dir_all(&network_dir).expect("network dir"); + + let platform = CliPlatform::new( + "", + &[], + Some(CliStoragePaths::pairing(network_dir)), + ApprovalPolicy::AutoAccept, + None, + ); + + let connect = |lite: Option<&str>, full: Option<&str>| { + AuthState::Connected(SessionUiInfo { + lite_username: lite.map(str::to_string), + full_username: full.map(str::to_string), + ..SessionUiInfo::default() + }) + }; + + platform.auth_state_changed(connect(Some("alice"), None)); + let alice_dir = platform.state_dir().expect("alice state dir"); + assert!(alice_dir.ends_with("alice_pairing_host")); + + // No lite username, so storage_user_id falls back to full_username. + platform.auth_state_changed(connect(None, Some("Tarik Gul"))); + + let after = platform.state_dir().expect("state dir after"); + assert_ne!( + after, + alice_dir, + "second identity is serving out of alice's storage at {}", + after.display() + ); + } + /// One undecodable value in `core-storage.json` must not cost the host the /// entries it never touched. Before the file was moved aside, a real /// `CliPlatform` discarded `PairingDeviceIdentity` and then overwrote it on From c29c68aba1e999c7e6dfc387cc40109dfbe532ca Mon Sep 17 00:00:00 2001 From: tarikgul Date: Wed, 29 Jul 2026 22:08:45 -0400 Subject: [PATCH 33/34] test(host-cli): assert pairing isolation on product KV, not the session --- rust/crates/truapi-host-cli/src/platform.rs | 32 ++++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index 09d3a118..3005cdaa 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -1188,10 +1188,14 @@ mod tests { /// The same thing through the lifecycle the core actually drives: /// `AuthPresenter::auth_state_changed` with a `Connected` state. That path - /// only logs a `warn!` on failure, so a rejected id silently inherits the - /// previous identity's namespace. - #[test] - fn auth_state_changed_does_not_inherit_storage_on_a_rejected_username() { + /// only logs a `warn!` on failure, so a rejected id used to silently inherit + /// the previous identity's namespace. + /// + /// Asserts the isolation itself, not just that the paths differ: a value + /// written while alice is connected must not be readable by the next + /// identity. + #[tokio::test] + async fn auth_state_changed_does_not_inherit_storage_on_a_rejected_username() { use truapi_platform::AuthPresenter; let dir = tempdir().expect("tempdir"); @@ -1218,6 +1222,19 @@ mod tests { let alice_dir = platform.state_dir().expect("alice state dir"); assert!(alice_dir.ends_with("alice_pairing_host")); + // Alice's product KV. `switch_pairing_user_storage` intentionally carries + // the transient pairing keys (AuthSession, PairingDeviceIdentity, + // LastProcessedPairingStatement) across namespaces, because the core + // writes a session before it knows the username. Product KV is the state + // the comment there promises stays with its own user, so that is what + // isolation is asserted on. + let alice_key = + ProductStorageKey::new("demo-product.dot", "secret").expect("product storage key"); + platform + .write(alice_key.encode(), b"alice-only".to_vec()) + .await + .expect("alice product write"); + // No lite username, so storage_user_id falls back to full_username. platform.auth_state_changed(connect(None, Some("Tarik Gul"))); @@ -1228,6 +1245,13 @@ mod tests { "second identity is serving out of alice's storage at {}", after.display() ); + + // The isolation that matters: alice's product KV is not visible. + assert_eq!( + platform.read(alice_key.encode()).await.expect("read"), + None, + "the second identity can read alice's product storage" + ); } /// One undecodable value in `core-storage.json` must not cost the host the From cc8dd2d687d68ae3f9e99b89c746374c6d87216b Mon Sep 17 00:00:00 2001 From: pg Date: Thu, 30 Jul 2026 10:53:01 +0200 Subject: [PATCH 34/34] fix(host-cli): refine CLI UX and default to Unix sockets --- .../dotli/helpers/signing-host-cli.test.ts | 2 - .../e2e/dotli/helpers/signing-host-cli.ts | 2 - rust/crates/truapi-host-cli/Cargo.toml | 4 +- rust/crates/truapi-host-cli/README.md | 27 +- rust/crates/truapi-host-cli/SPEC.md | 13 +- rust/crates/truapi-host-cli/js/runner.ts | 2 +- .../truapi-host-cli/js/ws-provider.test.ts | 265 ++++++++++++ rust/crates/truapi-host-cli/js/ws-provider.ts | 391 +++++++++++++++++- .../truapi-host-cli/src/frame_server.rs | 185 ++++++++- rust/crates/truapi-host-cli/src/main.rs | 173 ++++---- .../truapi-host-cli/src/script_runner.rs | 14 +- .../truapi-host-cli/src/signing_shell.rs | 69 +++- .../truapi-host-cli/tests/signing_host_cli.rs | 9 +- 13 files changed, 1034 insertions(+), 122 deletions(-) create mode 100644 rust/crates/truapi-host-cli/js/ws-provider.test.ts diff --git a/playground/tests/e2e/dotli/helpers/signing-host-cli.test.ts b/playground/tests/e2e/dotli/helpers/signing-host-cli.test.ts index 03f9f891..fc64490d 100644 --- a/playground/tests/e2e/dotli/helpers/signing-host-cli.test.ts +++ b/playground/tests/e2e/dotli/helpers/signing-host-cli.test.ts @@ -28,8 +28,6 @@ describe("signing-host CLI pairing", () => { "paseo-next-v2", "--base-path", "/repo/.e2e-dotli/signing-host", - "--frame-listen", - "127.0.0.1:0", "--auto-accept", "--lite-username-prefix", "dotlitest", diff --git a/playground/tests/e2e/dotli/helpers/signing-host-cli.ts b/playground/tests/e2e/dotli/helpers/signing-host-cli.ts index aef8b144..d6169334 100644 --- a/playground/tests/e2e/dotli/helpers/signing-host-cli.ts +++ b/playground/tests/e2e/dotli/helpers/signing-host-cli.ts @@ -37,8 +37,6 @@ export function signingHostPairArgs( config.network, "--base-path", config.basePath, - "--frame-listen", - "127.0.0.1:0", "--auto-accept", ]; if (config.liteUsernamePrefix !== undefined) { diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml index 9c2bcc81..72ef3613 100644 --- a/rust/crates/truapi-host-cli/Cargo.toml +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -38,12 +38,10 @@ subxt-rpcs = { version = "0.50.1", default-features = false, features = ["jsonrp tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "io-std", "net", "time", "signal", "process"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-tungstenite = { version = "0.24", features = ["connect", "rustls-tls-webpki-roots"] } +tempfile = "3" # TLS is compiled in for required People/Bulletin host traffic and optional # `Chain/*` playground methods. Product live-chain routing is opt-in # (`E2E_LIVE_CHAIN=1`); required host routes remain enabled. See src/chain.rs. tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } unicode-width = "0.2" - -[dev-dependencies] -tempfile = "3" diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index 0820de7f..87bf209d 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -36,6 +36,11 @@ make headless install # build dependencies and install truapi-host once truapi-host signing-host ``` +Product frames use a private, per-process WebSocket-over-Unix-domain-socket by +default, so starting either host does not reserve a TCP port. Pass +`--frame-listen 127.0.0.1:0` to expose an ordinary loopback WebSocket instead; +this is required for browser clients, which cannot open filesystem sockets. + The signing host opens an interactive terminal where you can paste a pairing link, type `/pair `, run `/script`, or use `/help` to discover the available commands. It uses `--mnemonic` / `HOST_CLI_SIGNER_MNEMONIC` if set. @@ -105,18 +110,16 @@ Non-interactive `--script` and `exec` runs use the same sentence-case event copy and status symbols without the full-screen chrome. This keeps captured logs readable while pairing URLs remain directly extractable by automation. `/copy` copies readable transcript text without UI chrome or complete pairing -links. Captured script output is plain text: Chalk sees piped stdout, and the -host strips terminal control sequences before adding child output to the -transcript. Raw ANSI styling such as bold is therefore not rendered in the -full-screen UI. +links. Captured script output is plain text: the host strips terminal control +sequences before adding child output to the transcript. Raw ANSI styling such +as bold is therefore not rendered in the full-screen UI. Bare `/script` reopens the last script recorded for the active session, including a path previously selected with `/script `. If that file is missing or the session has no script yet, it creates a durable Bun TypeScript -file under the active host state's `scripts/` directory. Its starter imports -`chalk` to demonstrate that scripts can import npm packages directly and let -Bun install missing dependencies automatically, then calls -`truapi.account.getUserId()`. +file under the active host state's `scripts/` directory. The dependency-free +starter uses ANSI colors and calls `truapi.account.getUserId()`. Scripts opened +from an npm project can import packages installed by that project. The TUI temporarily yields the terminal to `$VISUAL`, then `$EDITOR`, or `vi` when neither is set. After the editor exits successfully, the TUI is restored and the saved script runs through the public frame endpoint. Editor @@ -180,8 +183,8 @@ one-shot mode remains supported. ## Writing a product script A product script is top-level JavaScript or TypeScript (an ES module) run by -Bun. It can import npm dependencies directly; Bun installs missing packages -automatically. The runner injects three globals before running it: +Bun. It can import npm dependencies available beside the script or in a parent +project. The runner injects three globals before running it: - **`truapi`** — the `@parity/truapi` client connected to the pairing host and scoped to the host's `--product-id`. Call `truapi.account.requestLogin(...)`, @@ -382,7 +385,9 @@ truapi-host alloc-check --mnemonic "spin battle …" --lookback 100 Both hosts take `--network` (default `paseo-next-v2`). The network preset owns the identity backend URL, People RPC, Bulletin RPC, and genesis hashes; there is -no public `--statement-store` flag. +no public `--statement-store` flag. Both also accept `--frame-listen

` +to opt into a TCP product-frame WebSocket; without it, the CLI creates and +cleans up a unique temporary Unix socket. ## Scope / gaps diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index f6f51f53..d3584ffb 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -228,7 +228,7 @@ truapi-host pairing-host [options] | --- | --- | --- | | `--script ` | none | Run one JS/TS product script and exit with its status. | | `--product-id ` | `headless-playground.dot` | Initial product scope. | -| `--frame-listen ` | `127.0.0.1:9955` | Product WebSocket listener. Port `0` selects an available port. | +| `--frame-listen ` | none | Opt into a TCP product WebSocket listener. When omitted, use a private per-process Unix socket. Port `0` selects an available TCP port. | | `--base-path ` | section 12.1 | Root for network, identity, core, script, and product state. | | `--network ` | `paseo-next-v2` | Select the complete endpoint/genesis preset. | | `--auto-accept` | off | Approve platform confirmations automatically. | @@ -269,7 +269,7 @@ truapi-host signing-host [options] [exec ''] | `--lite-username-prefix ` | session-derived | Prefix for newly generated Lite username bases. | | `--base-path ` | section 12.1 | Root for account, session, core, script, and product state. | | `--network ` | `paseo-next-v2` | Select the complete endpoint/genesis preset. | -| `--frame-listen ` | `127.0.0.1:9956` | Direct product WebSocket listener. Port `0` is allowed. | +| `--frame-listen ` | none | Opt into a TCP product WebSocket listener. When omitted, use a private per-process Unix socket. Port `0` is allowed. | | `--auto-accept` | off | Approve platform confirmations automatically. | `HOST_CLI_SIGNER_MNEMONIC` supplies `--mnemonic` when the option is omitted. @@ -551,7 +551,8 @@ A product script is a JavaScript or TypeScript ES module executed by Bun. Before importing it, the runner: 1. reads its required environment; -2. opens the product-frame WebSocket, with a 15-second connection timeout; +2. opens the product-frame WebSocket over its Unix or TCP endpoint, with a + 15-second connection timeout; 3. creates the public `@parity/truapi` client; 4. injects the script globals; and 5. imports the absolute script URL. @@ -590,7 +591,7 @@ The Rust parent sets: | Variable | Meaning | | --- | --- | -| `TRUAPI_FRAME_URL` | Bound product-frame WebSocket URL. | +| `TRUAPI_FRAME_URL` | Bound product-frame endpoint: `ws+unix:/path` by default or `ws://address` in TCP mode. | | `TRUAPI_PRODUCT_ID` | Normalized active product id. | | `TRUAPI_SCRIPT` | Canonical absolute script path. | | `TRUAPI_CLI_HOST_ROLE` | `pairing-host` or `signing-host`. | @@ -1334,7 +1335,7 @@ Representative output: ```text • Listening for product frames - ws://127.0.0.1: + ws+unix:/tmp/truapi-host-…/frames.sock ✓ Paired ✓ Signing host ready ◌ Script running @@ -1342,6 +1343,8 @@ Representative output: ``` The same `SystemEvent` presentation code supplies plain and TUI wording. +Passing `--frame-listen 127.0.0.1:0` instead reports +`ws://127.0.0.1:`. ### 18.2 Lifecycle events diff --git a/rust/crates/truapi-host-cli/js/runner.ts b/rust/crates/truapi-host-cli/js/runner.ts index 1ea1bb53..57fede85 100644 --- a/rust/crates/truapi-host-cli/js/runner.ts +++ b/rust/crates/truapi-host-cli/js/runner.ts @@ -9,7 +9,7 @@ // promise exits non-zero, so `truapi-host pairing-host --script …` is the test. // // Env (set by the Rust CLI): -// TRUAPI_FRAME_URL ws:// URL of the pairing host's frame server +// TRUAPI_FRAME_URL ws+unix: or ws:// endpoint of the host frame server // TRUAPI_PRODUCT_ID product id the host serves (scopes storage etc.) // TRUAPI_SCRIPT absolute path to the user script import { pathToFileURL } from "node:url"; diff --git a/rust/crates/truapi-host-cli/js/ws-provider.test.ts b/rust/crates/truapi-host-cli/js/ws-provider.test.ts new file mode 100644 index 00000000..ca21de3f --- /dev/null +++ b/rust/crates/truapi-host-cli/js/ws-provider.test.ts @@ -0,0 +1,265 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer, type Server as NetServer, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Server } from "bun"; +import { wsProvider } from "./ws-provider.ts"; + +const servers: Server[] = []; +const netServers: NetServer[] = []; +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const server of servers.splice(0)) server.stop(true); + for (const server of netServers.splice(0)) server.close(); + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function unixServer( + websocket: NonNullable[0]["websocket"]>, +) { + const directory = mkdtempSync(join(tmpdir(), "truapi-ws-provider-")); + temporaryDirectories.push(directory); + const socketPath = join(directory, "frames.sock"); + const server = Bun.serve({ + unix: socketPath, + fetch(request, server) { + if (server.upgrade(request)) return; + return new Response("websocket upgrade required", { status: 426 }); + }, + websocket, + }); + servers.push(server); + return socketPath; +} + +async function rawUnixServer( + connection: (socket: Socket, request: string) => void, +): Promise { + const directory = mkdtempSync(join(tmpdir(), "truapi-ws-provider-")); + temporaryDirectories.push(directory); + const socketPath = join(directory, "frames.sock"); + const server = createServer((socket) => { + let request = Buffer.alloc(0); + const readHandshake = (chunk: Buffer) => { + request = Buffer.concat([request, chunk]); + const boundary = request.indexOf("\r\n\r\n"); + if (boundary < 0) return; + socket.off("data", readHandshake); + connection(socket, request.subarray(0, boundary).toString("utf8")); + }; + socket.on("data", readHandshake); + }); + netServers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + return socketPath; +} + +function acceptKey(request: string): string { + const key = request.match(/^Sec-WebSocket-Key:\s*(.+)$/im)?.[1]?.trim(); + if (!key) throw new Error("request did not include Sec-WebSocket-Key"); + return createHash("sha1") + .update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") + .digest("base64"); +} + +function upgradeResponse(request: string, accept = acceptKey(request)): string { + return [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${accept}`, + "", + "", + ].join("\r\n"); +} + +function serverFrame(fin: boolean, opcode: number, payload: number[]): Buffer { + if (payload.length > 125) + throw new Error("test helper only supports short frames"); + return Buffer.from([(fin ? 0x80 : 0) | opcode, payload.length, ...payload]); +} + +function readMaskedClientFrame(bytes: Buffer): { + opcode: number; + payload: number[]; +} { + const opcode = bytes[0]! & 0x0f; + const length = bytes[1]! & 0x7f; + if ((bytes[1]! & 0x80) === 0 || length > 125) { + throw new Error("expected a short masked client frame"); + } + const mask = bytes.subarray(2, 6); + const payload = Array.from(bytes.subarray(6, 6 + length), (byte, index) => { + return byte ^ mask[index % 4]!; + }); + return { opcode, payload }; +} + +describe("wsProvider Unix-domain WebSockets", () => { + test("buffers and round-trips a binary frame", async () => { + const socketPath = unixServer({ + message(websocket, message) { + websocket.send(message); + }, + }); + const provider = wsProvider(`ws+unix:${socketPath}`); + const received = new Promise((resolve) => { + provider.subscribe(resolve); + }); + + provider.postMessage(Uint8Array.from([1, 2, 3, 4])); + await provider.opened; + + const echoed = await received; + expect(Array.from(echoed)).toEqual([1, 2, 3, 4]); + expect(echoed.byteOffset).toBe(0); + expect(echoed.buffer.byteLength).toBe(echoed.byteLength); + provider.dispose(); + }); + + test("serializes a burst of product frames", async () => { + const socketPath = unixServer({ + message(websocket, message) { + websocket.send(message); + }, + }); + const provider = wsProvider(`ws+unix:${socketPath}`); + const expected = 128; + let received = 0; + const completed = new Promise((resolve) => { + provider.subscribe(() => { + received += 1; + if (received === expected) resolve(); + }); + }); + + await provider.opened; + for (let index = 0; index < expected; index += 1) { + provider.postMessage(new Uint8Array(2048).fill(index)); + } + + await completed; + expect(received).toBe(expected); + provider.dispose(); + }); + + test("round-trips a large product frame", async () => { + const socketPath = unixServer({ + message(websocket, message) { + websocket.send(message); + }, + }); + const provider = wsProvider(`ws+unix:${socketPath}`); + const received = new Promise((resolve) => { + provider.subscribe(resolve); + }); + const payload = new Uint8Array(256 * 1024); + payload[0] = 11; + payload[payload.length - 1] = 22; + + await provider.opened; + provider.postMessage(payload); + + const echoed = await received; + expect(echoed.length).toBe(payload.length); + expect([echoed[0], echoed[echoed.length - 1]]).toEqual([11, 22]); + provider.dispose(); + }); + + test("reports a server close to subscribers", async () => { + const socketPath = unixServer({ + open(websocket) { + websocket.close(1000, "done"); + }, + message() {}, + }); + const provider = wsProvider(`ws+unix:${socketPath}`); + const closed = new Promise((resolve) => { + provider.subscribeClose(resolve); + }); + + await provider.opened; + + expect((await closed).message).toBe("websocket closed"); + provider.dispose(); + }); + + test("reassembles fragments and answers ping frames", async () => { + let resolvePong!: (frame: { opcode: number; payload: number[] }) => void; + const pong = new Promise<{ opcode: number; payload: number[] }>( + (resolve) => { + resolvePong = resolve; + }, + ); + const socketPath = await rawUnixServer((socket, request) => { + socket.write( + Buffer.concat([ + Buffer.from(upgradeResponse(request)), + serverFrame(false, 0x2, [1, 2]), + serverFrame(true, 0x9, [9]), + serverFrame(true, 0x0, [3, 4]), + ]), + ); + socket.once("data", (bytes) => { + resolvePong(readMaskedClientFrame(Buffer.from(bytes))); + }); + }); + const provider = wsProvider(`ws+unix:${socketPath}`); + const received = new Promise((resolve) => { + provider.subscribe(resolve); + }); + + await provider.opened; + + expect(Array.from(await received)).toEqual([1, 2, 3, 4]); + expect(await pong).toEqual({ opcode: 0xa, payload: [9] }); + provider.dispose(); + }); + + test("rejects an invalid upgrade accept key", async () => { + const socketPath = await rawUnixServer((socket, request) => { + socket.write(upgradeResponse(request, "not-the-right-key")); + }); + const provider = wsProvider(`ws+unix:${socketPath}`); + + await expect(provider.opened).rejects.toThrow("invalid accept key"); + provider.dispose(); + }); +}); + +describe("wsProvider TCP WebSockets", () => { + test("keeps using the native WebSocket transport", async () => { + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request, server) { + if (server.upgrade(request)) return; + return new Response("websocket upgrade required", { status: 426 }); + }, + websocket: { + message(websocket, message) { + websocket.send(message); + }, + }, + }); + servers.push(server); + const provider = wsProvider(`ws://127.0.0.1:${server.port}`); + const received = new Promise((resolve) => { + provider.subscribe(resolve); + }); + + await provider.opened; + provider.postMessage(Uint8Array.from([5, 6, 7])); + + expect(Array.from(await received)).toEqual([5, 6, 7]); + provider.dispose(); + }); +}); diff --git a/rust/crates/truapi-host-cli/js/ws-provider.ts b/rust/crates/truapi-host-cli/js/ws-provider.ts index 7cd7a5e4..be6b88a5 100644 --- a/rust/crates/truapi-host-cli/js/ws-provider.ts +++ b/rust/crates/truapi-host-cli/js/ws-provider.ts @@ -1,8 +1,25 @@ -// WebSocket `WireProvider` for @parity/truapi: one binary WS message per -// SCALE protocol frame, sends buffered until the socket opens. +// WebSocket `WireProvider` for @parity/truapi: one binary WebSocket message +// per SCALE protocol frame. TCP endpoints use Bun's native WebSocket; Unix +// endpoints perform the same RFC 6455 protocol over a filesystem socket. +import { createHash, randomBytes } from "node:crypto"; +import { connect, type Socket } from "node:net"; import type { WireProvider } from "../../../../js/packages/truapi/src/index.ts"; -export function wsProvider(url: string): WireProvider & { opened: Promise } { +type FrameProvider = WireProvider & { opened: Promise }; + +const UNIX_WS_PREFIX = "ws+unix:"; +const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const MAX_MESSAGE_BYTES = 64 * 1024 * 1024; +const MAX_HANDSHAKE_BYTES = 16 * 1024; + +export function wsProvider(url: string): FrameProvider { + if (url.startsWith(UNIX_WS_PREFIX)) { + return unixWsProvider(parseUnixSocketPath(url)); + } + return nativeWsProvider(url); +} + +function nativeWsProvider(url: string): FrameProvider { const ws = new WebSocket(url); ws.binaryType = "arraybuffer"; const listeners = new Set<(message: Uint8Array) => void>(); @@ -10,8 +27,8 @@ export function wsProvider(url: string): WireProvider & { opened: Promise const pending: Uint8Array[] = []; let open = false; - let resolveOpened: () => void; - let rejectOpened: (error: Error) => void; + let resolveOpened!: () => void; + let rejectOpened!: (error: Error) => void; const opened = new Promise((resolve, reject) => { resolveOpened = resolve; rejectOpened = reject; @@ -55,3 +72,367 @@ export function wsProvider(url: string): WireProvider & { opened: Promise }, }; } + +function parseUnixSocketPath(url: string): string { + const path = url.slice(UNIX_WS_PREFIX.length); + if (!path.startsWith("/")) { + throw new Error(`invalid Unix WebSocket endpoint: ${url}`); + } + return path; +} + +function unixWsProvider(socketPath: string): FrameProvider { + const listeners = new Set<(message: Uint8Array) => void>(); + const closeListeners = new Set<(error: Error) => void>(); + const pending: Uint8Array[] = []; + const websocketKey = randomBytes(16).toString("base64"); + const expectedAccept = createHash("sha1") + .update(websocketKey + WEBSOCKET_GUID) + .digest("base64"); + const socket = connect(socketPath); + let handshakeBuffer = Buffer.alloc(0); + let frameBuffer = Buffer.alloc(0); + let fragmentedOpcode: number | undefined; + let fragmentedPayloads: Buffer[] = []; + let fragmentedLength = 0; + let handshakeComplete = false; + let openedSettled = false; + let closed = false; + let disposed = false; + let sentClose = false; + let writing = false; + let endAfterWrites = false; + let lastWriteLength = 0; + let largestWriteLength = 0; + let completedWrites = 0; + const writeQueue: Buffer[] = []; + + let resolveOpened!: () => void; + let rejectOpened!: (error: Error) => void; + const opened = new Promise((resolve, reject) => { + resolveOpened = resolve; + rejectOpened = reject; + }); + + const settleOpenError = (error: Error) => { + if (openedSettled) return; + openedSettled = true; + rejectOpened(error); + }; + const notifyClosed = (error: Error) => { + if (closed) return; + closed = true; + settleOpenError(error); + if (!disposed) { + for (const listener of closeListeners) listener(error); + } + }; + const fail = (error: Error, closeCode = 1002) => { + writeQueue.length = 0; + const sendClose = handshakeComplete && !sentClose && socket.writable; + if (sendClose) { + sentClose = true; + endAfterWrites = true; + writeQueue.push(clientFrame(0x8, closePayload(closeCode))); + } + notifyClosed(error); + if (sendClose) flushWrites(); + else socket.destroy(); + }; + const flushWrites = () => { + if (writing || !handshakeComplete) return; + if (!socket.writable) { + writeQueue.length = 0; + return; + } + const frame = writeQueue.shift(); + if (!frame) { + if (endAfterWrites && socket.writable) socket.end(); + return; + } + writing = true; + lastWriteLength = frame.length; + largestWriteLength = Math.max(largestWriteLength, frame.length); + socket.write(frame, () => { + completedWrites += 1; + writing = false; + flushWrites(); + }); + }; + const writeWebSocketFrame = (opcode: number, payload: Uint8Array) => { + if (closed || !socket.writable) return; + writeQueue.push(clientFrame(opcode, payload)); + flushWrites(); + }; + const emitMessage = (payload: Buffer) => { + // Match native WebSocket delivery: consumers may pass `.buffer` to the + // SCALE decoder, so never expose a view containing frame-header bytes. + const bytes = new Uint8Array(payload.length); + bytes.set(payload); + for (const listener of listeners) listener(bytes); + }; + const finishFragment = (payload: Buffer) => { + fragmentedPayloads.push(payload); + fragmentedLength += payload.length; + if (fragmentedLength > MAX_MESSAGE_BYTES) { + fail(new Error("websocket message exceeds maximum size"), 1009); + return; + } + emitMessage(Buffer.concat(fragmentedPayloads, fragmentedLength)); + fragmentedOpcode = undefined; + fragmentedPayloads = []; + fragmentedLength = 0; + }; + const handleFrame = (fin: boolean, opcode: number, payload: Buffer) => { + if (opcode >= 0x8) { + if (!fin || payload.length > 125) { + fail(new Error("invalid websocket control frame")); + return; + } + if (opcode === 0x8) { + if (payload.length === 1) { + fail(new Error("invalid websocket close frame")); + return; + } + if (!sentClose) { + sentClose = true; + writeWebSocketFrame(0x8, payload); + } + endAfterWrites = true; + notifyClosed(new Error("websocket closed")); + flushWrites(); + } else if (opcode === 0x9) { + writeWebSocketFrame(0xa, payload); + } else if (opcode !== 0xa) { + fail(new Error(`unsupported websocket opcode ${opcode}`)); + } + return; + } + + if (opcode === 0x0) { + if (fragmentedOpcode === undefined) { + fail(new Error("unexpected websocket continuation frame")); + return; + } + if (fin) { + finishFragment(payload); + } else { + fragmentedPayloads.push(payload); + fragmentedLength += payload.length; + if (fragmentedLength > MAX_MESSAGE_BYTES) { + fail(new Error("websocket message exceeds maximum size"), 1009); + } + } + return; + } + + if (opcode !== 0x1 && opcode !== 0x2) { + fail(new Error(`unsupported websocket opcode ${opcode}`)); + return; + } + if (fragmentedOpcode !== undefined) { + fail(new Error("new websocket message started before continuation")); + return; + } + if (fin) { + emitMessage(payload); + } else { + fragmentedOpcode = opcode; + fragmentedPayloads = [payload]; + fragmentedLength = payload.length; + } + }; + const parseFrames = () => { + while (!closed) { + if (frameBuffer.length < 2) return; + const first = frameBuffer[0]!; + const second = frameBuffer[1]!; + if ((first & 0x70) !== 0) { + fail(new Error("websocket extensions were not negotiated")); + return; + } + if ((second & 0x80) !== 0) { + fail(new Error("server websocket frames must not be masked")); + return; + } + + let headerLength = 2; + let payloadLength = second & 0x7f; + if (payloadLength === 126) { + if (frameBuffer.length < 4) return; + payloadLength = frameBuffer.readUInt16BE(2); + headerLength = 4; + } else if (payloadLength === 127) { + if (frameBuffer.length < 10) return; + const length = frameBuffer.readBigUInt64BE(2); + if (length > BigInt(Number.MAX_SAFE_INTEGER)) { + fail(new Error("websocket frame length is not representable"), 1009); + return; + } + payloadLength = Number(length); + headerLength = 10; + } + if (payloadLength > MAX_MESSAGE_BYTES) { + fail(new Error("websocket frame exceeds maximum size"), 1009); + return; + } + if (frameBuffer.length < headerLength + payloadLength) return; + const payload = frameBuffer.subarray( + headerLength, + headerLength + payloadLength, + ); + frameBuffer = frameBuffer.subarray(headerLength + payloadLength); + handleFrame((first & 0x80) !== 0, first & 0x0f, payload); + } + }; + const completeHandshake = (header: string, remaining: Buffer) => { + const lines = header.split("\r\n"); + const status = lines.shift() ?? ""; + if (!/^HTTP\/1\.[01] 101(?: |$)/.test(status)) { + fail( + new Error(`websocket upgrade failed: ${status || "empty response"}`), + ); + return; + } + const headers = new Map(); + for (const line of lines) { + const separator = line.indexOf(":"); + if (separator <= 0) continue; + headers.set( + line.slice(0, separator).trim().toLowerCase(), + line.slice(separator + 1).trim(), + ); + } + if (headers.get("upgrade")?.toLowerCase() !== "websocket") { + fail(new Error("websocket upgrade response is missing Upgrade")); + return; + } + const connection = headers.get("connection")?.toLowerCase() ?? ""; + if (!connection.split(",").some((value) => value.trim() === "upgrade")) { + fail(new Error("websocket upgrade response is missing Connection")); + return; + } + if (headers.get("sec-websocket-accept") !== expectedAccept) { + fail(new Error("websocket upgrade response has an invalid accept key")); + return; + } + + handshakeComplete = true; + openedSettled = true; + resolveOpened(); + for (const frame of pending.splice(0)) { + writeWebSocketFrame(0x2, frame); + } + frameBuffer = remaining; + parseFrames(); + }; + + socket.on("connect", () => { + socket.write( + [ + "GET / HTTP/1.1", + "Host: localhost", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Key: ${websocketKey}`, + "Sec-WebSocket-Version: 13", + "", + "", + ].join("\r\n"), + ); + }); + socket.on("data", (chunk) => { + if (closed) return; + const bytes = Buffer.from(chunk); + if (handshakeComplete) { + frameBuffer = Buffer.concat([frameBuffer, bytes]); + parseFrames(); + return; + } + + handshakeBuffer = Buffer.concat([handshakeBuffer, bytes]); + if (handshakeBuffer.length > MAX_HANDSHAKE_BYTES) { + fail(new Error("websocket upgrade response is too large")); + return; + } + const boundary = handshakeBuffer.indexOf("\r\n\r\n"); + if (boundary < 0) return; + const header = handshakeBuffer.subarray(0, boundary).toString("utf8"); + const remaining = handshakeBuffer.subarray(boundary + 4); + handshakeBuffer = Buffer.alloc(0); + completeHandshake(header, remaining); + }); + socket.on("error", (error) => { + notifyClosed( + new Error( + `websocket error: ${error.message} ` + + `(last write ${lastWriteLength} bytes, largest ${largestWriteLength}, ` + + `${completedWrites} completed, ${writeQueue.length} queued)`, + { cause: error }, + ), + ); + }); + socket.on("close", () => { + notifyClosed(new Error("websocket closed")); + }); + + return { + opened, + postMessage(message: Uint8Array) { + if (closed || disposed) return; + if (handshakeComplete) writeWebSocketFrame(0x2, message); + else pending.push(message); + }, + subscribe(cb: (message: Uint8Array) => void) { + listeners.add(cb); + return () => listeners.delete(cb); + }, + subscribeClose(cb: (error: Error) => void) { + closeListeners.add(cb); + return () => closeListeners.delete(cb); + }, + dispose() { + if (disposed) return; + disposed = true; + pending.length = 0; + if (handshakeComplete && !sentClose) { + sentClose = true; + endAfterWrites = true; + writeWebSocketFrame(0x8, closePayload(1000)); + } else { + socket.destroy(); + } + }, + }; +} + +function closePayload(code: number): Buffer { + const payload = Buffer.allocUnsafe(2); + payload.writeUInt16BE(code); + return payload; +} + +function clientFrame(opcode: number, value: Uint8Array): Buffer { + const payload = Buffer.from(value.buffer, value.byteOffset, value.byteLength); + const lengthBytes = + payload.length < 126 ? 0 : payload.length <= 0xffff ? 2 : 8; + const headerLength = 2 + lengthBytes + 4; + const frame = Buffer.allocUnsafe(headerLength + payload.length); + frame[0] = 0x80 | opcode; + if (lengthBytes === 0) { + frame[1] = 0x80 | payload.length; + } else if (lengthBytes === 2) { + frame[1] = 0x80 | 126; + frame.writeUInt16BE(payload.length, 2); + } else { + frame[1] = 0x80 | 127; + frame.writeBigUInt64BE(BigInt(payload.length), 2); + } + const maskOffset = 2 + lengthBytes; + const mask = randomBytes(4); + mask.copy(frame, maskOffset); + for (let index = 0; index < payload.length; index += 1) { + frame[headerLength + index] = payload[index]! ^ mask[index % 4]!; + } + return frame; +} diff --git a/rust/crates/truapi-host-cli/src/frame_server.rs b/rust/crates/truapi-host-cli/src/frame_server.rs index 8cab7ed8..fb818628 100644 --- a/rust/crates/truapi-host-cli/src/frame_server.rs +++ b/rust/crates/truapi-host-cli/src/frame_server.rs @@ -11,7 +11,10 @@ use std::time::Duration; use anyhow::{Context, Result}; use futures_util::{SinkExt, StreamExt}; -use tokio::net::{TcpListener, TcpStream}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::net::TcpListener; +#[cfg(unix)] +use tokio::net::UnixListener; use tokio::sync::{mpsc, watch}; use tokio_tungstenite::accept_async; use tokio_tungstenite::tungstenite::Message; @@ -136,11 +139,70 @@ impl FrameSink for WsFrameSink { } } -/// Bind the product-frame listener on `addr`. -pub async fn bind(addr: SocketAddr) -> Result { - TcpListener::bind(addr) - .await - .with_context(|| format!("frame server failed to bind {addr}")) +/// Bound product-frame endpoint. The temporary-directory guard keeps a Unix +/// socket alive for exactly as long as its listener. +pub struct BoundFrameServer { + listener: FrameListener, + endpoint: String, + #[cfg(unix)] + socket_directory: Option, +} + +enum FrameListener { + Tcp(TcpListener), + #[cfg(unix)] + Unix(UnixListener), +} + +impl BoundFrameServer { + /// Endpoint passed to the bundled product runner and shown in the CLI. + pub fn endpoint(&self) -> &str { + &self.endpoint + } +} + +/// Bind an explicit TCP WebSocket listener, or a private per-process Unix +/// socket when no TCP address was requested. +pub async fn bind(addr: Option) -> Result { + if let Some(addr) = addr { + let listener = TcpListener::bind(addr) + .await + .with_context(|| format!("frame server failed to bind {addr}"))?; + let endpoint = format!("ws://{}", listener.local_addr()?); + return Ok(BoundFrameServer { + listener: FrameListener::Tcp(listener), + endpoint, + #[cfg(unix)] + socket_directory: None, + }); + } + bind_unix() +} + +#[cfg(unix)] +fn bind_unix() -> Result { + let socket_directory = tempfile::Builder::new() + .prefix("truapi-host-") + .tempdir() + .context("create temporary product-frame socket directory")?; + let socket_path = socket_directory.path().join("frames.sock"); + let socket_path_text = socket_path + .to_str() + .context("product-frame Unix socket path is not valid UTF-8")?; + let listener = UnixListener::bind(&socket_path) + .with_context(|| format!("frame server failed to bind {}", socket_path.display()))?; + Ok(BoundFrameServer { + listener: FrameListener::Unix(listener), + endpoint: format!("ws+unix:{socket_path_text}"), + socket_directory: Some(socket_directory), + }) +} + +#[cfg(not(unix))] +fn bind_unix() -> Result { + anyhow::bail!( + "Unix-domain product sockets are unavailable on this platform; pass --frame-listen
" + ) } /// Accept product-frame connections on `listener` for `product_id` until @@ -152,11 +214,25 @@ pub async fn bind(addr: SocketAddr) -> Result { pub async fn accept_loop( runtime: Arc, product: Arc, - listener: TcpListener, + frame_server: BoundFrameServer, ) -> Result<()> { - let bound = listener.local_addr()?; let product_id = product.current(); - debug!(%bound, %product_id, "product frame server listening"); + let endpoint = frame_server.endpoint.clone(); + debug!(%endpoint, %product_id, "product frame server listening"); + #[cfg(unix)] + let _socket_directory = frame_server.socket_directory; + match frame_server.listener { + FrameListener::Tcp(listener) => accept_tcp_loop(runtime, product, listener).await, + #[cfg(unix)] + FrameListener::Unix(listener) => accept_unix_loop(runtime, product, listener).await, + } +} + +async fn accept_tcp_loop( + runtime: Arc, + product: Arc, + listener: TcpListener, +) -> Result<()> { loop { let (stream, peer) = match listener.accept().await { Ok(accepted) => accepted, @@ -180,11 +256,38 @@ pub async fn accept_loop( } } -async fn serve_connection( +#[cfg(unix)] +async fn accept_unix_loop( runtime: Arc, - selected_product: Arc, - stream: TcpStream, + product: Arc, + listener: UnixListener, ) -> Result<()> { + loop { + let (stream, peer) = match listener.accept().await { + Ok(accepted) => accepted, + Err(err) => { + warn!(%err, "product frame accept failed"); + continue; + } + }; + let runtime = runtime.clone(); + let product = product.clone(); + tokio::spawn(async move { + if let Err(err) = serve_connection(runtime, product, stream).await { + debug!(?peer, %err, "frame connection ended"); + } + }); + } +} + +async fn serve_connection( + runtime: Arc, + selected_product: Arc, + stream: S, +) -> Result<()> +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ // Subscribe before resolving the runtime so a concurrent replacement can // only cause an extra reconnect, never leave a connection on stale state. let mut reset = runtime.connection_reset(); @@ -253,6 +356,8 @@ async fn connection_reset(reset: &mut Option>) { #[cfg(test)] mod tests { use super::*; + use futures_util::{SinkExt, StreamExt}; + use tokio_tungstenite::client_async; #[test] fn product_selection_validates_and_normalizes_ids() -> Result<()> { @@ -278,4 +383,60 @@ mod tests { assert!(!connection.has_changed()?); Ok(()) } + + #[tokio::test] + async fn explicit_tcp_listener_reports_the_actual_bound_port() -> Result<()> { + let server = bind(Some("127.0.0.1:0".parse()?)).await?; + + assert!(server.endpoint().starts_with("ws://127.0.0.1:")); + assert!(!server.endpoint().ends_with(":0")); + Ok(()) + } + + #[cfg(unix)] + #[tokio::test] + async fn default_unix_listener_carries_websocket_frames_and_cleans_up() -> Result<()> { + use tokio::net::UnixStream; + + let server = bind(None).await?; + let socket_path = server + .endpoint() + .strip_prefix("ws+unix:") + .expect("Unix endpoint prefix"); + let socket_path = std::path::PathBuf::from(socket_path); + let socket_directory = socket_path + .parent() + .expect("socket has a parent directory") + .to_path_buf(); + assert!(socket_path.exists()); + + let FrameListener::Unix(listener) = &server.listener else { + panic!("default listener must be Unix"); + }; + let server_exchange = async { + let (stream, _) = listener.accept().await?; + let mut websocket = accept_async(stream).await?; + let message = websocket + .next() + .await + .context("client closed before sending")??; + websocket.send(message).await?; + Ok::<(), anyhow::Error>(()) + }; + let client_exchange = async { + let stream = UnixStream::connect(&socket_path).await?; + let (mut websocket, _) = client_async("ws://localhost/", stream).await?; + websocket.send(Message::Binary(vec![1, 2, 3, 4])).await?; + assert_eq!( + websocket.next().await.context("server did not echo")??, + Message::Binary(vec![1, 2, 3, 4]) + ); + Ok::<(), anyhow::Error>(()) + }; + tokio::try_join!(server_exchange, client_exchange)?; + + drop(server); + assert!(!socket_directory.exists()); + Ok(()) + } } diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 16d5de73..66d615a3 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -53,10 +53,6 @@ use crate::terminal_ui::{ /// Default product served by the pairing host's frame endpoint. Product ids /// must be a `.dot` name or a `localhost` identifier (host-spec product id). const DEFAULT_PRODUCT_ID: &str = "headless-playground.dot"; -/// Default product-frame address for the pairing host. -const DEFAULT_PAIRING_FRAME_LISTEN: &str = "127.0.0.1:9955"; -/// Default product-frame address for the signing host. -const DEFAULT_SIGNING_FRAME_LISTEN: &str = "127.0.0.1:9956"; /// Deeplink scheme advertised by the pairing host. const DEEPLINK_SCHEME: &str = "polkadotapp"; @@ -191,9 +187,10 @@ struct PairingHostArgs { /// Product id the host serves; scopes storage and product accounts. #[arg(long = "product-id", default_value = DEFAULT_PRODUCT_ID)] product_id: String, - /// Address to serve product frames on. - #[arg(long, default_value = DEFAULT_PAIRING_FRAME_LISTEN)] - frame_listen: SocketAddr, + /// TCP address to serve product WebSocket frames on. When omitted, use a + /// private per-process Unix-domain socket. + #[arg(long)] + frame_listen: Option, /// Root directory for CLI-managed host state. #[arg(long = "base-path", env = "TRUAPI_HOST_BASE_PATH")] base_path: Option, @@ -238,9 +235,10 @@ struct SigningHostArgs { /// Network preset that supplies all RPC/backend/genesis config. #[arg(long, value_enum, default_value = "paseo-next-v2")] network: Network, - /// Address to serve product frames on when running scripts. - #[arg(long, default_value = DEFAULT_SIGNING_FRAME_LISTEN)] - frame_listen: SocketAddr, + /// TCP address to serve product WebSocket frames on. When omitted, use a + /// private per-process Unix-domain socket. + #[arg(long)] + frame_listen: Option, /// Approve every confirmation without prompting on the CLI. #[arg(long)] auto_accept: bool, @@ -512,8 +510,8 @@ async fn run_pairing_host( let storage_platform = platform.clone(); let pairing_runtime = Arc::new(PairingHostRuntime::new(platform, config, tokio_spawner())); - let listener = frame_server::bind(args.frame_listen).await?; - let frame_url = format!("ws://{}", listener.local_addr()?); + let frame_server = frame_server::bind(args.frame_listen).await?; + let frame_url = frame_server.endpoint().to_string(); terminal_ui::output_event(SystemEvent::FramesListening { url: frame_url.clone(), }); @@ -522,7 +520,7 @@ async fn run_pairing_host( if let Some(script) = args.script { let script_product_id = product_id.clone(); let script_frame_url = frame_url.clone(); - let status = with_frame_server(runtime_for_frames, product, listener, async move { + let status = with_frame_server(runtime_for_frames, product, frame_server, async move { script_runner::run( &script_frame_url, &script_product_id, @@ -538,17 +536,22 @@ async fn run_pairing_host( } let terminal_ui = terminal_ui.context("interactive terminal was not initialized")?; - with_frame_server(runtime_for_frames, product.clone(), listener, async move { - pairing_interactive_loop( - frame_url, - product, - pairing_runtime, - storage_platform, - terminal_ui, - log_controller, - ) - .await - }) + with_frame_server( + runtime_for_frames, + product.clone(), + frame_server, + async move { + pairing_interactive_loop( + frame_url, + product, + pairing_runtime, + storage_platform, + terminal_ui, + log_controller, + ) + .await + }, + ) .await } @@ -603,8 +606,8 @@ async fn run_signing_host( ui_handle.clone(), ) .await?; - let listener = frame_server::bind(args.frame_listen).await?; - let frame_url = format!("ws://{}", listener.local_addr()?); + let frame_server = frame_server::bind(args.frame_listen).await?; + let frame_url = frame_server.endpoint().to_string(); terminal_ui::output_event(SystemEvent::FramesListening { url: frame_url.clone(), }); @@ -616,7 +619,7 @@ async fn run_signing_host( let script_product_id = product_id.clone(); let script_frame_url = frame_url.clone(); let initial_deeplink = args.deeplink.clone(); - let status = with_frame_server(runtime_for_frames, product, listener, async move { + let status = with_frame_server(runtime_for_frames, product, frame_server, async move { let mut responder = None; if let Some(deeplink) = initial_deeplink { prepare_pairing_response(&mut session, &deeplink).await?; @@ -653,51 +656,61 @@ async fn run_signing_host( let initial_deeplink = args.deeplink.clone(); if let Some(command) = exec_command { - return with_frame_server(runtime_for_frames, product.clone(), listener, async move { - let responder = if let Some(deeplink) = initial_deeplink { - prepare_pairing_response(&mut session, &deeplink).await?; - let runtime = session.runtime.clone(); - Some(tokio::spawn(async move { - match runtime.respond_to_pairing(&deeplink).await { - Ok(exit) => terminal_ui::output_event(SystemEvent::SigningHostExit { - outcome: format!("{exit:?}"), - }), - Err(err) => terminal_ui::output_event(SystemEvent::SigningHostError { - reason: err.reason, - }), - } - })) - } else { - None - }; - let result = execute_non_interactive_command( - &mut session, - &frame_url, - &product, - command, - &log_controller, - ) - .await; - if let Some(responder) = responder { - responder.abort(); - } - result - }) + return with_frame_server( + runtime_for_frames, + product.clone(), + frame_server, + async move { + let responder = if let Some(deeplink) = initial_deeplink { + prepare_pairing_response(&mut session, &deeplink).await?; + let runtime = session.runtime.clone(); + Some(tokio::spawn(async move { + match runtime.respond_to_pairing(&deeplink).await { + Ok(exit) => terminal_ui::output_event(SystemEvent::SigningHostExit { + outcome: format!("{exit:?}"), + }), + Err(err) => terminal_ui::output_event(SystemEvent::SigningHostError { + reason: err.reason, + }), + } + })) + } else { + None + }; + let result = execute_non_interactive_command( + &mut session, + &frame_url, + &product, + command, + &log_controller, + ) + .await; + if let Some(responder) = responder { + responder.abort(); + } + result + }, + ) .await; } let terminal_ui = terminal_ui.context("interactive terminal was not initialized")?; - with_frame_server(runtime_for_frames, product.clone(), listener, async move { - signing_interactive_loop( - &mut session, - frame_url, - product, - initial_deeplink, - terminal_ui, - log_controller, - ) - .await - }) + with_frame_server( + runtime_for_frames, + product.clone(), + frame_server, + async move { + signing_interactive_loop( + &mut session, + frame_url, + product, + initial_deeplink, + terminal_ui, + log_controller, + ) + .await + }, + ) .await } @@ -925,13 +938,13 @@ fn invalid_invocation(error: impl fmt::Display) -> ! { async fn with_frame_server( runtime: Arc, product: Arc, - listener: tokio::net::TcpListener, + frame_server: frame_server::BoundFrameServer, body: Fut, ) -> Result where Fut: Future>, { - let server = tokio::spawn(frame_server::accept_loop(runtime, product, listener)); + let server = tokio::spawn(frame_server::accept_loop(runtime, product, frame_server)); let result = body.await; server.abort(); result @@ -1969,6 +1982,26 @@ mod cli_tests { panic!("expected exec action"); }; assert_eq!(command, "/help"); + assert_eq!(args.frame_listen, None); + } + + #[test] + fn frame_listen_explicitly_selects_tcp_websockets() { + let cli = Cli::try_parse_from([ + "truapi-host", + "pairing-host", + "--frame-listen", + "127.0.0.1:0", + ]) + .expect("explicit TCP frame listener should parse"); + let Command::PairingHost(args) = cli.command else { + panic!("expected pairing-host command"); + }; + + assert_eq!( + args.frame_listen, + Some("127.0.0.1:0".parse().expect("valid socket address")) + ); } #[test] diff --git a/rust/crates/truapi-host-cli/src/script_runner.rs b/rust/crates/truapi-host-cli/src/script_runner.rs index 97ab3e20..2fabc832 100644 --- a/rust/crates/truapi-host-cli/src/script_runner.rs +++ b/rust/crates/truapi-host-cli/src/script_runner.rs @@ -38,16 +38,18 @@ impl ScriptHostRole { const SCRATCH_TEMPLATE: &str = r#"#!/usr/bin/env bun -// Import any npm package you need - Bun installs missing dependencies automatically. -import chalk from "chalk"; +// Scripts can use packages installed next to the script or in a parent project. +const cyanBold = "\u001b[1;36m"; +const green = "\u001b[32m"; +const reset = "\u001b[0m"; -console.log(chalk.cyan.bold("\n🚀 TrUAPI script\n")); +console.log(`${cyanBold}\n🚀 TrUAPI script\n${reset}`); const result = await truapi.account.getUserId(); if (!result.isOk()) { throw new Error(`getUserId failed: ${JSON.stringify(result.error)}`); } -console.log(chalk.green("user id:"), result.value); +console.log(`${green}user id:${reset}`, result.value); "#; /// Locate `js/runner.ts`, shipped alongside the crate. @@ -227,14 +229,14 @@ mod tests { use super::*; #[test] - fn scratch_script_starts_as_a_bun_script_with_dependency_examples() -> Result<()> { + fn scratch_script_starts_as_a_bun_script_with_dependency_free_example() -> Result<()> { let temporary = tempfile::tempdir()?; let script = create_scratch_script(temporary.path())?; let contents = fs::read_to_string(script)?; assert!(contents.starts_with("#!/usr/bin/env bun\n")); - assert!(contents.contains("import chalk from \"chalk\";")); + assert!(!contents.contains("from \"chalk\"")); assert!(contents.contains("truapi.account.getUserId()")); assert_eq!(contents, SCRATCH_TEMPLATE); Ok(()) diff --git a/rust/crates/truapi-host-cli/src/signing_shell.rs b/rust/crates/truapi-host-cli/src/signing_shell.rs index 96d2229d..3650f58b 100644 --- a/rust/crates/truapi-host-cli/src/signing_shell.rs +++ b/rust/crates/truapi-host-cli/src/signing_shell.rs @@ -141,9 +141,9 @@ pub struct Completion { } const SIGNING_COMMANDS: &[(&str, &str)] = &[ - ("/pair ", "answer a Polkadot Mobile pairing URL"), + ("/pair", "answer a Polkadot Mobile pairing URL"), ("/script", "edit the last or run an existing product script"), - ("/log ", "set error, warn, info, debug, or trace"), + ("/log", "set error, warn, info, debug, or trace"), ("/product", "show or switch the active product"), ("/session", "show or switch the active session"), ("/help", "show commands and keyboard shortcuts"), @@ -156,7 +156,7 @@ const PAIRING_COMMANDS: &[(&str, &str)] = &[ ("/script", "edit the last or run an existing product script"), ("/login", "pair with a signing host"), ("/logout", "disconnect and reset pairing keys"), - ("/log ", "set error, warn, info, debug, or trace"), + ("/log", "set error, warn, info, debug, or trace"), ("/product", "show or switch the active product"), ("/help", "show commands and keyboard shortcuts"), ("/clear", "clear the visible transcript"), @@ -164,6 +164,14 @@ const PAIRING_COMMANDS: &[(&str, &str)] = &[ ("/quit", "shut down the pairing host"), ]; +const LOG_ARGUMENTS: &[(&str, &str)] = &[ + ("error", "show only errors"), + ("warn", "show warnings and errors"), + ("info", "show informational host activity"), + ("debug", "show detailed host activity"), + ("trace", "show all host and protocol activity"), +]; + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] enum CommandScope { PairingHost, @@ -179,6 +187,9 @@ fn completions_for_scope( if let Some(path) = input.strip_prefix("/script ") { return path_completions(path); } + if let Some(prefix) = input.strip_prefix("/log ") { + return fixed_argument_completions("/log", prefix, LOG_ARGUMENTS); + } if scope == CommandScope::SigningHost && let Some(prefix) = input.strip_prefix("/session ") { @@ -221,6 +232,24 @@ fn completions_for_scope( .collect() } +fn fixed_argument_completions( + command: &str, + prefix: &str, + arguments: &[(&str, &'static str)], +) -> Vec { + if prefix.contains(char::is_whitespace) { + return Vec::new(); + } + arguments + .iter() + .filter(|(argument, _)| argument.starts_with(prefix)) + .map(|(argument, description)| Completion { + value: format!("{command} {argument}"), + description, + }) + .collect() +} + fn path_completions(input: &str) -> Vec { let path = Path::new(input); let ends_with_separator = input.ends_with(std::path::MAIN_SEPARATOR); @@ -690,4 +719,38 @@ mod tests { .any(|command| command.starts_with("/session")) ); } + + #[test] + fn log_completion_offers_levels_after_command_for_both_hosts() { + for scope in [CommandScope::SigningHost, CommandScope::PairingHost] { + let root_matches = completions_for_scope("/log", &[], scope); + assert_eq!( + root_matches + .iter() + .filter(|completion| completion.value == "/log") + .count(), + 1 + ); + assert!( + !root_matches + .iter() + .any(|completion| completion.value.starts_with("/log ")) + ); + + let argument_matches = completions_for_scope("/log ", &[], scope); + assert_eq!( + argument_matches + .into_iter() + .map(|completion| completion.value) + .collect::>(), + [ + "/log error", + "/log warn", + "/log info", + "/log debug", + "/log trace", + ] + ); + } + } } diff --git a/rust/crates/truapi-host-cli/tests/signing_host_cli.rs b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs index d4badeaf..bca4d6c6 100644 --- a/rust/crates/truapi-host-cli/tests/signing_host_cli.rs +++ b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs @@ -25,8 +25,11 @@ fn interactive_mode_rejects_non_tty_stdio_with_usage_exit() { fn exec_help_is_plain_and_exits_successfully() { let base_path = std::env::temp_dir().join(format!("truapi-host-cli-exec-help-{}", std::process::id())); - let output = command() - .args(["signing-host", "--frame-listen", "127.0.0.1:0"]) + let mut invocation = command(); + invocation.arg("signing-host"); + #[cfg(not(unix))] + invocation.args(["--frame-listen", "127.0.0.1:0"]); + let output = invocation .arg("--base-path") .arg(&base_path) .args(["exec", "/help"]) @@ -41,6 +44,8 @@ fn exec_help_is_plain_and_exits_successfully() { assert!(String::from_utf8_lossy(&output.stdout).contains("/copy")); assert!(String::from_utf8_lossy(&output.stdout).contains("/product")); assert!(String::from_utf8_lossy(&output.stdout).contains("/session")); + #[cfg(unix)] + assert!(String::from_utf8_lossy(&output.stdout).contains("ws+unix:")); assert!(!output.stdout.contains(&0x1b)); assert!(!output.stderr.contains(&0x1b)); }

#&t+Qh>dI$UiUGJ;CJ;(J# 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 literal 0 HcmV?d00001 From bc2a5fe90d0c5fa9e9517e288400bdb6bc785c38 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 23 Jul 2026 13:45:11 +0200 Subject: [PATCH 03/34] analyse and fix diff with ~/github/polkadot-app-android-v2/ --- rust/crates/truapi-server/README.md | 5 +- .../crates/truapi-server/src/chain_runtime.rs | 97 +++++-- .../truapi-server/src/host_logic/alias.rs | 81 ------ .../src/host_logic/sso/messages.rs | 70 ++++- .../src/host_logic/transaction.rs | 261 +++++++++--------- .../truapi-server/src/runtime/signing_host.rs | 11 +- .../src/runtime/signing_host/sso_responder.rs | 25 +- .../src/runtime/statement_allowance.rs | 145 +++++++++- .../runtime/statement_allowance/extension.rs | 168 ++++++++++- .../runtime/statement_allowance/extrinsic.rs | 124 ++++++--- .../src/runtime/statement_allowance/ring.rs | 96 ++++++- .../src/runtime/statement_allowance/rpc.rs | 208 +++++++++++++- .../src/runtime/statement_allowance/slot.rs | 18 ++ 13 files changed, 964 insertions(+), 345 deletions(-) delete mode 100644 rust/crates/truapi-server/src/host_logic/alias.rs diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index cabe2e22..601b3b15 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -190,8 +190,9 @@ role-specific lifecycle, so no method exists on a role that can't mean it: the chain's `Members` pallet, and pins membership, ring pages, exponent, and revision reads to one finalized block before creating an alias or proof. Full personhood is preferred over lite personhood. Extrinsic-payload signing - and resource allocation still return `Unavailable` pending chain-metadata - and on-chain support. + and v4 transaction construction work from pre-encoded payload fields, so no + chain metadata is needed; statement-store and Bulletin allowance allocation + are native-only (wasm builds report them as unavailable). `host_logic` stays pure: the orchestrators above call into it for codecs, session/SSO crypto, key derivation, and permission policy, while all I/O diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 4458bb67..28aa4e8b 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -19,7 +19,7 @@ use core::pin::Pin; use core::task::{Context, Poll}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -229,7 +229,7 @@ pub struct ChainRuntime { spawner: Spawner, connections: Arc>>>, connection_setups: Arc>>, - transaction_operations: Arc>>, + transaction_operations: Arc>>, next_transaction_operation_id: Arc, } @@ -239,6 +239,20 @@ struct TransactionOperation { provider_operation_id: String, } +/// Prefix of the host-assigned transaction operation ids handed to products. +const TRANSACTION_OPERATION_ID_PREFIX: &str = "truapi-tx-"; +/// Cap on tracked broadcast operations; products that never stop an operation +/// would otherwise grow the map for the whole session. Provider operation ids +/// are short-lived, so evicting the oldest handles is safe. +const MAX_TRACKED_TRANSACTION_OPERATIONS: usize = 256; + +fn transaction_operation_sequence(operation_id: &str) -> Option { + operation_id + .strip_prefix(TRANSACTION_OPERATION_ID_PREFIX)? + .parse() + .ok() +} + impl ChainRuntime { /// Build a `ChainRuntime` driven by `provider`. Background tasks (response /// pumps, follow setup) are spawned on `spawner`. @@ -248,7 +262,7 @@ impl ChainRuntime { spawner, connections: Arc::new(Mutex::new(HashMap::new())), connection_setups: Arc::new(Mutex::new(HashMap::new())), - transaction_operations: Arc::new(Mutex::new(HashMap::new())), + transaction_operations: Arc::new(Mutex::new(BTreeMap::new())), next_transaction_operation_id: Arc::new(AtomicU64::new(1)), } } @@ -519,22 +533,24 @@ impl ChainRuntime { .await .map_err(|err| rpc_failure(method, err))?; let operation_id = provider_operation_id.map(|provider_operation_id| { - let operation_id = format!( - "truapi-tx-{}", - self.next_transaction_operation_id - .fetch_add(1, Ordering::Relaxed) - ); - self.transaction_operations + let sequence = self + .next_transaction_operation_id + .fetch_add(1, Ordering::Relaxed); + let mut operations = self + .transaction_operations .lock() - .expect("transaction operation mutex poisoned") - .insert( - operation_id.clone(), - TransactionOperation { - genesis_hash: request.genesis_hash, - provider_operation_id, - }, - ); - operation_id + .expect("transaction operation mutex poisoned"); + operations.insert( + sequence, + TransactionOperation { + genesis_hash: request.genesis_hash, + provider_operation_id, + }, + ); + while operations.len() > MAX_TRACKED_TRANSACTION_OPERATIONS { + operations.pop_first(); + } + format!("{TRANSACTION_OPERATION_ID_PREFIX}{sequence}") }); Ok(RemoteChainTransactionBroadcastResponse { operation_id }) } @@ -546,11 +562,14 @@ impl ChainRuntime { request: RemoteChainTransactionStopRequest, ) -> Result<(), RuntimeFailure> { let method = "remote_chain_transaction_stop"; + let sequence = transaction_operation_sequence(&request.operation_id).ok_or_else(|| { + RuntimeFailure::host_failure(method, "unknown transaction operation id") + })?; let operation = self .transaction_operations .lock() .expect("transaction operation mutex poisoned") - .remove(&request.operation_id) + .remove(&sequence) .ok_or_else(|| { RuntimeFailure::host_failure(method, "unknown transaction operation id") })?; @@ -558,7 +577,7 @@ impl ChainRuntime { self.transaction_operations .lock() .expect("transaction operation mutex poisoned") - .insert(request.operation_id, operation); + .insert(sequence, operation); return Err(RuntimeFailure::host_failure( method, "transaction operation belongs to a different chain", @@ -570,7 +589,7 @@ impl ChainRuntime { self.transaction_operations .lock() .expect("transaction operation mutex poisoned") - .insert(request.operation_id, operation); + .insert(sequence, operation); return Err(err); } }; @@ -585,7 +604,7 @@ impl ChainRuntime { self.transaction_operations .lock() .expect("transaction operation mutex poisoned") - .insert(request.operation_id, operation); + .insert(sequence, operation); Err(rpc_failure(method, err)) } } @@ -1854,6 +1873,40 @@ mod tests { assert_eq!(stop["params"][0].as_str(), Some("REMOTE-OP")); } + #[test] + fn transaction_operations_evict_oldest_beyond_cap() { + let provider = Arc::new(ScriptedProvider::new(|request| { + let id = extract_id(request).unwrap(); + request + .contains("transaction_v1_broadcast") + .then(|| format!(r#"{{"jsonrpc":"2.0","id":"{id}","result":"REMOTE-OP"}}"#)) + })); + let runtime = ChainRuntime::new(provider, spawner_for_tests()); + let genesis_hash = vec![0x11; 32]; + + let mut first_id = None; + for _ in 0..=MAX_TRACKED_TRANSACTION_OPERATIONS { + let broadcast = + futures::executor::block_on(runtime.remote_chain_transaction_broadcast( + RemoteChainTransactionBroadcastRequest { + genesis_hash: genesis_hash.clone(), + transaction: vec![0], + }, + )) + .expect("broadcast succeeds"); + first_id.get_or_insert(broadcast.operation_id.expect("operation id")); + } + + let err = futures::executor::block_on(runtime.remote_chain_transaction_stop( + RemoteChainTransactionStopRequest { + genesis_hash, + operation_id: first_id.expect("first operation id"), + }, + )) + .expect_err("evicted operation id is unknown"); + assert!(err.reason().contains("unknown transaction operation id")); + } + #[test] fn unpin_uses_typed_subxt_method_for_each_hash() { let provider = Arc::new(ScriptedProvider::new(|request| { diff --git a/rust/crates/truapi-server/src/host_logic/alias.rs b/rust/crates/truapi-server/src/host_logic/alias.rs deleted file mode 100644 index 3b6874e1..00000000 --- a/rust/crates/truapi-server/src/host_logic/alias.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Bandersnatch ring-VRF product-account aliases (signing host). -//! -//! Mirrors the mobile app's `ProductAccountHolder.deriveAlias`: the alias is a -//! thin bandersnatch VRF output over a per-product context, using a -//! bandersnatch secret derived from the wallet's BIP-39 entropy. No ring -//! commitment or SRS is involved (that machinery is only for membership -//! proofs, which this path does not use). -//! -//! Reference: polkadot-app-ios-v2 `Packages/Products/.../ProductAccountHolder.swift` -//! and `verifiable-swift` over `paritytech/verifiable`. - -use verifiable::GenerateVerifiable; -use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; - -/// A product-account contextual alias. -pub struct ProductAlias { - /// 32-byte context identifier (blake2b-256 of the derivation path). - pub context: [u8; 32], - /// 32-byte ring-VRF alias output. - pub alias: [u8; 32], -} - -/// Derive the contextual alias for a product account from the wallet entropy. -/// -/// - `context = blake2b_256("/product/{product_id}/{derivation_index}")` -/// - `bandersnatch_entropy = blake2b_256(root_entropy)` -/// - `alias = BandersnatchVrf::alias_in_context(new_secret(bandersnatch_entropy), context)` -pub fn derive_product_alias( - root_entropy: &[u8], - product_id: &str, - derivation_index: u32, -) -> Result { - let derivation_path = format!("/product/{product_id}/{derivation_index}"); - let context = blake2b256(derivation_path.as_bytes()); - let bandersnatch_entropy = blake2b256(root_entropy); - let secret = BandersnatchVrfVerifiable::new_secret(bandersnatch_entropy); - let alias = BandersnatchVrfVerifiable::alias_in_context(&secret, &context) - .map_err(|err| format!("ring-VRF alias derivation failed: {err:?}"))?; - Ok(ProductAlias { context, alias }) -} - -fn blake2b256(message: &[u8]) -> [u8; 32] { - blake2b_simd::Params::new() - .hash_length(32) - .hash(message) - .as_bytes() - .try_into() - .expect("BLAKE2b-256 returns 32 bytes") -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The alias is deterministic in the entropy, product id, and index, and - /// the context is the blake2b-256 of the derivation path. - #[test] - fn alias_is_deterministic_with_expected_context() { - let entropy = [0xABu8; 16]; - let first = derive_product_alias(&entropy, "truapi-playground.dot", 0).unwrap(); - let again = derive_product_alias(&entropy, "truapi-playground.dot", 0).unwrap(); - - assert_eq!(first.context, again.context); - assert_eq!(first.alias, again.alias); - assert_eq!( - first.context, - blake2b256(b"/product/truapi-playground.dot/0") - ); - } - - #[test] - fn alias_varies_by_product_and_index() { - let entropy = [0xABu8; 16]; - let base = derive_product_alias(&entropy, "a.dot", 0).unwrap(); - let other_product = derive_product_alias(&entropy, "b.dot", 0).unwrap(); - let other_index = derive_product_alias(&entropy, "a.dot", 1).unwrap(); - - assert_ne!(base.alias, other_product.alias); - assert_ne!(base.alias, other_index.alias); - } -} diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index 26626610..620b8089 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -41,14 +41,18 @@ use crate::host_logic::statement_store::{ pub mod v1; +/// Transport-level acknowledgement code for an SSO session statement. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, derive_more::Display)] -enum SsoResponseCode { +pub enum SsoResponseCode { + /// The request statement was decrypted and decoded. #[codec(index = 0)] #[display("success")] Success, + /// The request statement could not be decrypted. #[codec(index = 1)] #[display("decryptionFailed")] DecryptionFailed, + /// The request statement decrypted but its messages could not be decoded. #[codec(index = 2)] #[display("decodingFailed")] DecodingFailed, @@ -738,20 +742,45 @@ pub struct IncomingSsoRequest { pub messages: Vec, } +/// Failure decoding a peer request statement. +/// +/// `request_id` is present when the encrypted envelope decoded far enough for +/// the responder to acknowledge the statement with +/// [`SsoResponseCode::DecodingFailed`]. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] +#[display("{reason}")] +pub struct SsoRequestDecodeError { + /// Statement-level request id, when recoverable. + pub request_id: Option, + /// Human-readable failure description. + pub reason: String, +} + +impl SsoRequestDecodeError { + fn unrecoverable(reason: String) -> Self { + Self { + request_id: None, + reason, + } + } +} + /// Decode a peer request for a signing-host responder. /// /// Own echoes, response acknowledgements, and expired statements are ignored. pub fn decode_incoming_sso_request( session: &SsoSessionInfo, statement: &[u8], -) -> Result, String> { - let verified = - decode_verified_statement_data(statement, None).map_err(|err| err.to_string())?; +) -> Result, SsoRequestDecodeError> { + let verified = decode_verified_statement_data(statement, None) + .map_err(|err| SsoRequestDecodeError::unrecoverable(err.to_string()))?; if verified.signer == session.ss_public_key { return Ok(None); } if verified.signer != session.identity_account_id { - return Err("statement proof signer does not match expected peer".to_string()); + return Err(SsoRequestDecodeError::unrecoverable( + "statement proof signer does not match expected peer".to_string(), + )); } if verified .expiry @@ -759,7 +788,9 @@ pub fn decode_incoming_sso_request( { return Ok(None); } - match decrypt_session_statement_data(session, &verified.data)? { + match decrypt_session_statement_data(session, &verified.data) + .map_err(SsoRequestDecodeError::unrecoverable)? + { SsoStatementData::Response { .. } => Ok(None), SsoStatementData::Request { request_id, data } => { let messages = data @@ -768,7 +799,11 @@ pub fn decode_incoming_sso_request( RemoteMessage::decode(&mut message.as_slice()) .map_err(|err| format!("invalid SSO remote message: {err}")) }) - .collect::, _>>()?; + .collect::, _>>() + .map_err(|reason| SsoRequestDecodeError { + request_id: Some(request_id.clone()), + reason, + })?; Ok(Some(IncomingSsoRequest { request_id, messages, @@ -1477,6 +1512,27 @@ mod tests { None ); } + + #[test] + fn responder_recovers_request_id_from_undecodable_messages() { + let (host_session, responder_session) = host_and_responder_sessions(); + let encrypted = encrypt_session_statement_data( + &host_session, + &SsoStatementData::Request { + request_id: "statement-1".to_string(), + data: vec![vec![0xFF, 0xFF, 0xFF]], + }, + ) + .unwrap(); + let statement = + build_signed_session_request_statement(&host_session, encrypted, fresh_expiry()) + .unwrap(); + + let error = decode_incoming_sso_request(&responder_session, &statement).unwrap_err(); + assert_eq!(error.request_id.as_deref(), Some("statement-1")); + assert!(error.reason.contains("invalid SSO remote message")); + } + fn response_ack_statement(session: &SsoSessionInfo, expiry: u64) -> Vec { let encrypted = encrypt_session_statement_data_with_nonce( session, diff --git a/rust/crates/truapi-server/src/host_logic/transaction.rs b/rust/crates/truapi-server/src/host_logic/transaction.rs index 13040bc2..8dec1e50 100644 --- a/rust/crates/truapi-server/src/host_logic/transaction.rs +++ b/rust/crates/truapi-server/src/host_logic/transaction.rs @@ -1,93 +1,68 @@ -//! Extrinsic signing preimages and v4 signed-extrinsic assembly. +//! Extrinsic signing preimages assembled from pre-encoded payload fields. //! -//! Signing hosts receive pre-encoded payload fields (`HostSignPayloadData`) -//! or pre-encoded transaction extensions (`TxPayloadExtension`), so no chain -//! metadata is needed: the preimage is a byte concatenation and the signed -//! extrinsic is assembled mechanically. Matches the polkadot-app signing -//! convention: preimages longer than 256 bytes are BLAKE2b-256 hashed before -//! signing. - -use parity_scale_codec::{Compact, Encode}; -use truapi::latest::{HostSignPayloadData, TxPayloadExtension}; - -/// Preimages longer than this are hashed before signing (standard Substrate -/// signed-payload rule). +//! Signing hosts receive the pre-encoded fields of [`HostSignPayloadData`], +//! so no chain metadata is needed: the preimage is the polkadot-js +//! `ExtrinsicPayloadV4` byte layout. The optional `ChargeAssetTxPayment` +//! (`asset_id` after `tip`) and `CheckMetadataHash` (`mode` extra plus the +//! `Option`-encoded `metadata_hash` implicit) fields participate when the +//! payload's signed-extension list names them. Extensions outside that set +//! are assumed to carry no extra or implicit bytes, matching polkadot-js +//! without user extensions. Preimages longer than 256 bytes are BLAKE2b-256 +//! hashed before signing (standard Substrate signed-payload rule). + +use truapi::latest::HostSignPayloadData; + +/// Preimages longer than this are hashed before signing. const MAX_SIGNED_PREIMAGE_LEN: usize = 256; -/// Extrinsic version 4 with the signed bit set. -const EXTRINSIC_V4_SIGNED: u8 = 0x84; -/// `MultiAddress::Id` variant index. -const MULTI_ADDRESS_ID: u8 = 0x00; -/// `MultiSignature::Sr25519` variant index. -const MULTI_SIGNATURE_SR25519: u8 = 0x01; - -/// Signing preimage for an extrinsic payload assembled from pre-encoded -/// fields, in the polkadot-app field order. Empty optional fields are -/// skipped, mirroring the JS falsy-field rule. -pub fn extrinsic_payload_preimage(payload: &HostSignPayloadData) -> Vec { - let parts: [&[u8]; 8] = [ - &payload.method, - &payload.era, - &payload.nonce, - &payload.tip, - &payload.spec_version, - &payload.transaction_version, - &payload.genesis_hash, - &payload.block_hash, - ]; - let mut preimage = Vec::new(); - for part in parts { - preimage.extend_from_slice(part); - } - if let Some(asset_id) = &payload.asset_id { - preimage.extend_from_slice(asset_id); - } - if let Some(metadata_hash) = &payload.metadata_hash { - preimage.extend_from_slice(metadata_hash); - } - hash_large_preimage(preimage) -} +/// Signed extension contributing the `asset_id` extra after `tip`. +const CHARGE_ASSET_TX_PAYMENT: &str = "ChargeAssetTxPayment"; +/// Signed extension contributing the `mode` extra and `metadata_hash` implicit. +const CHECK_METADATA_HASH: &str = "CheckMetadataHash"; + +/// Signing preimage for an extrinsic payload, in the polkadot-js +/// `ExtrinsicPayloadV4` field order: `method ++ era ++ nonce ++ tip ++ +/// [asset_id] ++ [mode] ++ spec_version ++ transaction_version ++ +/// genesis_hash ++ block_hash ++ [metadata_hash]`. +pub fn extrinsic_payload_preimage(payload: &HostSignPayloadData) -> Result, String> { + let has_extension = |id: &str| payload.signed_extensions.iter().any(|ext| ext == id); + let charge_asset = has_extension(CHARGE_ASSET_TX_PAYMENT) || payload.asset_id.is_some(); + let check_metadata_hash = has_extension(CHECK_METADATA_HASH) || payload.metadata_hash.is_some(); -/// Signing preimage for a transaction built from pre-encoded extensions: -/// call data, then every extension's `extra`, then every extension's -/// `additional_signed`. -pub fn transaction_signing_preimage( - call_data: &[u8], - extensions: &[TxPayloadExtension], -) -> Vec { - let mut preimage = call_data.to_vec(); - for extension in extensions { - preimage.extend_from_slice(&extension.extra); + let mut preimage = Vec::new(); + preimage.extend_from_slice(&payload.method); + preimage.extend_from_slice(&payload.era); + preimage.extend_from_slice(&payload.nonce); + preimage.extend_from_slice(&payload.tip); + if charge_asset { + // The wire carries the chain's `TAssetConversion` encoding (itself + // option-typed on asset chains); an absent field means `None`. + match &payload.asset_id { + Some(asset_id) => preimage.extend_from_slice(asset_id), + None => preimage.push(0), + } } - for extension in extensions { - preimage.extend_from_slice(&extension.additional_signed); + if check_metadata_hash { + let mode = payload.mode.unwrap_or(0); + let mode = u8::try_from(mode) + .map_err(|_| format!("CheckMetadataHash mode {mode} does not fit in a u8"))?; + preimage.push(mode); } - hash_large_preimage(preimage) -} - -/// Assemble a v4 signed extrinsic from a signer public key, an sr25519 -/// signature over [`transaction_signing_preimage`], the pre-encoded -/// extension `extra` data, and the call data. -pub fn build_v4_signed_extrinsic( - signer_public_key: [u8; 32], - signature: [u8; 64], - extensions: &[TxPayloadExtension], - call_data: &[u8], -) -> Vec { - let mut body = Vec::with_capacity(2 + 32 + 1 + 64 + call_data.len()); - body.push(EXTRINSIC_V4_SIGNED); - body.push(MULTI_ADDRESS_ID); - body.extend_from_slice(&signer_public_key); - body.push(MULTI_SIGNATURE_SR25519); - body.extend_from_slice(&signature); - for extension in extensions { - body.extend_from_slice(&extension.extra); + preimage.extend_from_slice(&payload.spec_version); + preimage.extend_from_slice(&payload.transaction_version); + preimage.extend_from_slice(&payload.genesis_hash); + preimage.extend_from_slice(&payload.block_hash); + if check_metadata_hash { + // `Option<[u8; 32]>` implicit; the wire carries the raw hash. + match &payload.metadata_hash { + Some(hash) => { + preimage.push(1); + preimage.extend_from_slice(hash); + } + None => preimage.push(0), + } } - body.extend_from_slice(call_data); - - let mut extrinsic = Compact(body.len() as u32).encode(); - extrinsic.extend_from_slice(&body); - extrinsic + Ok(hash_large_preimage(preimage)) } fn hash_large_preimage(preimage: Vec) -> Vec { @@ -127,35 +102,94 @@ mod tests { } #[test] - fn payload_preimage_uses_polkadot_app_field_order() { + fn payload_preimage_uses_extrinsic_payload_v4_field_order() { // method, era, nonce, tip, spec_version, transaction_version, // genesis_hash, block_hash. block_number is not part of the preimage. assert_eq!( - extrinsic_payload_preimage(&payload()), + extrinsic_payload_preimage(&payload()).unwrap(), vec![0x4D, 0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2] ); } #[test] - fn payload_preimage_appends_asset_id_and_metadata_hash() { + fn payload_preimage_places_asset_id_after_tip_and_metadata_hash_last() { let mut payload = payload(); - payload.asset_id = Some(vec![0xAA]); - payload.metadata_hash = Some(vec![0xBB]); + payload.signed_extensions = vec![ + "CheckMortality".to_string(), + "CheckNonce".to_string(), + "ChargeAssetTxPayment".to_string(), + "CheckMetadataHash".to_string(), + ]; + payload.asset_id = Some(vec![0x01, 0xAA]); + payload.mode = Some(1); + payload.metadata_hash = Some(vec![0xBB; 32]); + + let mut expected = vec![ + 0x4D, 0xE1, 0x4E, 0x54, // method, era, nonce, tip + 0x01, 0xAA, // asset_id (TAssetConversion bytes) + 0x01, // mode + 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2, // spec, tx, genesis, block + 0x01, // Some(metadata_hash) + ]; + expected.extend_from_slice(&[0xBB; 32]); + assert_eq!(extrinsic_payload_preimage(&payload).unwrap(), expected); + } + + #[test] + fn payload_preimage_defaults_listed_extensions_to_disabled() { + // A chain that has the extensions while the payload leaves them unset + // still signs their default encodings: assetId None, mode 0, + // metadata_hash None. + let mut payload = payload(); + payload.signed_extensions = vec![ + "ChargeAssetTxPayment".to_string(), + "CheckMetadataHash".to_string(), + ]; + payload.mode = Some(0); assert_eq!( - extrinsic_payload_preimage(&payload), + extrinsic_payload_preimage(&payload).unwrap(), vec![ - 0x4D, 0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2, 0xAA, 0xBB + 0x4D, 0xE1, 0x4E, 0x54, // method, era, nonce, tip + 0x00, // asset_id None + 0x00, // mode 0 + 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2, // spec, tx, genesis, block + 0x00, // metadata_hash None ] ); } + #[test] + fn payload_preimage_ignores_mode_without_check_metadata_hash() { + // polkadot-js always emits `mode: 0` in the payload JSON, even for + // chains without CheckMetadataHash; the extension list decides. + let mut payload = payload(); + payload.mode = Some(0); + + assert_eq!( + extrinsic_payload_preimage(&payload).unwrap(), + vec![0x4D, 0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2] + ); + } + + #[test] + fn payload_preimage_rejects_out_of_range_mode() { + let mut payload = payload(); + payload.signed_extensions = vec!["CheckMetadataHash".to_string()]; + payload.mode = Some(256); + + assert_eq!( + extrinsic_payload_preimage(&payload), + Err("CheckMetadataHash mode 256 does not fit in a u8".to_string()) + ); + } + #[test] fn long_preimages_are_blake2b_hashed() { let mut payload = payload(); payload.method = vec![0x4D; 300]; - let preimage = extrinsic_payload_preimage(&payload); + let preimage = extrinsic_payload_preimage(&payload).unwrap(); assert_eq!(preimage.len(), 32); let mut raw = vec![0x4D; 300]; @@ -169,49 +203,4 @@ mod tests { .to_vec() ); } - - #[test] - fn transaction_preimage_orders_call_extra_then_implicit() { - let extensions = vec![ - TxPayloadExtension { - id: "CheckNonce".to_string(), - extra: vec![0x01], - additional_signed: vec![0x02], - }, - TxPayloadExtension { - id: "CheckSpecVersion".to_string(), - extra: vec![0x03], - additional_signed: vec![0x04], - }, - ]; - - assert_eq!( - transaction_signing_preimage(&[0xCA, 0x11], &extensions), - vec![0xCA, 0x11, 0x01, 0x03, 0x02, 0x04] - ); - } - - #[test] - fn builds_v4_signed_extrinsic_layout() { - let extensions = vec![TxPayloadExtension { - id: "CheckNonce".to_string(), - extra: vec![0xEE], - additional_signed: vec![0xDD], - }]; - - let extrinsic = - build_v4_signed_extrinsic([0xAB; 32], [0xCD; 64], &extensions, &[0xCA, 0x11]); - - let body_len = 1 + 1 + 32 + 1 + 64 + 1 + 2; - assert_eq!(extrinsic[..2], Compact(body_len as u32).encode()[..]); - let body = &extrinsic[2..]; - assert_eq!(body.len(), body_len); - assert_eq!(body[0], 0x84); - assert_eq!(body[1], 0x00); - assert_eq!(&body[2..34], &[0xAB; 32]); - assert_eq!(body[34], 0x01); - assert_eq!(&body[35..99], &[0xCD; 64]); - assert_eq!(body[99], 0xEE); - assert_eq!(&body[100..], &[0xCA, 0x11]); - } } diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 95b69931..95077f01 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -8,9 +8,9 @@ //! //! Implemented: local session lifecycle, raw-bytes signing, extrinsic-payload //! signing, v4 transaction construction (payload fields and extensions arrive -//! pre-encoded, so no chain metadata is needed), RFC-0007 product entropy, and -//! bandersnatch ring-VRF product-account aliases (native only), and -//! product-scoped Statement Store and Bulletin allowance keys. +//! pre-encoded, so no chain metadata is needed), RFC-0007 product entropy, +//! bandersnatch ring-VRF aliases and membership proofs, and product-scoped +//! Statement Store and Bulletin allowance keys (native only). mod local_activation; mod ring_vrf; @@ -523,7 +523,8 @@ fn sign_extrinsic_payload( ), }); } - let preimage = extrinsic_payload_preimage(&payload); + let preimage = extrinsic_payload_preimage(&payload) + .map_err(|reason| AuthorityError::Unknown { reason })?; let signature = keypair .secret .sign_simple(SR25519_SIGNING_CONTEXT, &preimage, &keypair.public) @@ -864,7 +865,7 @@ mod tests { let session = authority.current_session().expect("active session"); let cx = CallContext::new(); let payload = crate::test_support::sign_payload_data(); - let preimage = extrinsic_payload_preimage(&payload); + let preimage = extrinsic_payload_preimage(&payload).expect("preimage builds"); let product_response = futures::executor::block_on(authority.sign_payload( &cx, diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index fd11583f..746d7e7b 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -35,7 +35,7 @@ use crate::host_logic::sso::messages::{ RemoteMessageData, ResourceAllocationResponse, RingVrfAliasResponse, RingVrfError, RingVrfProofResponse, SignRawLegacyResponse, SigningPayloadResponseData, SigningRequest, SigningResponse, SsoAllocatableResource, SsoAllocatedResource, SsoAllocationOutcome, - StatementStoreProductSignResponse, build_outgoing_request_statement, + SsoResponseCode, StatementStoreProductSignResponse, build_outgoing_request_statement, build_signed_session_response_statement, decode_incoming_sso_request, v1, }; use crate::host_logic::sso::pairing::{ @@ -161,14 +161,31 @@ async fn serve_session( let incoming = match decode_incoming_sso_request(&session, &statement) { Ok(Some(incoming)) => incoming, Ok(None) => continue, - Err(reason) => { + Err(error) => { let prefix = hex::encode(&statement[..statement.len().min(16)]); warn!( - %reason, + reason = %error.reason, statement_bytes = statement.len(), statement_prefix = %prefix, "ignoring undecodable SSO session statement" ); + // Ack a decodable envelope whose messages did not decode + // so the peer fails fast instead of waiting out its + // response deadline. + if let Some(request_id) = error.request_id + && served_request_ids.insert(request_id.clone()) + { + let ack = build_signed_session_response_statement( + &session, + request_id, + SsoResponseCode::DecodingFailed as u8, + fresh_statement_expiry(), + )?; + services + .statement_store + .submit_sso(ack, "sso-responder decode-failed ack") + .await?; + } continue; } }; @@ -223,7 +240,7 @@ async fn serve_request( let ack = build_signed_session_response_statement( session, incoming.request_id.clone(), - 0, + SsoResponseCode::Success as u8, fresh_statement_expiry(), )?; services diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index e1c845d5..3fae0a5e 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -101,8 +101,10 @@ fn json_u32(value: &Value, field: &str) -> Result { } /// Result of a statement-store allowance registration attempt. +#[derive(Debug)] pub enum RegistrationOutcome { - /// The extrinsic reached a block; the target now holds slot `seq`. + /// The extrinsic reached a block and the slot entry was verified at that + /// block: the target now holds slot `seq`. Registered { /// Block hash the extrinsic landed in. block_hash: String, @@ -151,7 +153,9 @@ impl BulletinAllowanceInfo { /// Find the newest ring (scanning up to `lookback` back from the current index) /// that includes our member key. Reads the ring exponent once and stops at the -/// first match. +/// first match. Every read is pinned to one finalized block so the snapshot is +/// internally consistent; the pinned hash is recorded on the returned +/// [`RingParams`]. pub async fn find_including_ring( rpc: &RpcClient, metadata: &Metadata, @@ -159,16 +163,18 @@ pub async fn find_including_ring( lookback: u32, ) -> Result, String> { let member = proof::member_key(entropy); - let exponent = ring::read_ring_exponent(rpc, metadata).await?; - let current = ring::read_current_ring_index(rpc).await?; + let at = rpc.finalized_head().await?; + let exponent = ring::read_ring_exponent(rpc, metadata, &at).await?; + let current = ring::read_current_ring_index_at(rpc, &at).await?; let oldest = current.saturating_sub(lookback); for ring_index in (oldest..=current).rev() { - let members = ring::read_ring_members_at(rpc, ring_index).await?; + let members = ring::read_ring_members_at(rpc, ring_index, &at).await?; if members.contains(&member) { return Ok(Some(RingParams { members, exponent, ring_index, + block_hash: at, })); } } @@ -205,16 +211,26 @@ pub async fn register_statement_account( }; let context = slot::derive_slot_context(period, seq); - let call = extrinsic::build_set_statement_store_account_call(period, seq, target); + let call = + extrinsic::build_set_statement_store_account_call(metadata, period, seq, target)?; let message = extension::build_proof_message(metadata, &call, chain_state)?; let domain = proof::domain_for_ring_exponent(ring.exponent)?; let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; - let as_resources_extra = extrinsic::build_as_resources_extra(&ring_proof, ring.ring_index); + let as_resources_extra = + extrinsic::build_as_resources_extra(metadata, &ring_proof, ring.ring_index)?; let extrinsic = extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; match rpc.submit_and_watch(&extrinsic).await { Ok(block_hash) => { + if slot::read_slot_account_at(rpc, entropy, period, seq, &block_hash).await? + != Some(*target) + { + return Err(format!( + "registration reached block {block_hash} but slot (period {period}, \ + seq {seq}) is not held by the target account" + )); + } return Ok(RegistrationOutcome::Registered { block_hash, seq, @@ -240,7 +256,8 @@ pub async fn claim_long_term_storage( period: u32, ring: &RingParams, ) -> Result { - let revision = ring::read_ring_revision(rpc, metadata, ring.ring_index).await?; + let revision = + ring::read_ring_revision(rpc, metadata, ring.ring_index, &ring.block_hash).await?; let mut skipped_duplicate_counters = Vec::new(); loop { let counter = slot::scan_long_term_storage_counter_excluding( @@ -253,12 +270,17 @@ pub async fn claim_long_term_storage( .await?; let context = slot::derive_long_term_storage_context(period, counter); - let call = extrinsic::build_claim_long_term_storage_call(period, counter, target); + let call = + extrinsic::build_claim_long_term_storage_call(metadata, period, counter, target)?; let message = extension::build_proof_message(metadata, &call, chain_state)?; let domain = proof::domain_for_ring_exponent(ring.exponent)?; let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; - let as_resources_extra = - extrinsic::build_long_term_storage_extra(&ring_proof, ring.ring_index, revision); + let as_resources_extra = extrinsic::build_long_term_storage_extra( + metadata, + &ring_proof, + ring.ring_index, + revision, + )?; let extrinsic = extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; info!( @@ -413,17 +435,24 @@ async fn fetch_block_number(rpc: &RpcClient) -> Result { .map_err(|err| format!("chain_getHeader number: {err}")) } +/// Pool responses meaning an equivalent claim already occupies the pool, so +/// the scan should move to the next slot. Bans and validity failures are hard +/// errors for the caller. fn duplicate_submit_error(message: &str) -> bool { let message = message.to_ascii_lowercase(); - message.contains("priority is too low") - || message.contains("already imported") - || message.contains("temporarily banned") + message.contains("priority is too low") || message.contains("already imported") } #[cfg(test)] mod tests { + use subxt_rpcs::RpcClient as HostRpcClient; + + use super::rpc::testing::ScriptedRpc; use super::*; + /// Fixture metadata captured from paseo-next-v2 (raw `RuntimeMetadataPrefixed`). + const FIXTURE: &[u8] = include_bytes!("../../tests/fixtures/paseo-next-v2-metadata.scale"); + fn allowance( remained_size: u64, remained_transactions: u32, @@ -463,4 +492,92 @@ mod tests { assert!(!authorization_refreshed(baseline, Some(baseline))); } + + #[test] + fn banned_submissions_are_not_classified_as_duplicates() { + let classified: Vec = [ + "Priority is too low: (100 vs 100)", + "Transaction Already Imported", + "Transaction is temporarily banned", + "Invalid Transaction", + ] + .into_iter() + .map(duplicate_submit_error) + .collect(); + + assert_eq!(classified, vec![true, true, false, false]); + } + + /// `StmtStoreAllowanceEntry { account_id, seq: 0, since: 0 }` as a scripted + /// JSON storage result. + fn slot_entry(account: [u8; 32]) -> String { + let mut entry = account.to_vec(); + entry.extend_from_slice(&0u32.to_le_bytes()); + entry.extend_from_slice(&0u64.to_le_bytes()); + format!(r#""0x{}""#, hex::encode(entry)) + } + + /// Run `register_statement_account` against a scripted chain: all ten + /// slots free, the extrinsic reaches block `0xb10c`, and the verification + /// read at that block returns `verified_entry`. + fn scripted_registration( + verified_entry: &str, + ) -> (Result, ScriptedRpc) { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let chain_state = ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + }; + let entropy = [0x11; 32]; + let ring = RingParams { + members: vec![proof::member_key(entropy)], + exponent: 9, + ring_index: 0, + block_hash: "0xfinal".to_string(), + }; + + let mut responses = vec!["null"; 10]; + responses.push(verified_entry); + let scripted = ScriptedRpc::new(responses); + scripted.script_subscription([r#"{"inBlock":"0xb10c"}"#]); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + + let outcome = futures::executor::block_on(register_statement_account( + &rpc, + &metadata, + &chain_state, + entropy, + &[0x22; 32], + 7, + &ring, + )); + (outcome, scripted) + } + + #[test] + fn registration_is_verified_at_the_included_block() { + let (outcome, scripted) = scripted_registration(&slot_entry([0x22; 32])); + + assert!(matches!( + outcome.unwrap(), + RegistrationOutcome::Registered { block_hash, seq: 0, ring_index: 0 } + if block_hash == "0xb10c" + )); + let (method, params) = scripted.calls().last().cloned().unwrap(); + assert_eq!(method, "state_getStorage"); + assert!( + params.ends_with(r#","0xb10c"]"#), + "verification read not pinned to the included block: {params}" + ); + } + + #[test] + fn registration_fails_when_the_included_block_lacks_the_slot() { + let (outcome, _scripted) = scripted_registration(&slot_entry([0x99; 32])); + + let err = outcome.unwrap_err(); + assert!(err.contains("0xb10c"), "unexpected error: {err}"); + } } diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs index 64bf6153..637e7897 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs @@ -18,7 +18,8 @@ use std::collections::HashMap; use frame_metadata::RuntimeMetadata; use frame_metadata::RuntimeMetadataPrefixed; use parity_scale_codec::{Compact, Decode, Encode}; -use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; +use scale_info::form::PortableForm; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive, TypeDefVariant}; /// Signed-extension identifier that carries the `AsResources` authorization. pub const AS_RESOURCES: &str = "AsResources"; @@ -52,13 +53,15 @@ pub struct EncodedExtension { pub additional_signed: Vec, } -/// Decoded metadata: the ordered signed-extension defs, the type registry, and -/// each storage entry's value type id (`(pallet, entry) -> type id`). +/// Decoded metadata: the ordered signed-extension defs, the type registry, +/// each storage entry's value type id (`(pallet, entry) -> type id`), pallet +/// constants, and each pallet's `(index, call enum type id)`. pub struct Metadata { extensions: Vec, registry: PortableRegistry, storage_values: HashMap<(String, String), u32>, constants: HashMap<(String, String), Vec>, + calls: HashMap, } /// Collect extensions, type registry, storage value types, and pallet constants @@ -77,7 +80,11 @@ macro_rules! collect_metadata { .collect(); let mut storage_values = HashMap::new(); let mut constants = HashMap::new(); + let mut calls = HashMap::new(); for pallet in &$m.pallets { + if let Some(pallet_calls) = &pallet.calls { + calls.insert(pallet.name.clone(), (pallet.index, pallet_calls.ty.id)); + } for constant in &pallet.constants { constants.insert( (pallet.name.clone(), constant.name.clone()), @@ -96,7 +103,7 @@ macro_rules! collect_metadata { storage_values.insert((pallet.name.clone(), entry.name.clone()), value_type); } } - (extensions, $m.types, storage_values, constants) + (extensions, $m.types, storage_values, constants, calls) }}; } @@ -124,7 +131,11 @@ macro_rules! collect_metadata_v16 { .collect(); let mut storage_values = HashMap::new(); let mut constants = HashMap::new(); + let mut calls = HashMap::new(); for pallet in &$m.pallets { + if let Some(pallet_calls) = &pallet.calls { + calls.insert(pallet.name.clone(), (pallet.index, pallet_calls.ty.id)); + } for constant in &pallet.constants { constants.insert( (pallet.name.clone(), constant.name.clone()), @@ -143,18 +154,18 @@ macro_rules! collect_metadata_v16 { storage_values.insert((pallet.name.clone(), entry.name.clone()), value_type); } } - (extensions, $m.types, storage_values, constants) + (extensions, $m.types, storage_values, constants, calls) }}; } impl Metadata { - /// Decode `state_getMetadata` bytes (a `RuntimeMetadataPrefixed`, V14 or - /// V15) into the ordered signed-extension defs, type registry, and storage - /// value types. + /// Decode `state_getMetadata` bytes (a `RuntimeMetadataPrefixed`, V14 + /// through V16) into the ordered signed-extension defs, type registry, + /// storage value types, constants, and call enums. pub fn decode(bytes: &[u8]) -> Result { let prefixed = RuntimeMetadataPrefixed::decode(&mut &bytes[..]) .map_err(|err| format!("metadata decode failed: {err}"))?; - let (extensions, registry, storage_values, constants) = match prefixed.1 { + let (extensions, registry, storage_values, constants, calls) = match prefixed.1 { RuntimeMetadata::V14(m) => collect_metadata!(m, frame_metadata::v14::StorageEntryType), RuntimeMetadata::V15(m) => collect_metadata!(m, frame_metadata::v15::StorageEntryType), RuntimeMetadata::V16(m) => collect_metadata_v16!(m), @@ -165,6 +176,7 @@ impl Metadata { registry, storage_values, constants, + calls, }) } @@ -187,6 +199,105 @@ impl Metadata { .map(Vec::as_slice) } + /// Resolve `pallet::call` by name to its `[pallet_index, call_index]` + /// dispatch bytes. + pub fn call_indices(&self, pallet: &str, call: &str) -> Result<[u8; 2], String> { + let (pallet_index, call_type) = self + .calls + .get(pallet) + .copied() + .ok_or_else(|| format!("pallet `{pallet}` has no calls in metadata"))?; + let variants = self.resolve_variant(call_type)?; + let variant = variants + .variants + .iter() + .find(|v| v.name == call) + .ok_or_else(|| format!("call `{pallet}.{call}` not found in metadata"))?; + Ok([pallet_index, variant.index]) + } + + /// Resolve `AsResourcesInfo::` and the + /// `MembershipCollection::LitePeople` index it carries, by name, from the + /// `AsResources` extension type. + pub fn as_resources_variant_indices(&self, info_variant: &str) -> Result<(u8, u8), String> { + let ext = self + .extensions + .iter() + .find(|e| e.identifier == AS_RESOURCES) + .ok_or_else(|| format!("{AS_RESOURCES} extension not found in metadata"))?; + // extra = `AsResources(Option)`, with or without the + // struct wrapper. + let option_type = match &self.resolve_type(ext.extra_type)?.type_def { + TypeDef::Composite(_) => self.single_field_type(ext.extra_type)?, + _ => ext.extra_type, + }; + let info_type = self + .resolve_variant(option_type)? + .variants + .iter() + .find(|v| v.name == "Some") + .and_then(|some| match some.fields.as_slice() { + [field] => Some(field.ty.id), + _ => None, + }) + .ok_or_else(|| format!("{AS_RESOURCES} extra is not an Option"))?; + let variant = self + .resolve_variant(info_type)? + .variants + .iter() + .find(|v| v.name == info_variant) + .ok_or_else(|| format!("AsResourcesInfo::{info_variant} not found in metadata"))?; + let collection_type = variant + .fields + .iter() + .rev() + .map(|field| field.ty.id) + .find(|&id| { + self.resolve_type(id).is_ok_and(|ty| { + ty.path.segments.last().map(String::as_str) == Some("MembershipCollection") + }) + }) + .ok_or_else(|| { + format!("AsResourcesInfo::{info_variant} carries no MembershipCollection field") + })?; + let lite_people = self + .resolve_variant(collection_type)? + .variants + .iter() + .find(|v| v.name == "LitePeople") + .ok_or_else(|| "MembershipCollection::LitePeople not found in metadata".to_string())?; + Ok((variant.index, lite_people.index)) + } + + /// Resolve a type id in the registry. + fn resolve_type(&self, type_id: u32) -> Result<&scale_info::Type, String> { + self.registry + .resolve(type_id) + .ok_or_else(|| format!("unknown type id {type_id}")) + } + + /// Resolve `type_id` as an enum definition. + fn resolve_variant(&self, type_id: u32) -> Result<&TypeDefVariant, String> { + match &self.resolve_type(type_id)?.type_def { + TypeDef::Variant(variant) => Ok(variant), + _ => Err(format!("type {type_id} is not an enum")), + } + } + + /// The field type of a one-field composite. + fn single_field_type(&self, type_id: u32) -> Result { + let TypeDef::Composite(composite) = &self.resolve_type(type_id)?.type_def else { + return Err(format!("type {type_id} is not a composite")); + }; + match composite.fields.as_slice() { + [field] => Ok(field.ty.id), + fields => Err(format!( + "type {type_id} has {} fields, expected 1", + fields.len() + )), + } + } + /// Encode every signed extension in metadata order. pub fn encode_signed_extensions(&self, state: &ChainState) -> Vec { self.extensions @@ -397,6 +508,45 @@ mod tests { ); } + #[test] + fn call_and_variant_indices_resolve_by_name() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + + assert_eq!( + ( + metadata + .call_indices("Resources", "set_statement_store_account") + .unwrap(), + metadata + .call_indices("Resources", "claim_long_term_storage") + .unwrap(), + metadata + .as_resources_variant_indices("RegisterStatementStoreAllowance") + .unwrap(), + metadata + .as_resources_variant_indices("ClaimLongTermStorage") + .unwrap(), + ), + ([0x3f, 0x0a], [0x3f, 0x0c], (0x02, 0x01), (0x03, 0x01)), + ); + } + + #[test] + fn index_resolution_fails_for_unknown_names() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + + assert_eq!( + ( + metadata.call_indices("Resources", "no_such_call").is_err(), + metadata.call_indices("NoSuchPallet", "transfer").is_err(), + metadata + .as_resources_variant_indices("NoSuchVariant") + .is_err(), + ), + (true, true, true), + ); + } + #[test] fn dropping_the_version_byte_changes_the_hash() { let metadata = Metadata::decode(FIXTURE).unwrap(); diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs index a427e88a..af353b07 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs @@ -1,20 +1,13 @@ //! `Resources.set_statement_store_account` call + unsigned General (v5) //! extrinsic assembly. Mirrors signing-bot `allocation.ts` / `extrinsic-submit.ts`. +//! Dispatch and variant indices are resolved by name from the fetched runtime +//! metadata, so a re-indexed runtime fails loudly instead of encoding a wrong +//! call. use parity_scale_codec::{Compact, Encode}; use super::extension::{ChainState, Metadata}; -/// Pallet + call index for `Resources.set_statement_store_account` (63 / 10). -pub const SET_STATEMENT_STORE_ACCOUNT_CALL: [u8; 2] = [0x3f, 0x0a]; -/// Pallet + call index for `Resources.claim_long_term_storage` (63 / 12). -pub const CLAIM_LONG_TERM_STORAGE_CALL: [u8; 2] = [0x3f, 0x0c]; -/// `AsResourcesInfo::RegisterStatementStoreAllowance` variant index. -const REGISTER_STATEMENT_STORE_ALLOWANCE: u8 = 0x02; -/// `AsResourcesInfo::ClaimLongTermStorage` variant index. -const CLAIM_LONG_TERM_STORAGE: u8 = 0x03; -/// `MembershipCollection::LitePeople` variant index. -const MEMBERSHIP_COLLECTION_LITE_PEOPLE: u8 = 0x01; /// General-transaction preamble byte: `0b01` (General) | version 5. const GENERAL_V5_PREAMBLE: u8 = 0x45; /// Current signed-extension version byte. @@ -23,56 +16,81 @@ const EXTENSION_VERSION: u8 = 0x00; const OPTION_SOME: u8 = 0x01; /// Encode `Resources.set_statement_store_account(period, seq, target)`: -/// `3f 0a ‖ period_u32LE ‖ seq_u32LE ‖ target[32]`. -pub fn build_set_statement_store_account_call(period: u32, seq: u32, target: &[u8; 32]) -> Vec { +/// `pallet ‖ call ‖ period_u32LE ‖ seq_u32LE ‖ target[32]`, with the dispatch +/// indices resolved from `metadata`. +pub fn build_set_statement_store_account_call( + metadata: &Metadata, + period: u32, + seq: u32, + target: &[u8; 32], +) -> Result, String> { + let indices = metadata.call_indices("Resources", "set_statement_store_account")?; let mut call = Vec::with_capacity(2 + 4 + 4 + 32); - call.extend_from_slice(&SET_STATEMENT_STORE_ACCOUNT_CALL); + call.extend_from_slice(&indices); call.extend_from_slice(&period.to_le_bytes()); call.extend_from_slice(&seq.to_le_bytes()); call.extend_from_slice(target); - call + Ok(call) } /// Encode `Resources.claim_long_term_storage(period, counter, account_id)`: -/// `3f 0c ‖ period_u32LE ‖ counter_u8 ‖ account_id[32]`. +/// `pallet ‖ call ‖ period_u32LE ‖ counter_u8 ‖ account_id[32]`, with the +/// dispatch indices resolved from `metadata`. pub fn build_claim_long_term_storage_call( + metadata: &Metadata, period: u32, counter: u8, account_id: &[u8; 32], -) -> Vec { +) -> Result, String> { + let indices = metadata.call_indices("Resources", "claim_long_term_storage")?; let mut call = Vec::with_capacity(2 + 4 + 1 + 32); - call.extend_from_slice(&CLAIM_LONG_TERM_STORAGE_CALL); + call.extend_from_slice(&indices); call.extend_from_slice(&period.to_le_bytes()); call.push(counter); call.extend_from_slice(account_id); - call + Ok(call) } /// Encode the `AsResources` extension `extra` for a statement-store allowance: -/// `Some(RegisterStatementStoreAllowance { proof, ring_index, LitePeople })`. -pub fn build_as_resources_extra(proof: &[u8], ring_index: u32) -> Vec { +/// `Some(RegisterStatementStoreAllowance { proof, ring_index, LitePeople })`, +/// with the variant indices resolved from `metadata`. +pub fn build_as_resources_extra( + metadata: &Metadata, + proof: &[u8], + ring_index: u32, +) -> Result, String> { + let (info_index, lite_people) = + metadata.as_resources_variant_indices("RegisterStatementStoreAllowance")?; let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 1); extra.push(OPTION_SOME); - extra.push(REGISTER_STATEMENT_STORE_ALLOWANCE); + extra.push(info_index); extra.extend_from_slice(&Compact(proof.len() as u32).encode()); extra.extend_from_slice(proof); extra.extend_from_slice(&ring_index.to_le_bytes()); - extra.push(MEMBERSHIP_COLLECTION_LITE_PEOPLE); - extra + extra.push(lite_people); + Ok(extra) } /// Encode the `AsResources` extension `extra` for a long-term storage claim: -/// `Some(ClaimLongTermStorage { proof, ring_index, revision, LitePeople })`. -pub fn build_long_term_storage_extra(proof: &[u8], ring_index: u32, revision: u32) -> Vec { +/// `Some(ClaimLongTermStorage { proof, ring_index, revision, LitePeople })`, +/// with the variant indices resolved from `metadata`. +pub fn build_long_term_storage_extra( + metadata: &Metadata, + proof: &[u8], + ring_index: u32, + revision: u32, +) -> Result, String> { + let (info_index, lite_people) = + metadata.as_resources_variant_indices("ClaimLongTermStorage")?; let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 4 + 1); extra.push(OPTION_SOME); - extra.push(CLAIM_LONG_TERM_STORAGE); + extra.push(info_index); extra.extend_from_slice(&Compact(proof.len() as u32).encode()); extra.extend_from_slice(proof); extra.extend_from_slice(&ring_index.to_le_bytes()); extra.extend_from_slice(&revision.to_le_bytes()); - extra.push(MEMBERSHIP_COLLECTION_LITE_PEOPLE); - extra + extra.push(lite_people); + Ok(extra) } /// Assemble the unsigned General (v5) extrinsic: @@ -120,7 +138,8 @@ mod tests { #[test] fn call_layout_is_pallet_call_period_seq_target() { - let call = build_set_statement_store_account_call(7, 0, &[0u8; 32]); + let metadata = Metadata::decode(FIXTURE).unwrap(); + let call = build_set_statement_store_account_call(&metadata, 7, 0, &[0u8; 32]).unwrap(); assert_eq!( call, [ @@ -135,7 +154,8 @@ mod tests { #[test] fn long_term_storage_call_layout_is_pallet_call_period_counter_account() { - let call = build_claim_long_term_storage_call(7, 3, &[0u8; 32]); + let metadata = Metadata::decode(FIXTURE).unwrap(); + let call = build_claim_long_term_storage_call(&metadata, 7, 3, &[0u8; 32]).unwrap(); assert_eq!( call, [ @@ -150,35 +170,49 @@ mod tests { #[test] fn as_resources_extra_wraps_proof_as_bytes() { + let metadata = Metadata::decode(FIXTURE).unwrap(); let proof = vec![0xEE; 785]; - let extra = build_as_resources_extra(&proof, 3); + let extra = build_as_resources_extra(&metadata, &proof, 3).unwrap(); // Some(0x01) ‖ variant(0x02) ‖ compact(785)=0x45,0x0c ‖ 785 bytes ‖ ringIndex LE ‖ LitePeople. - assert_eq!(&extra[..2], &[0x01, 0x02]); - assert_eq!(&extra[2..4], &Compact(785u32).encode()[..]); - assert_eq!(&extra[4..4 + 785], &proof[..]); - assert_eq!(&extra[4 + 785..4 + 785 + 4], &3u32.to_le_bytes()); - assert_eq!(extra[4 + 785 + 4], MEMBERSHIP_COLLECTION_LITE_PEOPLE); + assert_eq!( + extra, + [ + vec![0x01, 0x02], + Compact(785u32).encode(), + proof, + 3u32.to_le_bytes().to_vec(), + vec![0x01], + ] + .concat() + ); } #[test] fn long_term_storage_extra_wraps_revision() { + let metadata = Metadata::decode(FIXTURE).unwrap(); let proof = vec![0xEE; 785]; - let extra = build_long_term_storage_extra(&proof, 3, 9); + let extra = build_long_term_storage_extra(&metadata, &proof, 3, 9).unwrap(); // Some(0x01) ‖ variant(0x03) ‖ compact(785)=0x45,0x0c ‖ proof // ‖ ringIndex LE ‖ revision LE ‖ LitePeople. - assert_eq!(&extra[..2], &[0x01, 0x03]); - assert_eq!(&extra[2..4], &Compact(785u32).encode()[..]); - assert_eq!(&extra[4..4 + 785], &proof[..]); - assert_eq!(&extra[4 + 785..4 + 785 + 4], &3u32.to_le_bytes()); - assert_eq!(&extra[4 + 785 + 4..4 + 785 + 8], &9u32.to_le_bytes()); - assert_eq!(extra[4 + 785 + 8], MEMBERSHIP_COLLECTION_LITE_PEOPLE); + assert_eq!( + extra, + [ + vec![0x01, 0x03], + Compact(785u32).encode(), + proof, + 3u32.to_le_bytes().to_vec(), + 9u32.to_le_bytes().to_vec(), + vec![0x01], + ] + .concat() + ); } #[test] fn extrinsic_has_general_v5_preamble_and_embeds_call() { let metadata = Metadata::decode(FIXTURE).unwrap(); - let call = build_set_statement_store_account_call(7, 0, &[0u8; 32]); - let extra = build_as_resources_extra(&vec![0xEE; 785], 0); + let call = build_set_statement_store_account_call(&metadata, 7, 0, &[0u8; 32]).unwrap(); + let extra = build_as_resources_extra(&metadata, &[0xEE; 785], 0).unwrap(); let xt = build_unsigned_extrinsic(&metadata, &fixture_state(), &call, &extra).unwrap(); // Strip the compact length prefix and check the body head + tail. diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs index e70be951..320c560b 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs @@ -24,6 +24,8 @@ pub struct RingParams { pub exponent: u8, /// Ring index these members belong to. pub ring_index: u32, + /// Finalized block hash the ring snapshot was read at. + pub block_hash: String, } /// `Members.CurrentRingIndex[id]` storage key. @@ -100,23 +102,43 @@ fn ring_exponent_from_name(name: &str) -> Result { } } -/// Read the current LitePeople ring index (absent => 0). +/// Read the current LitePeople ring index at the current best block +/// (absent => 0). pub async fn read_current_ring_index(rpc: &RpcClient) -> Result { - match rpc - .get_storage(¤t_ring_index_key()) - .await - .map_err(|e| e.to_string())? - { + decode_ring_index( + rpc.get_storage(¤t_ring_index_key()) + .await + .map_err(|e| e.to_string())?, + ) +} + +/// Read the current LitePeople ring index pinned to block `at` (absent => 0). +pub async fn read_current_ring_index_at(rpc: &RpcClient, at: &str) -> Result { + decode_ring_index( + rpc.get_storage_at(¤t_ring_index_key(), at) + .await + .map_err(|e| e.to_string())?, + ) +} + +/// Decode a `CurrentRingIndex` storage value (absent => 0). +fn decode_ring_index(bytes: Option>) -> Result { + match bytes { Some(bytes) => u32::decode(&mut &bytes[..]).map_err(|e| format!("ring index: {e}")), None => Ok(0), } } -/// Read the LitePeople ring size exponent from `Collections[LitePeople].ring_size`. -/// This is a chain constant, so read it once and reuse across ring indices. -pub async fn read_ring_exponent(rpc: &RpcClient, metadata: &Metadata) -> Result { +/// Read the LitePeople ring size exponent from `Collections[LitePeople].ring_size`, +/// pinned to block `at`. This is a chain constant, so read it once and reuse +/// across ring indices. +pub async fn read_ring_exponent( + rpc: &RpcClient, + metadata: &Metadata, + at: &str, +) -> Result { let collection = rpc - .get_storage(&collections_key()) + .get_storage_at(&collections_key(), at) .await .map_err(|e| e.to_string())? .ok_or_else(|| "Members.Collections[LitePeople] missing".to_string())?; @@ -128,16 +150,19 @@ pub async fn read_ring_exponent(rpc: &RpcClient, metadata: &Metadata) -> Result< ring_exponent_from_name(&variant) } -/// Read the members of `ring_index`, sliced to the baked-in `included` prefix. +/// Read the members of `ring_index`, sliced to the baked-in `included` +/// prefix, with every read pinned to block `at` so pages and status come from +/// one consistent snapshot. pub async fn read_ring_members_at( rpc: &RpcClient, ring_index: u32, + at: &str, ) -> Result, String> { // 1. Page through RingKeys collecting raw 32-byte members. let mut members = Vec::new(); for page in 0.. { let Some(bytes) = rpc - .get_storage(&ring_keys_key(ring_index, page)) + .get_storage_at(&ring_keys_key(ring_index, page), at) .await .map_err(|e| e.to_string())? else { @@ -162,7 +187,7 @@ pub async fn read_ring_members_at( // 2. Slice to the baked-in `included` prefix (absent status => all included). if let Some(status) = rpc - .get_storage(&ring_keys_status_key(ring_index)) + .get_storage_at(&ring_keys_status_key(ring_index), at) .await .map_err(|e| e.to_string())? { @@ -178,14 +203,16 @@ pub async fn read_ring_members_at( Ok(members) } -/// Read `Members.Root[LitePeople][ring_index].revision` (absent => 0). +/// Read `Members.Root[LitePeople][ring_index].revision` pinned to block `at` +/// (absent => 0). pub async fn read_ring_revision( rpc: &RpcClient, metadata: &Metadata, ring_index: u32, + at: &str, ) -> Result { match rpc - .get_storage(&ring_root_key(ring_index)) + .get_storage_at(&ring_root_key(ring_index), at) .await .map_err(|e| e.to_string())? { @@ -199,3 +226,42 @@ pub async fn read_ring_revision( None => Ok(0), } } + +#[cfg(test)] +mod tests { + use subxt_rpcs::RpcClient as HostRpcClient; + + use super::super::rpc::testing::ScriptedRpc; + use super::*; + + #[test] + fn member_reads_are_pinned_and_truncated_to_included() { + // Page 0 holds two members; RingStatus { total: 2, included: 1, None }. + let page = format!( + r#""0x08{}{}""#, + hex::encode([0xaa; 32]), + hex::encode([0xbb; 32]), + ); + let status = r#""0x020000000100000000""#; + let scripted = ScriptedRpc::new([page.as_str(), "null", status]); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + + let members = futures::executor::block_on(read_ring_members_at(&rpc, 3, "0xat")).unwrap(); + + assert_eq!(members, vec![[0xaa; 32]]); + let expected: Vec<(String, String)> = [ + ring_keys_key(3, 0), + ring_keys_key(3, 1), + ring_keys_status_key(3), + ] + .into_iter() + .map(|key| { + ( + "state_getStorage".to_string(), + format!(r#"["0x{}","0xat"]"#, hex::encode(key)), + ) + }) + .collect(); + assert_eq!(scripted.calls(), expected); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs index 34e04642..bd51be99 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs @@ -3,7 +3,7 @@ use core::time::Duration; use futures::{FutureExt, pin_mut}; -use serde_json::Value; +use serde_json::{Value, json}; use subxt_rpcs::RpcClient as HostRpcClient; use subxt_rpcs::client::{RpcClient as NativeRpcClient, RpcParams, rpc_params}; @@ -38,12 +38,31 @@ impl RpcClient { .map_err(rpc_error_message) } - /// `state_getStorage(key)` -> raw value bytes, or `None` if absent. + /// `state_getStorage(key)` at the current best block -> raw value bytes, + /// or `None` if absent. pub async fn get_storage(&self, key: &[u8]) -> Result>, String> { + self.get_storage_maybe_at(key, None).await + } + + /// `state_getStorage(key, at)` pinned to block `at` -> raw value bytes, + /// or `None` if absent. + pub async fn get_storage_at(&self, key: &[u8], at: &str) -> Result>, String> { + self.get_storage_maybe_at(key, Some(at)).await + } + + async fn get_storage_maybe_at( + &self, + key: &[u8], + at: Option<&str>, + ) -> Result>, String> { let key_hex = format!("0x{}", hex::encode(key)); + let params = match at { + Some(at) => rpc_params![key_hex, at], + None => rpc_params![key_hex], + }; match self .inner - .request::("state_getStorage", rpc_params![key_hex]) + .request::("state_getStorage", params) .await .map_err(rpc_error_message)? { @@ -52,6 +71,15 @@ impl RpcClient { } } + /// `chain_getFinalizedHead` -> hash of the latest finalized block. + pub async fn finalized_head(&self) -> Result { + let value = self.call("chain_getFinalizedHead", json!([])).await?; + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| "chain_getFinalizedHead returned non-string".to_string()) + } + /// Submit an extrinsic and wait for `inBlock` or `finalized`; returns the block hash. pub async fn submit_and_watch(&self, extrinsic: &[u8]) -> Result { let extrinsic_hex = format!("0x{}", hex::encode(extrinsic)); @@ -88,6 +116,7 @@ impl RpcClient { } } +#[derive(Debug, PartialEq)] enum ExtrinsicStatus { Included(String), Rejected(String), @@ -100,7 +129,13 @@ fn extrinsic_status(status: &Value) -> ExtrinsicStatus { return ExtrinsicStatus::Included(hash.to_string()); } } - for key in ["invalid", "dropped", "usurped", "finalityTimeout"] { + for key in [ + "invalid", + "dropped", + "usurped", + "retracted", + "finalityTimeout", + ] { if status.get(key).is_some() { return ExtrinsicStatus::Rejected(key.to_string()); } @@ -131,11 +166,101 @@ fn rpc_error_message(error: subxt_rpcs::Error) -> String { } } +#[cfg(test)] +pub(crate) mod testing { + //! Scripted JSON-RPC transport for exercising request shapes in tests. + + use std::sync::{Arc, Mutex}; + + use subxt_rpcs::client::{RawRpcFuture, RawRpcSubscription, RawValue, RpcClientT}; + + /// Records every request as `(method, params)` and replays canned JSON + /// results in order; subscriptions replay the scripted notification items. + #[derive(Clone, Default)] + pub(crate) struct ScriptedRpc(Arc); + + #[derive(Default)] + struct Inner { + calls: Mutex>, + responses: Mutex>, + subscription_items: Mutex>, + } + + impl ScriptedRpc { + /// A script answering requests with `responses`, in order. + pub(crate) fn new<'a>(responses: impl IntoIterator) -> Self { + let scripted = Self::default(); + *scripted.0.responses.lock().unwrap() = + responses.into_iter().map(str::to_owned).collect(); + scripted + } + + /// Queue the notification items for the next subscription. + pub(crate) fn script_subscription<'a>(&self, items: impl IntoIterator) { + *self.0.subscription_items.lock().unwrap() = + items.into_iter().map(str::to_owned).collect(); + } + + /// The `(method, params)` pairs seen so far. + pub(crate) fn calls(&self) -> Vec<(String, String)> { + self.0.calls.lock().unwrap().clone() + } + } + + fn params_json(params: Option>) -> String { + params.map_or_else(|| "[]".to_string(), |p| p.get().to_owned()) + } + + impl RpcClientT for ScriptedRpc { + fn request_raw<'a>( + &'a self, + method: &'a str, + params: Option>, + ) -> RawRpcFuture<'a, Box> { + self.0 + .calls + .lock() + .unwrap() + .push((method.to_owned(), params_json(params))); + let mut responses = self.0.responses.lock().unwrap(); + assert!(!responses.is_empty(), "unscripted request `{method}`"); + let response = responses.remove(0); + Box::pin(async move { + Ok(RawValue::from_string(response).expect("scripted response is valid JSON")) + }) + } + + fn subscribe_raw<'a>( + &'a self, + sub: &'a str, + params: Option>, + _unsub: &'a str, + ) -> RawRpcFuture<'a, RawRpcSubscription> { + self.0 + .calls + .lock() + .unwrap() + .push((sub.to_owned(), params_json(params))); + let items: Vec<_> = core::mem::take(&mut *self.0.subscription_items.lock().unwrap()) + .into_iter() + .map(|item| Ok(RawValue::from_string(item).expect("scripted item is valid JSON"))) + .collect(); + Box::pin(async move { + Ok(RawRpcSubscription { + stream: Box::pin(futures::stream::iter(items)), + id: Some("scripted".to_string()), + }) + }) + } + } +} + #[cfg(test)] mod tests { use serde_json::json; - use super::{ExtrinsicStatus, extrinsic_status}; + use super::testing::ScriptedRpc; + use super::{ExtrinsicStatus, HostRpcClient, RpcClient, extrinsic_status}; #[test] fn in_block_status_completes_submission() { @@ -150,4 +275,77 @@ mod tests { assert!(matches!(status, ExtrinsicStatus::Included(hash) if hash == "0xabcd")); } + + #[test] + fn terminal_pool_statuses_reject_the_submission() { + let statuses: Vec = [ + "invalid", + "dropped", + "usurped", + "retracted", + "finalityTimeout", + ] + .into_iter() + .map(|key| extrinsic_status(&json!({key: "0x1234"}))) + .collect(); + + assert_eq!( + statuses, + vec![ + ExtrinsicStatus::Rejected("invalid".to_string()), + ExtrinsicStatus::Rejected("dropped".to_string()), + ExtrinsicStatus::Rejected("usurped".to_string()), + ExtrinsicStatus::Rejected("retracted".to_string()), + ExtrinsicStatus::Rejected("finalityTimeout".to_string()), + ], + ); + } + + #[test] + fn get_storage_at_pins_the_read_to_a_block() { + let scripted = ScriptedRpc::new([r#""0x0102""#]); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + + let value = futures::executor::block_on(rpc.get_storage_at(b"key", "0xat")).unwrap(); + + assert_eq!(value, Some(vec![0x01, 0x02])); + assert_eq!( + scripted.calls(), + vec![( + "state_getStorage".to_string(), + r#"["0x6b6579","0xat"]"#.to_string(), + )], + ); + } + + #[test] + fn get_storage_reads_at_the_current_block() { + let scripted = ScriptedRpc::new(["null"]); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + + let value = futures::executor::block_on(rpc.get_storage(b"key")).unwrap(); + + assert_eq!(value, None); + assert_eq!( + scripted.calls(), + vec![( + "state_getStorage".to_string(), + r#"["0x6b6579"]"#.to_string() + )], + ); + } + + #[test] + fn finalized_head_returns_the_hash() { + let scripted = ScriptedRpc::new([r#""0xfeed""#]); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + + let head = futures::executor::block_on(rpc.finalized_head()).unwrap(); + + assert_eq!(head, "0xfeed"); + assert_eq!( + scripted.calls(), + vec![("chain_getFinalizedHead".to_string(), "[]".to_string())], + ); + } } diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs index a9a99b81..7ee4b6e5 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -138,6 +138,24 @@ fn entry_account_id(bytes: &[u8]) -> Option<[u8; 32]> { bytes.get(..32).map(|s| s.try_into().expect("32 bytes")) } +/// The account holding our alias slot `(period, seq)`, read pinned to +/// `block_hash` (`None` when the slot entry is absent). +pub async fn read_slot_account_at( + rpc: &RpcClient, + entropy: [u8; 32], + period: u32, + seq: u32, + block_hash: &str, +) -> Result, String> { + let alias = slot_alias(entropy, period, seq)?; + let key = statement_store_allowance_key(period, &alias); + Ok(rpc + .get_storage_at(&key, block_hash) + .await + .map_err(|e| e.to_string())? + .and_then(|bytes| entry_account_id(&bytes))) +} + /// Outcome of scanning for a slot to register `target` in. pub enum SlotSelection { /// A free `seq` we should claim. From 1aed8f4dbaee4fca34686fdd224dfcb11f3ca1a8 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 23 Jul 2026 14:51:50 +0200 Subject: [PATCH 04/34] fixup --- rust/crates/truapi-platform/src/lib.rs | 5 + .../truapi-server/src/host_logic/extrinsic.rs | 18 +- .../src/host_logic/permissions.rs | 54 +++++ .../src/host_logic/transaction.rs | 170 +++++++++++----- rust/crates/truapi-server/src/runtime.rs | 166 +++++++++++----- .../truapi-server/src/runtime/authority.rs | 2 + .../truapi-server/src/runtime/pairing_host.rs | 1 + .../src/runtime/pairing_host/sso_channel.rs | 34 ++-- .../truapi-server/src/runtime/signing_host.rs | 186 +++++++++++++++--- .../src/runtime/signing_host/sso_responder.rs | 153 ++++++++++---- .../src/runtime/statement_allowance.rs | 54 +++-- .../src/runtime/statement_allowance/slot.rs | 3 +- 12 files changed, 650 insertions(+), 196 deletions(-) diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 29a071fb..920f99e4 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -323,6 +323,11 @@ pub enum PermissionAuthorizationRequest { Remote(RemotePermissionRequest), /// Product-scoped permission to disclose the user's primary identity. IdentityDisclosure, + /// Product-scoped permission to access another product's account context. + AccountAccess { + /// Product whose account context may be accessed. + target_product_id: String, + }, } /// Authorization status for a permission request. diff --git a/rust/crates/truapi-server/src/host_logic/extrinsic.rs b/rust/crates/truapi-server/src/host_logic/extrinsic.rs index 713ed740..c72b1809 100644 --- a/rust/crates/truapi-server/src/host_logic/extrinsic.rs +++ b/rust/crates/truapi-server/src/host_logic/extrinsic.rs @@ -116,13 +116,25 @@ pub(crate) fn build_signed_extrinsic_v4( signer: &Sr25519Signer, call_data: &[u8], extensions: &[v01::TxPayloadExtension], +) -> Vec { + let signature = signer.sign(&v4_signer_payload(call_data, extensions)); + build_signed_extrinsic_v4_with_signature(signer.account_id(), &signature, call_data, extensions) +} + +/// Assemble a signed Extrinsic V4 using an existing signature. +/// +/// This keeps `signPayload`'s returned typed signature byte-for-byte identical +/// to the signature embedded in an optionally requested signed transaction. +pub(crate) fn build_signed_extrinsic_v4_with_signature( + account_id: AccountId32, + signature: &MultiSignature, + call_data: &[u8], + extensions: &[v01::TxPayloadExtension], ) -> Vec { /// `0b1000_0000 | 4`: the "signed" bit plus extrinsic format version 4. const EXTRINSIC_V4_SIGNED: u8 = 0x84; - let signature = signer.sign(&v4_signer_payload(call_data, extensions)); - let address = MultiAddress::::Id(signer.account_id()); - + let address = MultiAddress::::Id(account_id); let mut inner = Vec::new(); inner.push(EXTRINSIC_V4_SIGNED); address.encode_to(&mut inner); diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs index d44ec1da..7db47cdf 100644 --- a/rust/crates/truapi-server/src/host_logic/permissions.rs +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -114,6 +114,13 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a ) .await } + PermissionAuthorizationRequest::AccountAccess { target_product_id } => { + authorization_status( + self.storage, + account_access_core_storage_key(self.product_id, target_product_id), + ) + .await + } } } @@ -149,6 +156,9 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a PermissionAuthorizationRequest::IdentityDisclosure => { identity_disclosure_core_storage_key(self.product_id) } + PermissionAuthorizationRequest::AccountAccess { target_product_id } => { + account_access_core_storage_key(self.product_id, target_product_id) + } }; set_authorization_status(self.storage, key, status).await } @@ -268,6 +278,15 @@ fn identity_disclosure_core_storage_key(product_id: &str) -> CoreStorageKey { } } +fn account_access_core_storage_key(product_id: &str, target_product_id: &str) -> CoreStorageKey { + CoreStorageKey::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::AccountAccess { + target_product_id: target_product_id.to_string(), + }, + } +} + fn canonical_remote_request(request: &RemotePermissionRequest) -> RemotePermissionRequest { let permission = match &request.permission { RemotePermission::Remote { domains } => { @@ -638,6 +657,41 @@ mod tests { ); } + #[test] + fn account_access_authorization_is_scoped_by_requester_and_target() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + let request = PermissionAuthorizationRequest::AccountAccess { + target_product_id: "target.dot".to_string(), + }; + + 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 + ); + assert_eq!( + futures::executor::block_on(service.authorization_status( + &PermissionAuthorizationRequest::AccountAccess { + target_product_id: "other.dot".to_string(), + } + )) + .unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + + let other_product_service = PermissionsService::new(&storage, &prompt, "other.dot"); + assert_eq!( + futures::executor::block_on(other_product_service.authorization_status(&request)) + .unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + } + /// Prompt callback that always errors, to exercise the transient-failure /// path (fail closed for the current call, but do not persist the error). struct FailingPrompt; diff --git a/rust/crates/truapi-server/src/host_logic/transaction.rs b/rust/crates/truapi-server/src/host_logic/transaction.rs index 8dec1e50..a1b08ac8 100644 --- a/rust/crates/truapi-server/src/host_logic/transaction.rs +++ b/rust/crates/truapi-server/src/host_logic/transaction.rs @@ -1,16 +1,13 @@ //! Extrinsic signing preimages assembled from pre-encoded payload fields. //! //! Signing hosts receive the pre-encoded fields of [`HostSignPayloadData`], -//! so no chain metadata is needed: the preimage is the polkadot-js -//! `ExtrinsicPayloadV4` byte layout. The optional `ChargeAssetTxPayment` -//! (`asset_id` after `tip`) and `CheckMetadataHash` (`mode` extra plus the -//! `Option`-encoded `metadata_hash` implicit) fields participate when the -//! payload's signed-extension list names them. Extensions outside that set -//! are assumed to carry no extra or implicit bytes, matching polkadot-js -//! without user extensions. Preimages longer than 256 bytes are BLAKE2b-256 +//! so no chain metadata is needed for the standard Substrate extensions. The +//! payload's `signed_extensions` list supplies runtime order; extensions whose +//! bytes cannot be represented by [`HostSignPayloadData`] are rejected instead +//! of being silently omitted. Preimages longer than 256 bytes are BLAKE2b-256 //! hashed before signing (standard Substrate signed-payload rule). -use truapi::latest::HostSignPayloadData; +use truapi::latest::{HostSignPayloadData, TxPayloadExtension}; /// Preimages longer than this are hashed before signing. const MAX_SIGNED_PREIMAGE_LEN: usize = 256; @@ -20,47 +17,79 @@ const CHARGE_ASSET_TX_PAYMENT: &str = "ChargeAssetTxPayment"; /// Signed extension contributing the `mode` extra and `metadata_hash` implicit. const CHECK_METADATA_HASH: &str = "CheckMetadataHash"; -/// Signing preimage for an extrinsic payload, in the polkadot-js -/// `ExtrinsicPayloadV4` field order: `method ++ era ++ nonce ++ tip ++ -/// [asset_id] ++ [mode] ++ spec_version ++ transaction_version ++ -/// genesis_hash ++ block_hash ++ [metadata_hash]`. +/// Standard signed extensions with no extra or implicit bytes. +const EMPTY_EXTENSIONS: &[&str] = &["CheckNonZeroSender", "CheckWeight"]; + +/// Encode the standard signed extensions in the order declared by the target +/// runtime. Unknown extensions are rejected because this wire payload has no +/// field carrying their extra or implicit bytes. +pub(crate) fn extrinsic_payload_extensions( + payload: &HostSignPayloadData, +) -> Result, String> { + payload + .signed_extensions + .iter() + .map(|id| { + let (extra, additional_signed) = match id.as_str() { + id if EMPTY_EXTENSIONS.contains(&id) => (Vec::new(), Vec::new()), + "CheckSpecVersion" => (Vec::new(), payload.spec_version.clone()), + "CheckTxVersion" => (Vec::new(), payload.transaction_version.clone()), + "CheckGenesis" => (Vec::new(), payload.genesis_hash.clone()), + "CheckMortality" => (payload.era.clone(), payload.block_hash.clone()), + "CheckNonce" => (payload.nonce.clone(), Vec::new()), + "ChargeTransactionPayment" => (payload.tip.clone(), Vec::new()), + CHARGE_ASSET_TX_PAYMENT => { + let mut extra = payload.tip.clone(); + match &payload.asset_id { + Some(asset_id) => extra.extend_from_slice(asset_id), + None => extra.push(0), + } + (extra, Vec::new()) + } + CHECK_METADATA_HASH => { + let mode = payload.mode.unwrap_or(0); + let mode = u8::try_from(mode).map_err(|_| { + format!("CheckMetadataHash mode {mode} does not fit in a u8") + })?; + let mut additional_signed = Vec::new(); + match &payload.metadata_hash { + Some(hash) => { + additional_signed.push(1); + additional_signed.extend_from_slice(hash); + } + None => additional_signed.push(0), + } + (vec![mode], additional_signed) + } + unsupported => { + return Err(format!( + "unsupported signed extension `{unsupported}`: its encoded fields are not \ + present in HostSignPayloadData" + )); + } + }; + Ok(TxPayloadExtension { + id: id.clone(), + extra, + additional_signed, + }) + }) + .collect() +} + +/// Signing preimage for an extrinsic payload: +/// `method ++ Σextension.extra ++ Σextension.additional_signed`, with both +/// extension sequences following the declared runtime order. pub fn extrinsic_payload_preimage(payload: &HostSignPayloadData) -> Result, String> { - let has_extension = |id: &str| payload.signed_extensions.iter().any(|ext| ext == id); - let charge_asset = has_extension(CHARGE_ASSET_TX_PAYMENT) || payload.asset_id.is_some(); - let check_metadata_hash = has_extension(CHECK_METADATA_HASH) || payload.metadata_hash.is_some(); + let extensions = extrinsic_payload_extensions(payload)?; let mut preimage = Vec::new(); preimage.extend_from_slice(&payload.method); - preimage.extend_from_slice(&payload.era); - preimage.extend_from_slice(&payload.nonce); - preimage.extend_from_slice(&payload.tip); - if charge_asset { - // The wire carries the chain's `TAssetConversion` encoding (itself - // option-typed on asset chains); an absent field means `None`. - match &payload.asset_id { - Some(asset_id) => preimage.extend_from_slice(asset_id), - None => preimage.push(0), - } + for extension in &extensions { + preimage.extend_from_slice(&extension.extra); } - if check_metadata_hash { - let mode = payload.mode.unwrap_or(0); - let mode = u8::try_from(mode) - .map_err(|_| format!("CheckMetadataHash mode {mode} does not fit in a u8"))?; - preimage.push(mode); - } - preimage.extend_from_slice(&payload.spec_version); - preimage.extend_from_slice(&payload.transaction_version); - preimage.extend_from_slice(&payload.genesis_hash); - preimage.extend_from_slice(&payload.block_hash); - if check_metadata_hash { - // `Option<[u8; 32]>` implicit; the wire carries the raw hash. - match &payload.metadata_hash { - Some(hash) => { - preimage.push(1); - preimage.extend_from_slice(hash); - } - None => preimage.push(0), - } + for extension in &extensions { + preimage.extend_from_slice(&extension.additional_signed); } Ok(hash_large_preimage(preimage)) } @@ -92,7 +121,14 @@ mod tests { spec_version: vec![0x51], tip: vec![0x54], transaction_version: vec![0x56], - signed_extensions: vec![], + signed_extensions: vec![ + "CheckSpecVersion".to_string(), + "CheckTxVersion".to_string(), + "CheckGenesis".to_string(), + "CheckMortality".to_string(), + "CheckNonce".to_string(), + "ChargeTransactionPayment".to_string(), + ], version: 4, asset_id: None, metadata_hash: None, @@ -115,6 +151,9 @@ mod tests { fn payload_preimage_places_asset_id_after_tip_and_metadata_hash_last() { let mut payload = payload(); payload.signed_extensions = vec![ + "CheckSpecVersion".to_string(), + "CheckTxVersion".to_string(), + "CheckGenesis".to_string(), "CheckMortality".to_string(), "CheckNonce".to_string(), "ChargeAssetTxPayment".to_string(), @@ -142,6 +181,11 @@ mod tests { // metadata_hash None. let mut payload = payload(); payload.signed_extensions = vec![ + "CheckSpecVersion".to_string(), + "CheckTxVersion".to_string(), + "CheckGenesis".to_string(), + "CheckMortality".to_string(), + "CheckNonce".to_string(), "ChargeAssetTxPayment".to_string(), "CheckMetadataHash".to_string(), ]; @@ -172,6 +216,42 @@ mod tests { ); } + #[test] + fn payload_preimage_follows_noncanonical_runtime_order() { + let mut payload = payload(); + payload.signed_extensions = vec![ + "CheckNonce".to_string(), + "CheckMortality".to_string(), + "CheckGenesis".to_string(), + "CheckSpecVersion".to_string(), + ]; + + assert_eq!( + extrinsic_payload_preimage(&payload).unwrap(), + vec![ + 0x4D, 0x4E, 0xE1, // method, nonce extra, era extra + 0xB1, 0xB2, // mortality implicit + 0x61, 0x62, // genesis implicit + 0x51, // spec-version implicit + ] + ); + } + + #[test] + fn payload_preimage_rejects_unsupported_extension() { + let mut payload = payload(); + payload.signed_extensions = vec!["CustomExtension".to_string()]; + + assert_eq!( + extrinsic_payload_preimage(&payload), + Err( + "unsupported signed extension `CustomExtension`: its encoded fields are not \ + present in HostSignPayloadData" + .to_string() + ) + ); + } + #[test] fn payload_preimage_rejects_out_of_range_mode() { let mut payload = payload(); diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 60b71380..3d13a0e3 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -607,6 +607,51 @@ impl ProductRuntimeHost { } } +async fn account_access_authorization( + services: &RuntimeServices, + requesting_product_id: &str, + target_product_id: &str, +) -> Result { + if requesting_product_id == target_product_id { + return Ok(PermissionAuthorizationStatus::Authorized); + } + + let request = PermissionAuthorizationRequest::AccountAccess { + target_product_id: target_product_id.to_string(), + }; + let service = PermissionsService::new( + services.platform.as_ref(), + services.platform.as_ref(), + requesting_product_id, + ); + let cached = service + .authorization_status(&request) + .await + .map_err(|err| format!("permission storage failed: {err:?}"))?; + if cached != PermissionAuthorizationStatus::NotDetermined { + return Ok(cached); + } + + let confirmed = services + .platform + .confirm_user_action(UserConfirmationReview::AccountAccess(AccountAccessReview { + requesting_product_id: requesting_product_id.to_string(), + target_product_id: target_product_id.to_string(), + })) + .await + .map_err(|err| format!("account access confirmation failed: {err:?}"))?; + let status = if confirmed { + PermissionAuthorizationStatus::Authorized + } else { + PermissionAuthorizationStatus::Denied + }; + service + .set_authorization_status(&request, status) + .await + .map_err(|err| format!("permission storage failed: {err:?}"))?; + Ok(status) +} + fn parse_legacy_signer_hex(signer: &str) -> Option<[u8; 32]> { let raw = signer .strip_prefix("0x") @@ -821,21 +866,23 @@ impl Account for ProductRuntimeHost { let product_id = self.product_id(); if product_account_id.dot_ns_identifier != product_id { - let confirmed = self - .services - .platform - .confirm_user_action(UserConfirmationReview::AccountAccess(AccountAccessReview { - requesting_product_id: product_id, - target_product_id: product_account_id.dot_ns_identifier.clone(), - })) - .await - .map_err(|err| CallError::HostFailure { - reason: format!("account access confirmation failed: {err:?}"), - })?; - if !confirmed { - return Err(CallError::Domain(HostAccountGetError::V1( - v01::HostAccountGetError::Rejected, - ))); + match account_access_authorization( + &self.services, + &product_id, + &product_account_id.dot_ns_identifier, + ) + .await + { + Ok(PermissionAuthorizationStatus::Authorized) => {} + Ok( + PermissionAuthorizationStatus::Denied + | PermissionAuthorizationStatus::NotDetermined, + ) => { + return Err(CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::Rejected, + ))); + } + Err(reason) => return Err(CallError::HostFailure { reason }), } } @@ -1423,15 +1470,6 @@ impl Signing for ProductRuntimeHost { }, )) })?; - if !matches!(signer, LegacySigner::Product) { - return Err(CallError::Domain( - HostCreateTransactionWithLegacyAccountError::V1( - v01::HostCreateTransactionError::Unknown { - reason: LEGACY_PRODUCT_ACCOUNT_MISMATCH_REASON.to_string(), - }, - ), - )); - } self.require_chain_submit(HostCreateTransactionWithLegacyAccountError::V1( v01::HostCreateTransactionError::PermissionDenied, )) @@ -1454,19 +1492,20 @@ impl Signing for ProductRuntimeHost { )); } let cx = remote_authority_context(cx); + let authority_request = match signer { + LegacySigner::Product => CreateTransactionAuthorityRequest::LegacyAccount { + product_account: v01::ProductAccountId { + dot_ns_identifier: self.product_id(), + derivation_index: 0, + }, + request: inner, + }, + LegacySigner::Identity(_) => CreateTransactionAuthorityRequest::IdentityAccount(inner), + }; remote_authority_call( &cx, - self.authority.create_transaction( - &cx, - &session, - CreateTransactionAuthorityRequest::LegacyAccount { - product_account: v01::ProductAccountId { - dot_ns_identifier: self.product_id(), - derivation_index: 0, - }, - request: inner, - }, - ), + self.authority + .create_transaction(&cx, &session, authority_request), ) .await .map(|response| { @@ -3828,13 +3867,34 @@ mod tests { } #[test] - fn legacy_create_transaction_rejects_identity_account() { - let session = session_info(); + fn legacy_create_transaction_accepts_identity_account_then_routes_legacy_request() { + let session = sso_session_info(); let identity = session.identity_account_id.unwrap(); - let host = - ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); - host.test_session_state().set_session(session); - let cx = CallContext::new(); + let platform = Arc::new(StubPlatform { + create_transaction_confirmed: true, + sso_response_script: Some(sso_success_response_script( + &session, + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-identity-create-tx-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::CreateTransactionResponse( + crate::host_logic::sso::messages::CreateTransactionResponse { + responding_to: "identity-create-tx-1".to_string(), + signed_transaction: Ok(vec![0xca, 0xfe]), + }, + ), + ), + }, + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("identity-create-tx-1".to_string()); let request = HostCreateTransactionWithLegacyAccountRequest::V1(v01::LegacyAccountTxPayload { signer: identity, @@ -3844,16 +3904,24 @@ mod tests { tx_ext_version: 0, }); - let err = + let response = futures::executor::block_on(host.create_transaction_with_legacy_account(&cx, request)) - .unwrap_err(); + .unwrap(); - match err { - CallError::Domain(HostCreateTransactionWithLegacyAccountError::V1( - v01::HostCreateTransactionError::Unknown { reason }, - )) => assert_eq!(reason, LEGACY_PRODUCT_ACCOUNT_MISMATCH_REASON), - other => panic!("expected identity account rejection, got {other:?}"), - } + let HostCreateTransactionWithLegacyAccountResponse::V1(inner) = response; + assert_eq!(inner.transaction, vec![0xca, 0xfe]); + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::CreateTransactionLegacyRequest( + request, + ), + ) = message.data + else { + panic!("expected identity transaction request"); + }; + let crate::host_logic::sso::messages::CreateTransactionLegacyPayload::V1(payload) = + request.payload; + assert_eq!(payload.signer, identity); } #[test] diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index c48c2ca3..e76cbaf3 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -220,6 +220,8 @@ pub(crate) enum CreateTransactionAuthorityRequest { /// Original legacy-account transaction request. request: LegacyAccountTxPayload, }, + /// Create a transaction with the active wallet's identity account. + IdentityAccount(LegacyAccountTxPayload), } /// Contextual-alias request forwarded to the account authority (RFC 0004). diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index 6d3603a9..07c944d7 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -81,6 +81,7 @@ impl From<&CreateTransactionAuthorityRequest> for AuthorityRequestKind { CreateTransactionAuthorityRequest::LegacyAccount { .. } => { Self::LegacyCreateTransaction } + CreateTransactionAuthorityRequest::IdentityAccount(_) => Self::LegacyCreateTransaction, } } } diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index c97cfc11..5453fbb8 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -17,9 +17,10 @@ use crate::host_logic::session::{SessionInfo, SessionState, SsoSessionInfo}; use crate::host_logic::sso::messages::{ OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, RingVrfError, SsoAllocatedResource, SsoAllocationOutcome, SsoRemoteResponse, SsoSessionStatement, - alias_request_message, build_outgoing_request_statement, create_transaction_message, - decode_sso_session_statement, proof_request_message, resource_allocation_message, - sign_payload_message, sign_raw_legacy_message, sign_raw_message, v1, + alias_request_message, build_outgoing_request_statement, create_transaction_legacy_message, + create_transaction_message, decode_sso_session_statement, proof_request_message, + resource_allocation_message, sign_payload_message, sign_raw_legacy_message, sign_raw_message, + v1, }; use crate::host_logic::statement_store::parse_new_statements_result; @@ -334,20 +335,27 @@ impl PairingHost { ) -> Result { let action = AuthorityRequestKind::from(&request); let message_id = sso_message_id(); - let request = match request { - CreateTransactionAuthorityRequest::Product(request) => request, + let message = match request { + CreateTransactionAuthorityRequest::Product(request) => { + create_transaction_message(message_id, request) + } CreateTransactionAuthorityRequest::LegacyAccount { product_account, request, - } => latest::ProductAccountTxPayload { - signer: product_account, - genesis_hash: request.genesis_hash, - call_data: request.call_data, - extensions: request.extensions, - tx_ext_version: request.tx_ext_version, - }, + } => create_transaction_message( + message_id, + latest::ProductAccountTxPayload { + signer: product_account, + genesis_hash: request.genesis_hash, + call_data: request.call_data, + extensions: request.extensions, + tx_ext_version: request.tx_ext_version, + }, + ), + CreateTransactionAuthorityRequest::IdentityAccount(request) => { + create_transaction_legacy_message(message_id, request) + } }; - let message = create_transaction_message(message_id, request); let response = self .submit_remote_message(cx, session, RemoteAction::Signing(action), message) .await diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 95077f01..01b2d923 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -18,6 +18,9 @@ mod sso_responder; use std::sync::{Arc, Mutex}; +use parity_scale_codec::Encode; +use subxt::utils::{AccountId32, MultiSignature}; + pub(crate) use local_activation::LocalActivation; pub use sso_responder::ResponderExit; pub(crate) use sso_responder::respond_to_pairing; @@ -30,14 +33,16 @@ use super::authority::{ }; use super::{RuntimeServices, connected_session_ui_info}; use crate::host_logic::entropy::derive_product_entropy; -use crate::host_logic::extrinsic::{Sr25519Signer, build_signed_extrinsic_v4}; +use crate::host_logic::extrinsic::{ + Sr25519Signer, build_signed_extrinsic_v4, build_signed_extrinsic_v4_with_signature, +}; use crate::host_logic::product_account::{ ProductAccountError, SR25519_SIGNING_CONTEXT, derive_product_keypair, derive_root_keypair_from_entropy, derive_sr25519_hard_path, }; use crate::host_logic::session::SessionState; use crate::host_logic::sso::messages::{OnExistingAllowancePolicy, RingVrfError}; -use crate::host_logic::transaction::extrinsic_payload_preimage; +use crate::host_logic::transaction::{extrinsic_payload_extensions, extrinsic_payload_preimage}; use crate::runtime::auth_state::AuthStateMachine; use ring_vrf::{ ChainRingResolver, MemberCandidate, PersonKey, RingResolver, alias_from_entropy, context_bytes, @@ -47,8 +52,8 @@ use ring_vrf::{ use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, v01}; use truapi_platform::{ - AccountAliasReview, CreateProofReview, Platform, ProductContext, UserConfirmationReview, - normalize_product_identifier, + CreateProofReview, PermissionAuthorizationStatus, Platform, ProductContext, + UserConfirmationReview, normalize_product_identifier, }; use zeroize::Zeroizing; @@ -136,6 +141,11 @@ impl SigningHost { .map_err(product_authority_error) } + fn identity_keypair(&self) -> Result { + let entropy = self.root_entropy()?; + derive_sr25519_hard_path(&entropy, &["wallet", "sso"]).map_err(product_authority_error) + } + fn person_entropy( &self, session: &AuthoritySession, @@ -251,9 +261,7 @@ impl ProductAuthority for SigningHost { (self.product_keypair(&request.account)?, request.payload) } SignRawAuthorityRequest::LegacyAccount { account, request } => { - let entropy = self.root_entropy()?; - let keypair = derive_sr25519_hard_path(&entropy, &["wallet", "sso"]) - .map_err(product_authority_error)?; + let keypair = self.identity_keypair()?; if keypair.public.to_bytes() != account { return Err(AuthorityError::Unavailable { reason: "signing host: the requested legacy account is not available in \ @@ -317,6 +325,22 @@ impl ProductAuthority for SigningHost { request.tx_ext_version, ) } + CreateTransactionAuthorityRequest::IdentityAccount(request) => { + let keypair = self.identity_keypair()?; + if keypair.public.to_bytes() != request.signer { + return Err(AuthorityError::Unavailable { + reason: "signing host: the requested identity account is not available in \ + this CLI wallet" + .to_string(), + }); + } + build_local_transaction( + &keypair, + &request.call_data, + &request.extensions, + request.tx_ext_version, + ) + } } } @@ -327,16 +351,20 @@ impl ProductAuthority for SigningHost { request: AccountAliasAuthorityRequest, ) -> Result { require_current_session(&self.session_state, session)?; - self.confirm_ring_vrf_if_cross_product( + match super::account_access_authorization( + &self.services, &request.calling_product_id, &request.context.product_id, - UserConfirmationReview::AccountAlias(AccountAliasReview { - calling_product_id: request.calling_product_id.clone(), - context: request.context.clone(), - ring_location: request.ring_location.clone(), - }), ) - .await?; + .await + { + Ok(PermissionAuthorizationStatus::Authorized) => {} + Ok( + PermissionAuthorizationStatus::Denied + | PermissionAuthorizationStatus::NotDetermined, + ) => return Err(RingVrfError::Rejected), + Err(reason) => return Err(RingVrfError::Unknown { reason }), + } let collection = self.ring_resolver.validate(&request.ring_location).await?; let context = context_bytes(&request.context); let entropy = self.person_entropy(session, key_for_collection(&collection))?; @@ -402,6 +430,7 @@ impl ProductAuthority for SigningHost { &self.services, self, &product_id, + OnExistingAllowancePolicy::Increase, ) .await .map(|_| v01::AllocationOutcome::Allocated) @@ -411,7 +440,7 @@ impl ProductAuthority for SigningHost { &self.services, self, &product_id, - OnExistingAllowancePolicy::Ignore, + OnExistingAllowancePolicy::Increase, ) .await .map(|_| v01::AllocationOutcome::Allocated) @@ -423,7 +452,7 @@ impl ProductAuthority for SigningHost { Ok(outcome) => outcomes.push(outcome), Err(reason) => { tracing::warn!(%product_id, %reason, "direct resource allocation item failed"); - outcomes.push(v01::AllocationOutcome::Rejected); + outcomes.push(v01::AllocationOutcome::NotAvailable); } } } @@ -437,10 +466,14 @@ impl ProductAuthority for SigningHost { product_id: String, ) -> Result { require_current_session(&self.session_state, session)?; - let secret = - sso_responder::allocate_statement_store_allowance(&self.services, self, &product_id) - .await - .map_err(allocation_authority_error)?; + let secret = sso_responder::allocate_statement_store_allowance( + &self.services, + self, + &product_id, + OnExistingAllowancePolicy::Ignore, + ) + .await + .map_err(allocation_authority_error)?; StatementStoreAllowanceKey::from_secret_bytes(secret) } @@ -525,13 +558,24 @@ fn sign_extrinsic_payload( } let preimage = extrinsic_payload_preimage(&payload) .map_err(|reason| AuthorityError::Unknown { reason })?; - let signature = keypair + let raw_signature = keypair .secret .sign_simple(SR25519_SIGNING_CONTEXT, &preimage, &keypair.public) .to_bytes(); + let signature = MultiSignature::Sr25519(raw_signature); + let signed_transaction = payload.with_signed_transaction.unwrap_or(false).then(|| { + let extensions = extrinsic_payload_extensions(&payload) + .expect("preimage construction already validated signed extensions"); + build_signed_extrinsic_v4_with_signature( + AccountId32(keypair.public.to_bytes()), + &signature, + &payload.method, + &extensions, + ) + }); Ok(v01::HostSignPayloadResponse { - signature: signature.to_vec(), - signed_transaction: None, + signature: signature.encode(), + signed_transaction, }) } @@ -624,7 +668,9 @@ mod tests { use crate::host_logic::product_account::{ derive_product_keypair, derive_root_keypair_from_entropy, derive_sr25519_hard_path, }; - use crate::host_logic::transaction::extrinsic_payload_preimage; + use crate::host_logic::transaction::{ + extrinsic_payload_extensions, extrinsic_payload_preimage, + }; use crate::test_support::{StubPlatform, test_spawner}; use truapi::api::{Account, Entropy, Signing}; use truapi::versioned::account::{HostAccountGetError, HostAccountGetRequest}; @@ -782,10 +828,10 @@ mod tests { } #[test] - fn cross_product_ring_requests_require_signing_host_confirmation() { - let platform: Arc = Arc::new(StubPlatform::default()); + fn cross_product_ring_requests_use_their_respective_authorization_paths() { + let platform = Arc::new(StubPlatform::default()); let authority = - SigningHostRole::new_with_ring_resolver(platform, full_person_ring_resolver()); + SigningHostRole::new_with_ring_resolver(platform.clone(), full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); let session = authority.current_session().expect("active session"); @@ -821,6 +867,55 @@ mod tests { }, )); assert_eq!(proof, Err(RingVrfError::Rejected)); + assert_eq!( + platform + .account_access_reviews + .lock() + .expect("account access review list mutex poisoned") + .len(), + 1 + ); + } + + #[test] + fn cross_product_alias_reuses_persisted_account_access_grant() { + let platform = Arc::new(StubPlatform { + account_access_confirmed: true, + ..StubPlatform::default() + }); + let authority = + SigningHostRole::new_with_ring_resolver(platform.clone(), full_person_ring_resolver()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = authority.current_session().expect("active session"); + let cx = CallContext::new(); + let request = AccountAliasAuthorityRequest { + calling_product_id: "myapp.dot".to_string(), + context: v01::ProductProofContext { + product_id: "other.dot".to_string(), + suffix: b"account".to_vec(), + }, + ring_location: v01::RingLocation { + chain_id: [0x22; 32], + junctions: vec![v01::RingLocationJunction::CollectionId( + b"pop:polkadot.network/people ".to_vec(), + )], + }, + }; + + futures::executor::block_on(authority.account_alias(&cx, &session, request.clone())) + .expect("first alias succeeds"); + futures::executor::block_on(authority.account_alias(&cx, &session, request)) + .expect("second alias succeeds from cached grant"); + + assert_eq!( + platform + .account_access_reviews + .lock() + .expect("account access review list mutex poisoned") + .len(), + 1 + ); } #[test] @@ -864,7 +959,16 @@ mod tests { .expect("activation succeeds"); let session = authority.current_session().expect("active session"); let cx = CallContext::new(); - let payload = crate::test_support::sign_payload_data(); + let mut payload = crate::test_support::sign_payload_data(); + payload.signed_extensions = vec![ + "CheckSpecVersion".to_string(), + "CheckTxVersion".to_string(), + "CheckGenesis".to_string(), + "CheckMortality".to_string(), + "CheckNonce".to_string(), + "ChargeTransactionPayment".to_string(), + ]; + payload.with_signed_transaction = Some(true); let preimage = extrinsic_payload_preimage(&payload).expect("preimage builds"); let product_response = futures::executor::block_on(authority.sign_payload( @@ -879,13 +983,33 @@ mod tests { let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); - let signature = schnorrkel::Signature::from_bytes(&product_response.signature).unwrap(); + assert_eq!(product_response.signature.len(), 65); + assert_eq!(product_response.signature[0], 1); + let signature = + schnorrkel::Signature::from_bytes(&product_response.signature[1..]).unwrap(); assert!( keypair .public .verify_simple(SR25519_SIGNING_CONTEXT, &preimage, &signature) .is_ok() ); + let signed_transaction = product_response + .signed_transaction + .as_ref() + .expect("requested signed transaction"); + let (account, embedded_signature, tail) = split_v4(signed_transaction); + assert_eq!(account, keypair.public.to_bytes()); + assert_eq!( + embedded_signature.as_slice(), + &product_response.signature[1..] + ); + let extensions = extrinsic_payload_extensions(&payload).unwrap(); + let expected_tail = extensions + .iter() + .flat_map(|extension| extension.extra.iter().copied()) + .chain(payload.method.iter().copied()) + .collect::>(); + assert_eq!(tail, expected_tail); let legacy_response = futures::executor::block_on(authority.sign_payload( &cx, @@ -899,13 +1023,15 @@ mod tests { }, )) .expect("legacy payload signing succeeds"); - let signature = schnorrkel::Signature::from_bytes(&legacy_response.signature).unwrap(); + assert_eq!(legacy_response.signature[0], 1); + let signature = schnorrkel::Signature::from_bytes(&legacy_response.signature[1..]).unwrap(); assert!( keypair .public .verify_simple(SR25519_SIGNING_CONTEXT, &preimage, &signature) .is_ok() ); + assert!(legacy_response.signed_transaction.is_some()); } #[test] diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index 746d7e7b..7b62dfe7 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -504,14 +504,17 @@ fn resource_allocation_payload_result( ); } - ResponseResult { - outcome: "not_available", - reason: Some(if total == 1 { - "Requested resource is not available".to_string() - } else { - format!("None of the {total} requested resources are available") - }), - } + allocation_result_with_failures( + ResponseResult { + outcome: "not_available", + reason: Some(if total == 1 { + "Requested resource is not available".to_string() + } else { + format!("None of the {total} requested resources are available") + }), + }, + item_failures, + ) } fn allocation_result_with_failures( @@ -613,12 +616,18 @@ async fn answer_remote_message( signed_transaction, }) } - v1::RemoteMessage::CreateTransactionLegacyRequest(_) => { + v1::RemoteMessage::CreateTransactionLegacyRequest(request) => { + let messages::CreateTransactionLegacyPayload::V1(payload) = request.payload; + let signed_transaction = create_transaction_response( + services, + signing_host, + CreateTransactionReview::LegacyAccount(payload.clone()), + CreateTransactionAuthorityRequest::IdentityAccount(payload), + ) + .await; v1::RemoteMessage::CreateTransactionResponse(messages::CreateTransactionResponse { responding_to: message_id, - signed_transaction: Err( - "signing host: legacy-account transactions are not supported".to_string(), - ), + signed_transaction, }) } v1::RemoteMessage::SignRawLegacyRequest(request) => { @@ -661,22 +670,31 @@ async fn resource_allocation_response( signing_host: &Arc, request: messages::ResourceAllocationRequest, ) -> ResourceAllocationAnswer { - if let Err(reason) = confirm( - services, + let review = UserConfirmationReview::ResourceAllocation(api::HostRequestResourceAllocationRequest { resources: request .resources .iter() .map(public_allocatable_resource) .collect(), - }), - ) - .await - { - return ResourceAllocationAnswer { - payload: Err(reason), - item_failures: Vec::new(), - }; + }); + match services.platform.confirm_user_action(review).await { + Ok(true) => {} + Ok(false) => { + return ResourceAllocationAnswer { + payload: Ok(vec![ + SsoAllocationOutcome::Rejected; + request.resources.len() + ]), + item_failures: Vec::new(), + }; + } + Err(err) => { + return ResourceAllocationAnswer { + payload: Err(format!("confirmation failed: {}", err.reason)), + item_failures: Vec::new(), + }; + } } let mut outcomes = Vec::with_capacity(request.resources.len()); @@ -687,6 +705,7 @@ async fn resource_allocation_response( services, signing_host, &request.calling_product_id, + request.on_existing, ) .await .map(|slot_account_key| { @@ -714,7 +733,7 @@ async fn resource_allocation_response( Err(reason) => { warn!(%reason, "resource allocation item failed"); item_failures.push(reason); - outcomes.push(SsoAllocationOutcome::Rejected); + outcomes.push(SsoAllocationOutcome::NotAvailable); } } } @@ -742,9 +761,11 @@ pub(super) async fn allocate_statement_store_allowance( services: &Arc, signing_host: &SigningHost, product_id: &str, + policy: OnExistingAllowancePolicy, ) -> Result, String> { use crate::runtime::statement_allowance::{ - self, fetch_chain_state, fetch_metadata, find_including_ring, register_statement_account, + self, RegistrationParams, fetch_chain_state, fetch_metadata, find_including_ring, + register_statement_account, }; let entropy = signing_host.root_entropy().map_err(|err| err.reason())?; @@ -774,9 +795,12 @@ pub(super) async fn allocate_statement_store_allowance( &metadata, &chain_state, bandersnatch, - &target, - period, - &ring, + RegistrationParams { + target: &target, + period, + ring: &ring, + reuse_existing: matches!(policy, OnExistingAllowancePolicy::Ignore), + }, ) .await?; match outcome { @@ -900,6 +924,7 @@ pub(super) async fn allocate_statement_store_allowance( _services: &Arc, _signing_host: &SigningHost, _product_id: &str, + _policy: OnExistingAllowancePolicy, ) -> Result, String> { Err("signing host: statement-store allowance allocation is native-only".to_string()) } @@ -1132,6 +1157,7 @@ async fn confirm( mod tests { use super::super::LocalActivation; use super::*; + use crate::host_logic::extrinsic::tests::split_v4; use crate::host_logic::statement_store::{StatementField, unsigned_statement_signing_payload}; use crate::runtime::services::RuntimeServices; use crate::test_support::{StubPlatform, test_spawner}; @@ -1308,14 +1334,12 @@ mod tests { #[test] fn resource_allocation_summary_reflects_per_resource_outcomes() { - let result = resource_allocation_payload_result( - &Ok(vec![SsoAllocationOutcome::Rejected]), - &["timed out waiting for Bulletin authorization".to_string()], - ); + let result = + resource_allocation_payload_result(&Ok(vec![SsoAllocationOutcome::Rejected]), &[]); assert_eq!(result.outcome, "rejected"); assert_eq!( result.reason.as_deref(), - Some("Requested resource was rejected: timed out waiting for Bulletin authorization") + Some("Requested resource was rejected") ); let result = resource_allocation_payload_result( @@ -1334,12 +1358,16 @@ mod tests { Some("1 of 3 requested resources allocated; 1 rejected; 1 unavailable") ); - let result = - resource_allocation_payload_result(&Ok(vec![SsoAllocationOutcome::NotAvailable]), &[]); + let result = resource_allocation_payload_result( + &Ok(vec![SsoAllocationOutcome::NotAvailable]), + &["timed out waiting for Bulletin authorization".to_string()], + ); assert_eq!(result.outcome, "not_available"); assert_eq!( result.reason.as_deref(), - Some("Requested resource is not available") + Some( + "Requested resource is not available: timed out waiting for Bulletin authorization" + ) ); } @@ -1363,6 +1391,59 @@ mod tests { else { panic!("expected resource allocation response"); }; - assert_eq!(response.payload.unwrap_err(), "Rejected"); + assert_eq!( + response.payload.unwrap(), + vec![SsoAllocationOutcome::Rejected] + ); + } + + #[test] + fn legacy_transaction_request_uses_the_controlled_identity_account() { + let (services, signing_host) = signing_fixture(Arc::new(StubPlatform { + create_transaction_confirmed: true, + ..StubPlatform::default() + })); + let identity = derive_sr25519_hard_path(&ENTROPY, &["wallet", "sso"]).unwrap(); + let payload = api::LegacyAccountTxPayload { + signer: identity.public.to_bytes(), + genesis_hash: [0xaa; 32], + call_data: vec![0x00, 0x00], + extensions: vec![api::TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![1], + additional_signed: vec![2, 3], + }], + tx_ext_version: 0, + }; + + let response = futures::executor::block_on(answer_remote_message( + &services, + &signing_host, + "legacy-tx-1".to_string(), + v1::RemoteMessage::CreateTransactionLegacyRequest( + messages::CreateTransactionLegacyRequest { + payload: messages::CreateTransactionLegacyPayload::V1(payload), + }, + ), + )) + .expect("response is emitted"); + + let v1::RemoteMessage::CreateTransactionResponse(response) = response_payload(response) + else { + panic!("expected create transaction response"); + }; + let transaction = response + .signed_transaction + .expect("identity transaction succeeds"); + let (account, signature, tail) = split_v4(&transaction); + assert_eq!(account, identity.public.to_bytes()); + assert_eq!(tail, vec![1, 0x00, 0x00]); + let signature = schnorrkel::Signature::from_bytes(&signature).unwrap(); + assert!( + identity + .public + .verify_simple(b"substrate", &[0x00, 0x00, 1, 2, 3], &signature) + .is_ok() + ); } } diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index 3fae0a5e..da9fedd0 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -120,6 +120,14 @@ pub enum RegistrationOutcome { }, } +/// Target and slot-selection inputs for one statement-store registration. +pub struct RegistrationParams<'a> { + pub target: &'a [u8; 32], + pub period: u32, + pub ring: &'a RingParams, + pub reuse_existing: bool, +} + /// Result of a long-term storage claim attempt. pub enum LongTermStorageOutcome { /// The extrinsic reached a block; the target should receive Bulletin @@ -188,9 +196,7 @@ pub async fn register_statement_account( metadata: &Metadata, chain_state: &ChainState, entropy: [u8; 32], - target: &[u8; 32], - period: u32, - ring: &RingParams, + params: RegistrationParams<'_>, ) -> Result { let mut skipped_duplicate_slots = Vec::new(); loop { @@ -198,9 +204,10 @@ pub async fn register_statement_account( rpc, metadata, entropy, - period, - target, + params.period, + params.target, &skipped_duplicate_slots, + params.reuse_existing, ) .await? { @@ -210,31 +217,37 @@ pub async fn register_statement_account( SlotSelection::Free(seq) => seq, }; - let context = slot::derive_slot_context(period, seq); - let call = - extrinsic::build_set_statement_store_account_call(metadata, period, seq, target)?; + let context = slot::derive_slot_context(params.period, seq); + let call = extrinsic::build_set_statement_store_account_call( + metadata, + params.period, + seq, + params.target, + )?; let message = extension::build_proof_message(metadata, &call, chain_state)?; - let domain = proof::domain_for_ring_exponent(ring.exponent)?; - let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; + let domain = proof::domain_for_ring_exponent(params.ring.exponent)?; + let ring_proof = + proof::ring_vrf_proof(domain, entropy, ¶ms.ring.members, &context, &message)?; let as_resources_extra = - extrinsic::build_as_resources_extra(metadata, &ring_proof, ring.ring_index)?; + extrinsic::build_as_resources_extra(metadata, &ring_proof, params.ring.ring_index)?; let extrinsic = extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; match rpc.submit_and_watch(&extrinsic).await { Ok(block_hash) => { - if slot::read_slot_account_at(rpc, entropy, period, seq, &block_hash).await? - != Some(*target) + if slot::read_slot_account_at(rpc, entropy, params.period, seq, &block_hash).await? + != Some(*params.target) { return Err(format!( - "registration reached block {block_hash} but slot (period {period}, \ - seq {seq}) is not held by the target account" + "registration reached block {block_hash} but slot (period {}, \ + seq {seq}) is not held by the target account", + params.period )); } return Ok(RegistrationOutcome::Registered { block_hash, seq, - ring_index: ring.ring_index, + ring_index: params.ring.ring_index, }); } Err(err) if duplicate_submit_error(&err) => { @@ -549,9 +562,12 @@ mod tests { &metadata, &chain_state, entropy, - &[0x22; 32], - 7, - &ring, + RegistrationParams { + target: &[0x22; 32], + period: 7, + ring: &ring, + reuse_existing: true, + }, )); (outcome, scripted) } diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs index 7ee4b6e5..f05ebb55 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -174,6 +174,7 @@ pub async fn scan_slot_excluding( period: u32, target: &[u8; 32], excluded: &[u32], + reuse_existing: bool, ) -> Result { let max = max_slots(metadata)?; let mut first_free: Option = None; @@ -187,7 +188,7 @@ pub async fn scan_slot_excluding( } } Some(bytes) => { - if entry_account_id(&bytes) == Some(*target) { + if reuse_existing && entry_account_id(&bytes) == Some(*target) { return Ok(SlotSelection::AlreadyAllocated(seq)); } } From 5489b139d02622c011ba1661a4f837916d13d28f Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 23 Jul 2026 15:25:34 +0200 Subject: [PATCH 05/34] fixes --- rust/crates/truapi-server/src/runtime.rs | 53 +---- .../truapi-server/src/runtime/services.rs | 83 +++++++ .../truapi-server/src/runtime/signing_host.rs | 14 ++ .../runtime/signing_host/local_activation.rs | 10 +- .../src/runtime/statement_store.rs | 203 +++++++++++++----- rust/crates/truapi/src/lib.rs | 10 +- 6 files changed, 265 insertions(+), 108 deletions(-) diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 3d13a0e3..0b015b5b 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -67,7 +67,6 @@ use truapi::api::{ Account, Chain, Chat, CoinPayment, Entropy, LocalStorage, Notifications, Payment, Permissions, Preimage, ResourceAllocation, Signing, System, Theme, }; -use truapi::v01; use truapi::versioned::account::{ HostAccountConnectionStatusSubscribeItem, HostAccountCreateProofError, HostAccountCreateProofRequest, HostAccountCreateProofResponse, HostAccountGetAliasError, @@ -142,6 +141,7 @@ use truapi::versioned::system::{ }; use truapi::versioned::theme::HostThemeSubscribeItem; use truapi::{CallContext, CallError, CancellationReason, Subscription}; +use truapi::{latest, v01}; #[cfg(test)] use truapi_platform::Platform; use truapi_platform::{ @@ -986,41 +986,10 @@ impl Account for ProductRuntimeHost { _cx: &CallContext, _request: HostGetLegacyAccountsRequest, ) -> Result> { - let Some(session) = self.authority.current_session() else { - return Ok(HostGetLegacyAccountsResponse::V1( - v01::HostGetLegacyAccountsResponse { accounts: vec![] }, - )); - }; - - let product_id = self.product_id(); - - let public_key = - derive_product_public_key(session.public_key, &product_id, 0).map_err(|err| { - CallError::Domain(HostGetLegacyAccountsError::V1( - v01::HostAccountGetError::Unknown { - reason: err.to_string(), - }, - )) - })?; - - let mut accounts = vec![v01::LegacyAccount { - public_key: public_key.to_vec(), - // TODO(#266): gate this legacy display name on - // IdentityDisclosure while keeping the public key for - // compatibility. - name: session.lite_username.clone(), - }]; - if let Some(identity_account_id) = session.identity_account_id - && identity_account_id != public_key - { - accounts.push(v01::LegacyAccount { - public_key: identity_account_id.to_vec(), - name: Some("Identity".to_string()), - }); - } - + // Match the mobile hosts: compatibility signing accounts may be + // addressed explicitly, but are not enumerated. Ok(HostGetLegacyAccountsResponse::V1( - v01::HostGetLegacyAccountsResponse { accounts }, + latest::HostGetLegacyAccountsResponse { accounts: vec![] }, )) } @@ -2709,7 +2678,7 @@ mod tests { } #[test] - fn get_legacy_accounts_returns_derived_slot_zero_when_connected() { + fn get_legacy_accounts_returns_empty_when_connected() { let host = ProductRuntimeHost::new( stub_platform(), runtime_config("localhost:3000"), @@ -2722,17 +2691,7 @@ mod tests { ) .unwrap(); let HostGetLegacyAccountsResponse::V1(inner) = response; - assert_eq!(inner.accounts.len(), 2); - assert_eq!(inner.accounts[0].name.as_deref(), Some("alice")); - assert_eq!( - hex::encode(&inner.accounts[0].public_key), - "1c822b488297fde8c60d9cbc5585839f70a69fb2c5c69daa66b6043c75184467" - ); - assert_eq!(inner.accounts[1].name.as_deref(), Some("Identity")); - assert_eq!( - inner.accounts[1].public_key, - session_info().identity_account_id.unwrap() - ); + assert!(inner.accounts.is_empty()); } #[test] diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 35c0292b..42dca210 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -13,12 +13,17 @@ use crate::runtime::bulletin_rpc::BulletinRpc; use crate::runtime::statement_store_rpc::StatementStoreRpc; use crate::subscription::Spawner; use async_trait::async_trait; +use truapi::latest; use truapi_platform::{JsonRpcConnection, Platform}; /// Upper bound on the in-core preimage cache. The cache is a bridge until /// content propagates to the lookup backend, not a store, so it stays small. const PREIMAGE_CACHE_MAX_BYTES: usize = 16 * 1024 * 1024; +/// Upper bound on accepted statements retained while the remote Statement +/// Store catches up. +const STATEMENT_CACHE_MAX_ENTRIES: usize = 64; + /// Infrastructure shared by all product runtimes created from one host role. pub(crate) struct RuntimeServices { pub(crate) platform: Arc, @@ -29,6 +34,9 @@ pub(crate) struct RuntimeServices { /// Values from confirmed in-core submissions, served to `lookup_subscribe` /// until the host's content backend has them. Byte-bounded, oldest-first. preimage_cache: Mutex, + /// Confirmed submissions served to new subscriptions until the remote + /// Statement Store reports them. + statement_cache: Mutex, pub(crate) spawner: Spawner, next_core_instance: AtomicU64, } @@ -56,6 +64,7 @@ impl RuntimeServices { statement_store, bulletin, preimage_cache: Mutex::new(PreimageCache::default()), + statement_cache: Mutex::new(StatementCache::default()), spawner, next_core_instance: AtomicU64::new(1), }) @@ -80,6 +89,34 @@ impl RuntimeServices { .expect("preimage cache mutex poisoned") .get(key) } + + /// Retain a remotely accepted statement for read-after-write consistency. + pub(crate) fn cache_statement(&self, statement: latest::SignedStatement) { + self.statement_cache + .lock() + .expect("statement cache mutex poisoned") + .insert(statement); + } + + /// Return accepted statements matching a Statement Store topic filter. + pub(crate) fn cached_statements( + &self, + kind: crate::host_logic::statement_store::TopicFilterKind, + topics: &[[u8; 32]], + ) -> Vec { + self.statement_cache + .lock() + .expect("statement cache mutex poisoned") + .matching(kind, topics) + } + + /// Drop bridge entries once a remote subscription reports them. + pub(crate) fn mark_statements_visible(&self, statements: &[latest::SignedStatement]) { + self.statement_cache + .lock() + .expect("statement cache mutex poisoned") + .remove_all(statements); + } } /// Byte-bounded, insertion-ordered preimage cache. @@ -120,6 +157,52 @@ impl PreimageCache { } } +/// Entry-bounded, insertion-ordered Statement Store propagation bridge. +#[derive(Default)] +struct StatementCache { + entries: VecDeque, +} + +impl StatementCache { + fn insert(&mut self, statement: latest::SignedStatement) { + if let Some(index) = self + .entries + .iter() + .position(|existing| existing == &statement) + { + self.entries.remove(index); + } + self.entries.push_back(statement); + while self.entries.len() > STATEMENT_CACHE_MAX_ENTRIES { + self.entries.pop_front(); + } + } + + fn matching( + &self, + kind: crate::host_logic::statement_store::TopicFilterKind, + topics: &[[u8; 32]], + ) -> Vec { + self.entries + .iter() + .filter(|statement| match kind { + crate::host_logic::statement_store::TopicFilterKind::MatchAll => { + topics.iter().all(|topic| statement.topics.contains(topic)) + } + crate::host_logic::statement_store::TopicFilterKind::MatchAny => { + topics.iter().any(|topic| statement.topics.contains(topic)) + } + }) + .cloned() + .collect() + } + + fn remove_all(&mut self, statements: &[latest::SignedStatement]) { + self.entries + .retain(|cached| !statements.iter().any(|visible| visible == cached)); + } +} + /// Adapter from `truapi_platform::ChainProvider` into the /// [`RuntimeChainProvider`] surface the chain runtime expects. struct HostChainProvider { diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 01b2d923..9ec823f3 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -918,6 +918,20 @@ mod tests { ); } + #[test] + fn local_activation_exposes_the_wallet_sso_identity_account() { + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + + let session = authority.current_session().expect("active session"); + let identity = derive_sr25519_hard_path(&ENTROPY, &["wallet", "sso"]) + .expect("wallet identity derivation") + .public + .to_bytes(); + assert_eq!(session.identity_account_id, Some(identity)); + } + #[test] fn activate_then_sign_raw_verifies_against_derived_product_key() { let (services, activation) = signing_runtime(); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs index c8ba2611..75e549a2 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs @@ -1,5 +1,7 @@ use super::{SigningHost, product_authority_error}; -use crate::host_logic::product_account::derive_root_keypair_from_entropy; +use crate::host_logic::product_account::{ + derive_root_keypair_from_entropy, derive_sr25519_hard_path, +}; use crate::host_logic::session::SessionInfo; use crate::runtime::authority::AuthorityError; use crate::runtime::connected_session_ui_info; @@ -41,6 +43,10 @@ impl LocalActivation for SigningHost { let secret = Zeroizing::new(secret); let root = derive_root_keypair_from_entropy(&secret).map_err(product_authority_error)?; let public_key = root.public.to_bytes(); + let identity_account_id = derive_sr25519_hard_path(&secret, &["wallet", "sso"]) + .map_err(product_authority_error)? + .public + .to_bytes(); *self .root_entropy .lock() @@ -49,7 +55,7 @@ impl LocalActivation for SigningHost { public_key, sso: None, root_entropy_source: None, - identity_account_id: None, + identity_account_id: Some(identity_account_id), lite_username, full_username: None, }; diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index 1f597ff1..63d7515d 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -4,6 +4,8 @@ use core::pin::Pin; use core::task::{Context, Poll}; +use futures::StreamExt as _; + use super::authority::{AuthorityError, StatementStoreAllowanceKey}; use super::statement_store_rpc::{self, StatementStoreRpc}; use super::{ @@ -21,7 +23,7 @@ use serde_json::Value; use subxt_rpcs::client::RpcSubscription; use tracing::instrument; use truapi::api::StatementStore; -use truapi::v01; +use truapi::latest; use truapi::versioned::statement_store::{ RemoteStatementStoreCreateProofAuthorizedError, RemoteStatementStoreCreateProofAuthorizedRequest, @@ -47,7 +49,7 @@ impl StatementStore for ProductRuntimeHost { Ok(value) => value, Err(reason) => { return Err(CallError::Domain(RemoteStatementStoreSubscribeError::V1( - v01::GenericError { reason }, + latest::GenericError { reason }, ))); } }; @@ -56,26 +58,57 @@ impl StatementStore for ProductRuntimeHost { .client("statement-store") .await .map_err(|reason| { - CallError::Domain(RemoteStatementStoreSubscribeError::V1(v01::GenericError { - reason, - })) + CallError::Domain(RemoteStatementStoreSubscribeError::V1( + latest::GenericError { reason }, + )) })?; let subscription = statement_store_rpc::subscribe(&rpc_client, kind, &topics) .await .map_err(|err| { - CallError::Domain(RemoteStatementStoreSubscribeError::V1(v01::GenericError { - reason: format!("statement-store subscribe failed: {err}"), - })) + CallError::Domain(RemoteStatementStoreSubscribeError::V1( + latest::GenericError { + reason: format!("statement-store subscribe failed: {err}"), + }, + )) })?; let Some(remote_subscription_id) = subscription.subscription_id().map(ToString::to_string) else { return Err(CallError::Domain(RemoteStatementStoreSubscribeError::V1( - v01::GenericError { + latest::GenericError { reason: "statement-store subscribe returned no subscription id".to_string(), }, ))); }; - let stream = statement_store_subscription_stream(subscription, remote_subscription_id); + let remote_stream = + statement_store_subscription_stream(subscription, remote_subscription_id); + let cached = self.services.cached_statements(kind, &topics); + let cached_page = (!cached.is_empty()).then(|| { + RemoteStatementStoreSubscribeItem::V1(latest::RemoteStatementStoreSubscribeItem { + statements: cached.clone(), + is_complete: false, + }) + }); + let services = self.services.clone(); + let mut pending_local = cached; + let remote_stream = remote_stream.filter_map(move |item| { + let RemoteStatementStoreSubscribeItem::V1(mut page) = item; + let mut visible_local = Vec::new(); + page.statements.retain(|statement| { + if pending_local.contains(statement) { + visible_local.push(statement.clone()); + false + } else { + true + } + }); + pending_local.retain(|statement| !visible_local.contains(statement)); + services.mark_statements_visible(&visible_local); + futures::future::ready( + (!page.statements.is_empty() || page.is_complete) + .then_some(RemoteStatementStoreSubscribeItem::V1(page)), + ) + }); + let stream = futures::stream::iter(cached_page).chain(remote_stream); Ok(Subscription::new(Box::pin(stream))) } @@ -92,12 +125,12 @@ impl StatementStore for ProductRuntimeHost { inner.product_account_id = Self::normalize_product_account_id(inner.product_account_id) .map_err(|()| { CallError::Domain(RemoteStatementStoreCreateProofError::V1( - v01::RemoteStatementStoreCreateProofError::UnknownAccount, + latest::RemoteStatementStoreCreateProofError::UnknownAccount, )) })?; if !self.is_product_account_valid_for_caller(&inner.product_account_id.dot_ns_identifier) { return Err(CallError::Domain(RemoteStatementStoreCreateProofError::V1( - v01::RemoteStatementStoreCreateProofError::UnknownAccount, + latest::RemoteStatementStoreCreateProofError::UnknownAccount, ))); } let proof = self @@ -105,7 +138,7 @@ impl StatementStore for ProductRuntimeHost { .await .map_err(statement_proof_error)?; Ok(RemoteStatementStoreCreateProofResponse::V1( - v01::RemoteStatementStoreCreateProofResponse { proof }, + latest::RemoteStatementStoreCreateProofResponse { proof }, )) } @@ -124,7 +157,7 @@ impl StatementStore for ProductRuntimeHost { .await .map_err(statement_proof_authorized_error)?; Ok(RemoteStatementStoreCreateProofAuthorizedResponse::V1( - v01::RemoteStatementStoreCreateProofResponse { proof }, + latest::RemoteStatementStoreCreateProofResponse { proof }, )) } @@ -136,25 +169,27 @@ impl StatementStore for ProductRuntimeHost { ) -> Result<(), CallError> { let RemoteStatementStoreSubmitRequest::V1(statement) = request; self.require_remote_permission( - v01::RemotePermission::StatementSubmit, - RemoteStatementStoreSubmitError::V1(v01::GenericError { + latest::RemotePermission::StatementSubmit, + RemoteStatementStoreSubmitError::V1(latest::GenericError { reason: REMOTE_PERMISSION_DENIED_REASON.to_string(), }), ) .await?; - let statement = signed_statement_to_scale(statement).map_err(|reason| { - CallError::Domain(RemoteStatementStoreSubmitError::V1(v01::GenericError { + let encoded = signed_statement_to_scale(statement.clone()).map_err(|reason| { + CallError::Domain(RemoteStatementStoreSubmitError::V1(latest::GenericError { reason, })) })?; self.statement_store_rpc() - .submit(statement, "statement-store") + .submit(encoded, "statement-store") .await .map_err(|reason| { - CallError::Domain(RemoteStatementStoreSubmitError::V1(v01::GenericError { + CallError::Domain(RemoteStatementStoreSubmitError::V1(latest::GenericError { reason: format!("statement-store submit failed: {reason}"), })) - }) + })?; + self.services.cache_statement(statement); + Ok(()) } } @@ -163,7 +198,7 @@ fn statement_store_topic_filter( ) -> Result<(TopicFilterKind, Vec<[u8; 32]>), String> { match request { RemoteStatementStoreSubscribeRequest::V1( - v01::RemoteStatementStoreSubscribeRequest::MatchAll(topics), + latest::RemoteStatementStoreSubscribeRequest::MatchAll(topics), ) => { if topics.len() > MAX_MATCH_ALL_TOPICS { return Err(format!( @@ -175,7 +210,7 @@ fn statement_store_topic_filter( Ok((TopicFilterKind::MatchAll, topics)) } RemoteStatementStoreSubscribeRequest::V1( - v01::RemoteStatementStoreSubscribeRequest::MatchAny(topics), + latest::RemoteStatementStoreSubscribeRequest::MatchAny(topics), ) => { if topics.len() > MAX_MATCH_ANY_TOPICS { let topic_count = topics.len(); @@ -236,7 +271,7 @@ impl futures::Stream for StatementStoreSubscriptionStream { if statements.is_empty() { if is_complete && !was_complete { return Poll::Ready(Some(RemoteStatementStoreSubscribeItem::V1( - v01::RemoteStatementStoreSubscribeItem { + latest::RemoteStatementStoreSubscribeItem { statements, is_complete, }, @@ -246,7 +281,7 @@ impl futures::Stream for StatementStoreSubscriptionStream { } return Poll::Ready(Some(RemoteStatementStoreSubscribeItem::V1( - v01::RemoteStatementStoreSubscribeItem { + latest::RemoteStatementStoreSubscribeItem { statements, is_complete, }, @@ -264,9 +299,9 @@ impl ProductRuntimeHost { async fn create_product_statement_proof( &self, cx: &CallContext, - product_account_id: v01::ProductAccountId, - statement: v01::Statement, - ) -> Result { + product_account_id: latest::ProductAccountId, + statement: latest::Statement, + ) -> Result { let session = self .authority .current_session() @@ -293,14 +328,14 @@ impl ProductRuntimeHost { ) .await .map_err(statement_authority_failure)?; - Ok(v01::StatementProof::Sr25519 { signature, signer }) + Ok(latest::StatementProof::Sr25519 { signature, signer }) } async fn create_authorized_statement_proof( &self, cx: &CallContext, - statement: v01::Statement, - ) -> Result { + statement: latest::Statement, + ) -> Result { let session = self .authority .current_session() @@ -318,9 +353,9 @@ impl ProductRuntimeHost { } fn create_statement_proof_with_key( - statement: v01::Statement, + statement: latest::Statement, key: &StatementStoreAllowanceKey, -) -> Result { +) -> Result { let fields = statement_fields_from_v01(statement).map_err(StatementProofFailure::InvalidStatement)?; let signed = sign_statement_fields(key.secret, key.public_key, fields) @@ -349,16 +384,18 @@ fn statement_authority_failure(err: AuthorityError) -> StatementProofFailure { } } -fn statement_proof_v01_error( +fn statement_proof_domain_error( failure: StatementProofFailure, -) -> v01::RemoteStatementStoreCreateProofError { +) -> latest::RemoteStatementStoreCreateProofError { match failure { - StatementProofFailure::NoSession => v01::RemoteStatementStoreCreateProofError::UnableToSign, + StatementProofFailure::NoSession => { + latest::RemoteStatementStoreCreateProofError::UnableToSign + } StatementProofFailure::UnableToSign(_reason) => { - v01::RemoteStatementStoreCreateProofError::UnableToSign + latest::RemoteStatementStoreCreateProofError::UnableToSign } StatementProofFailure::InvalidStatement(reason) => { - v01::RemoteStatementStoreCreateProofError::Unknown { reason } + latest::RemoteStatementStoreCreateProofError::Unknown { reason } } } } @@ -367,7 +404,7 @@ fn statement_proof_error( failure: StatementProofFailure, ) -> CallError { CallError::Domain(RemoteStatementStoreCreateProofError::V1( - statement_proof_v01_error(failure), + statement_proof_domain_error(failure), )) } @@ -375,7 +412,7 @@ fn statement_proof_authorized_error( failure: StatementProofFailure, ) -> CallError { CallError::Domain(RemoteStatementStoreCreateProofAuthorizedError::V1( - statement_proof_v01_error(failure), + statement_proof_domain_error(failure), )) } @@ -399,7 +436,7 @@ mod tests { const ENTROPY: [u8; 16] = [0xAB; 16]; - fn statement_payload(statement: v01::Statement) -> Vec { + fn statement_payload(statement: latest::Statement) -> Vec { unsigned_statement_signing_payload(statement_fields_from_v01(statement).unwrap()).unwrap() } @@ -438,7 +475,7 @@ mod tests { host.test_session_state().set_session(sso_session_info()); let cx = CallContext::new(); let request = RemoteStatementStoreCreateProofRequest::V1( - v01::RemoteStatementStoreCreateProofRequest { + latest::RemoteStatementStoreCreateProofRequest { product_account_id: account_id("myapp.dot", 0), statement: statement(), }, @@ -450,7 +487,7 @@ mod tests { assert!(matches!( err, CallError::Domain(RemoteStatementStoreCreateProofError::V1( - v01::RemoteStatementStoreCreateProofError::UnableToSign + latest::RemoteStatementStoreCreateProofError::UnableToSign )) )); } @@ -465,7 +502,7 @@ mod tests { let expected_signer = product_keypair.public.to_bytes(); let cx = CallContext::new(); let request = RemoteStatementStoreCreateProofRequest::V1( - v01::RemoteStatementStoreCreateProofRequest { + latest::RemoteStatementStoreCreateProofRequest { product_account_id: account_id("myapp.dot", 0), statement, }, @@ -475,7 +512,7 @@ mod tests { futures::executor::block_on(StatementStore::create_proof(&host, &cx, request)).unwrap(); let RemoteStatementStoreCreateProofResponse::V1(inner) = response; - let v01::StatementProof::Sr25519 { signer, signature } = inner.proof else { + let latest::StatementProof::Sr25519 { signer, signature } = inner.proof else { panic!("expected sr25519 statement proof"); }; assert_eq!(signer, expected_signer); @@ -489,7 +526,7 @@ mod tests { host.test_session_state().set_session(sso_session_info()); let cx = CallContext::new(); let request = RemoteStatementStoreCreateProofRequest::V1( - v01::RemoteStatementStoreCreateProofRequest { + latest::RemoteStatementStoreCreateProofRequest { product_account_id: account_id("other.dot", 0), statement: statement(), }, @@ -501,7 +538,7 @@ mod tests { assert!(matches!( err, CallError::Domain(RemoteStatementStoreCreateProofError::V1( - v01::RemoteStatementStoreCreateProofError::UnknownAccount + latest::RemoteStatementStoreCreateProofError::UnknownAccount )) )); } @@ -550,7 +587,7 @@ mod tests { .unwrap(); let RemoteStatementStoreCreateProofAuthorizedResponse::V1(inner) = response; - let v01::StatementProof::Sr25519 { signer, signature } = inner.proof else { + let latest::StatementProof::Sr25519 { signer, signature } = inner.proof else { panic!("expected sr25519 statement proof"); }; assert_eq!(signer, expected_signer); @@ -603,6 +640,11 @@ mod tests { crate::host_logic::statement_store::decode_signed_statement(&statement).unwrap(), signed_statement([7; 32]) ); + assert_eq!( + host.services + .cached_statements(TopicFilterKind::MatchAll, &[[7; 32]]), + vec![signed_statement([7; 32])] + ); } #[test] @@ -623,7 +665,7 @@ mod tests { futures::executor::block_on(StatementStore::submit(&host, &cx, request)).unwrap_err(); match err { - CallError::Domain(RemoteStatementStoreSubmitError::V1(v01::GenericError { + CallError::Domain(RemoteStatementStoreSubmitError::V1(latest::GenericError { reason, })) => assert_eq!(reason, REMOTE_PERMISSION_DENIED_REASON), other => panic!("expected statement-store permission denial, got {other:?}"), @@ -658,7 +700,7 @@ mod tests { &host, &cx, RemoteStatementStoreSubscribeRequest::V1( - v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + latest::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), ), )) .unwrap(); @@ -677,6 +719,55 @@ mod tests { ); } + #[test] + fn statement_store_subscription_bridges_then_suppresses_remote_echo() { + let cached = signed_statement([7; 32]); + let encoded = + crate::host_logic::statement_store::signed_statement_to_scale(cached.clone()).unwrap(); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + subscribe_ack_frame("truapi:1", "remote-sub-cached"), + new_statements_frame("remote-sub-cached", vec![encoded]), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new(platform, runtime_config("myapp.dot"), test_spawner()); + host.services.cache_statement(cached.clone()); + let cx = CallContext::with_request_id("sub-cached".to_string()); + let mut subscription = futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + latest::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + ), + )) + .unwrap(); + + assert_eq!( + futures::executor::block_on(subscription.next()), + Some(RemoteStatementStoreSubscribeItem::V1( + latest::RemoteStatementStoreSubscribeItem { + statements: vec![cached], + is_complete: false, + } + )) + ); + assert_eq!( + futures::executor::block_on(subscription.next()), + Some(RemoteStatementStoreSubscribeItem::V1( + latest::RemoteStatementStoreSubscribeItem { + statements: vec![], + is_complete: true, + } + )) + ); + assert!( + host.services + .cached_statements(TopicFilterKind::MatchAny, &[[7; 32]]) + .is_empty() + ); + } + /// Pages that arrive before the subscribe ack are buffered by remote /// subscription id and replayed once the ack confirms the subscription. #[test] @@ -703,7 +794,7 @@ mod tests { &host, &cx, RemoteStatementStoreSubscribeRequest::V1( - v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + latest::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), ), )) .unwrap(); @@ -712,7 +803,7 @@ mod tests { assert_eq!( item, - RemoteStatementStoreSubscribeItem::V1(v01::RemoteStatementStoreSubscribeItem { + RemoteStatementStoreSubscribeItem::V1(latest::RemoteStatementStoreSubscribeItem { statements: vec![signed_statement([9; 32])], is_complete: true, }) @@ -742,7 +833,7 @@ mod tests { &host, &cx, RemoteStatementStoreSubscribeRequest::V1( - v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + latest::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), ), )) .unwrap(); @@ -776,7 +867,7 @@ mod tests { &host, &cx, RemoteStatementStoreSubscribeRequest::V1( - v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + latest::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), ), )) .unwrap(); @@ -803,7 +894,7 @@ mod tests { &host, &cx, RemoteStatementStoreSubscribeRequest::V1( - v01::RemoteStatementStoreSubscribeRequest::MatchAny(topics), + latest::RemoteStatementStoreSubscribeRequest::MatchAny(topics), ), )) { Ok(_) => panic!("topic limit violation should fail subscription start"), @@ -841,7 +932,7 @@ mod tests { &host, &cx, RemoteStatementStoreSubscribeRequest::V1( - v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + latest::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), ), )) { Ok(_) => panic!("chain connect failure should fail subscription start"), diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 2708a906..5a4dde04 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -29,9 +29,11 @@ pub mod latest { pub use crate::v01::{ AccountId, AllocatableResource, AllocationOutcome, ContextualAlias, GenericError, HostSignPayloadData, NotificationId, OperationStartedResult, ProductAccountId, - ProductProofContext, RawPayload, RemotePermission, RingLocation, RuntimeApi, RuntimeSpec, - RuntimeType, StorageQueryItem, StorageQueryType, StorageResultItem, ThemeVariant, - TxPayloadExtension, + ProductProofContext, RawPayload, RemotePermission, RemoteStatementStoreCreateProofError, + RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, + RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, RingLocation, + RuntimeApi, RuntimeSpec, RuntimeType, SignedStatement, Statement, StatementProof, + StorageQueryItem, StorageQueryType, StorageResultItem, ThemeVariant, TxPayloadExtension, }; pub type LatestOf = ::Latest; @@ -40,6 +42,8 @@ pub mod latest { LatestOf; pub type HostAccountGetAliasResponse = LatestOf; + pub type HostGetLegacyAccountsResponse = + LatestOf; pub type HostCreateTransactionResponse = LatestOf; pub type HostDevicePermissionRequest = From 132f6677aa2f999c73195590929056c72e632b8d Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 23 Jul 2026 15:57:02 +0200 Subject: [PATCH 06/34] fix(core): align host compatibility --- .../truapi-server/src/host_logic/alias.rs | 81 +++++++++++++++++++ rust/crates/truapi-server/src/runtime.rs | 8 +- .../src/runtime/signing_host/sso_responder.rs | 14 ++-- .../src/runtime/statement_allowance.rs | 4 +- rust/crates/truapi/src/api/account.rs | 5 +- rust/crates/truapi/src/api/chain.rs | 18 ++++- rust/crates/truapi/src/api/preimage.rs | 8 +- .../truapi/src/api/resource_allocation.rs | 13 +-- rust/crates/truapi/src/api/signing.rs | 27 ++++--- 9 files changed, 141 insertions(+), 37 deletions(-) create mode 100644 rust/crates/truapi-server/src/host_logic/alias.rs diff --git a/rust/crates/truapi-server/src/host_logic/alias.rs b/rust/crates/truapi-server/src/host_logic/alias.rs new file mode 100644 index 00000000..3b6874e1 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/alias.rs @@ -0,0 +1,81 @@ +//! Bandersnatch ring-VRF product-account aliases (signing host). +//! +//! Mirrors the mobile app's `ProductAccountHolder.deriveAlias`: the alias is a +//! thin bandersnatch VRF output over a per-product context, using a +//! bandersnatch secret derived from the wallet's BIP-39 entropy. No ring +//! commitment or SRS is involved (that machinery is only for membership +//! proofs, which this path does not use). +//! +//! Reference: polkadot-app-ios-v2 `Packages/Products/.../ProductAccountHolder.swift` +//! and `verifiable-swift` over `paritytech/verifiable`. + +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +/// A product-account contextual alias. +pub struct ProductAlias { + /// 32-byte context identifier (blake2b-256 of the derivation path). + pub context: [u8; 32], + /// 32-byte ring-VRF alias output. + pub alias: [u8; 32], +} + +/// Derive the contextual alias for a product account from the wallet entropy. +/// +/// - `context = blake2b_256("/product/{product_id}/{derivation_index}")` +/// - `bandersnatch_entropy = blake2b_256(root_entropy)` +/// - `alias = BandersnatchVrf::alias_in_context(new_secret(bandersnatch_entropy), context)` +pub fn derive_product_alias( + root_entropy: &[u8], + product_id: &str, + derivation_index: u32, +) -> Result { + let derivation_path = format!("/product/{product_id}/{derivation_index}"); + let context = blake2b256(derivation_path.as_bytes()); + let bandersnatch_entropy = blake2b256(root_entropy); + let secret = BandersnatchVrfVerifiable::new_secret(bandersnatch_entropy); + let alias = BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("ring-VRF alias derivation failed: {err:?}"))?; + Ok(ProductAlias { context, alias }) +} + +fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .hash(message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The alias is deterministic in the entropy, product id, and index, and + /// the context is the blake2b-256 of the derivation path. + #[test] + fn alias_is_deterministic_with_expected_context() { + let entropy = [0xABu8; 16]; + let first = derive_product_alias(&entropy, "truapi-playground.dot", 0).unwrap(); + let again = derive_product_alias(&entropy, "truapi-playground.dot", 0).unwrap(); + + assert_eq!(first.context, again.context); + assert_eq!(first.alias, again.alias); + assert_eq!( + first.context, + blake2b256(b"/product/truapi-playground.dot/0") + ); + } + + #[test] + fn alias_varies_by_product_and_index() { + let entropy = [0xABu8; 16]; + let base = derive_product_alias(&entropy, "a.dot", 0).unwrap(); + let other_product = derive_product_alias(&entropy, "b.dot", 0).unwrap(); + let other_index = derive_product_alias(&entropy, "a.dot", 1).unwrap(); + + assert_ne!(base.alias, other_product.alias); + assert_ne!(base.alias, other_index.alias); + } +} diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 0b015b5b..9359cb50 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -62,7 +62,7 @@ use authority::{ use futures::{FutureExt, StreamExt, pin_mut}; #[cfg(test)] use parity_scale_codec::Encode; -use tracing::{info, instrument, warn}; +use tracing::{debug, instrument, warn}; use truapi::api::{ Account, Chain, Chat, CoinPayment, Entropy, LocalStorage, Notifications, Payment, Permissions, Preimage, ResourceAllocation, Signing, System, Theme, @@ -1127,7 +1127,7 @@ impl Signing for ProductRuntimeHost { cx: &CallContext, request: HostSignPayloadRequest, ) -> Result> { - info!("sign_payload: requesting signing-host signature"); + debug!("sign_payload: requesting signing-host signature"); let HostSignPayloadRequest::V1(mut inner) = request; inner.account = Self::normalize_product_account_id(inner.account).map_err(|()| { CallError::Domain(HostSignPayloadError::V1( @@ -1180,7 +1180,7 @@ impl Signing for ProductRuntimeHost { cx: &CallContext, request: HostSignRawRequest, ) -> Result> { - info!("sign_raw: requesting signing-host signature"); + debug!("sign_raw: requesting signing-host signature"); let HostSignRawRequest::V1(mut inner) = request; inner.account = Self::normalize_product_account_id(inner.account).map_err(|()| { CallError::Domain(HostSignRawError::V1( @@ -1233,7 +1233,7 @@ impl Signing for ProductRuntimeHost { cx: &CallContext, request: HostCreateTransactionRequest, ) -> Result> { - info!("create_transaction: requesting signing-host signature"); + debug!("create_transaction: requesting signing-host signature"); let HostCreateTransactionRequest::V1(mut inner) = request; inner.signer = Self::normalize_product_account_id(inner.signer).map_err(|()| { CallError::Domain(HostCreateTransactionError::V1( diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index 7b62dfe7..92f0764a 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use std::time::Instant; use parity_scale_codec::Encode; -use tracing::{debug, info, instrument, trace, warn}; +use tracing::{debug, instrument, trace, warn}; use truapi::{CallContext, latest as api}; use truapi_platform::{ CreateTransactionReview, SignPayloadReview, SignRawReview, UserConfirmationReview, @@ -131,7 +131,7 @@ pub(crate) async fn respond_to_pairing( .statement_store .submit(statement, "sso-responder handshake") .await?; - info!("answered pairing handshake, serving SSO session"); + debug!("answered pairing handshake, serving SSO session"); serve_session(services, signing_host, session).await } @@ -251,7 +251,7 @@ async fn serve_request( for message in incoming.messages { let RemoteMessageData::V1(request) = message.data; if matches!(request, v1::RemoteMessage::Disconnected) { - info!("pairing host disconnected the SSO session"); + debug!("pairing host disconnected the SSO session"); return Ok(Some(ResponderExit::PeerDisconnected)); } let request_name = remote_v1_message_name(&request); @@ -809,7 +809,7 @@ pub(super) async fn allocate_statement_store_allowance( seq, ring_index, } => { - info!( + debug!( %product_id, %block_hash, seq, @@ -818,7 +818,7 @@ pub(super) async fn allocate_statement_store_allowance( ); } statement_allowance::RegistrationOutcome::AlreadyAllocated { seq } => { - info!( + debug!( %product_id, seq, "statement-store allowance already allocated" @@ -895,7 +895,7 @@ pub(super) async fn allocate_bulletin_allowance( counter, ring_index, } = outcome; - info!( + debug!( %product_id, %block_hash, counter, @@ -910,7 +910,7 @@ pub(super) async fn allocate_bulletin_allowance( BULLETIN_AUTHORIZATION_WAIT, ) .await?; - info!( + debug!( %product_id, remained_size = authorization.remained_size, remained_transactions = authorization.remained_transactions, diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index da9fedd0..0c5852e4 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -20,7 +20,7 @@ use futures::FutureExt; use parity_scale_codec::Decode; use serde_json::{Value, json}; use sp_crypto_hashing::twox_128; -use tracing::{info, warn}; +use tracing::{debug, warn}; use extension::{ChainState, Metadata}; use ring::RingParams; @@ -296,7 +296,7 @@ pub async fn claim_long_term_storage( )?; let extrinsic = extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; - info!( + debug!( period, counter, ring_index = ring.ring_index, diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index aa3f3799..a3074048 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -125,10 +125,13 @@ pub trait Account: Send + Sync { /// List non-product accounts the user owns. /// + /// Current hosts do not expose non-product accounts, so the list is empty. + /// /// ```ts /// const result = await truapi.account.getLegacyAccounts(); /// assert(result.isOk(), "getLegacyAccounts failed:", result); - /// console.log("legacy accounts:", result.value); + /// assert(result.value.accounts.length === 0, "unexpected legacy accounts:", result.value); + /// console.log("legacy accounts:", result.value.accounts); /// ``` #[wire(request_id = 28)] async fn get_legacy_accounts( diff --git a/rust/crates/truapi/src/api/chain.rs b/rust/crates/truapi/src/api/chain.rs index 158dd12f..1f47c85b 100644 --- a/rust/crates/truapi/src/api/chain.rs +++ b/rust/crates/truapi/src/api/chain.rs @@ -362,12 +362,22 @@ pub trait Chain: Send + Sync { /// ```ts /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; /// - /// const result = await truapi.chain.stopTransaction({ + /// // Start a broadcast, then stop it using the returned operation id. + /// const broadcast = await truapi.chain.broadcastTransaction({ /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, - /// operationId: "op-id", + /// transaction: "0x", /// }); - /// assert(result.isOk(), "stopTransaction failed:", result); - /// console.log("transaction broadcast stopped"); + /// assert(broadcast.isOk(), "broadcastTransaction failed:", broadcast); + /// if (broadcast.value.operationId) { + /// const result = await truapi.chain.stopTransaction({ + /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// operationId: broadcast.value.operationId, + /// }); + /// assert(result.isOk(), "stopTransaction failed:", result); + /// console.log("transaction broadcast stopped"); + /// } else { + /// console.log("broadcast completed before a stop operation was created"); + /// } /// ``` #[wire(request_id = 102)] async fn stop_transaction( diff --git a/rust/crates/truapi/src/api/preimage.rs b/rust/crates/truapi/src/api/preimage.rs index 13075f6b..ad8769c9 100644 --- a/rust/crates/truapi/src/api/preimage.rs +++ b/rust/crates/truapi/src/api/preimage.rs @@ -15,13 +15,14 @@ pub trait Preimage: Send + Sync { /// import { firstValueFrom, from } from "rxjs"; /// /// // Submit a preimage first so the lookup resolves to a value. - /// const submitted = await truapi.preimage.submit("0xdeadbeef"); + /// const value = `0x${crypto.getRandomValues(new Uint8Array(4)).toHex()}` as `0x${string}`; + /// const submitted = await truapi.preimage.submit(value); /// assert(submitted.isOk(), "submit failed:", submitted); /// /// const item = await firstValueFrom( /// from(truapi.preimage.lookupSubscribe({ request: { key: submitted.value } })), /// ); - /// assert(item.value === "0xdeadbeef", "preimage lookup returned the wrong value:", item); + /// assert(item.value === value, "preimage lookup returned the wrong value:", item); /// console.log("preimage lookup received:", item); /// ``` #[wire(start_id = 64)] @@ -36,7 +37,8 @@ pub trait Preimage: Send + Sync { /// Submit a preimage. Returns the preimage key (hash) on success. /// /// ```ts - /// const result = await truapi.preimage.submit("0xdeadbeef"); + /// const value = `0x${crypto.getRandomValues(new Uint8Array(4)).toHex()}` as `0x${string}`; + /// const result = await truapi.preimage.submit(value); /// assert(result.isOk(), "submit failed:", result); /// console.log("preimage submitted:", result.value); /// ``` diff --git a/rust/crates/truapi/src/api/resource_allocation.rs b/rust/crates/truapi/src/api/resource_allocation.rs index 813ecef4..9d50a335 100644 --- a/rust/crates/truapi/src/api/resource_allocation.rs +++ b/rust/crates/truapi/src/api/resource_allocation.rs @@ -22,17 +22,20 @@ pub trait ResourceAllocation: Send + Sync { /// }); /// assert(result.isOk(), "request failed:", result); /// assert(result.value.outcomes.length === 4, "missing allocation outcomes:", result.value); + /// // Statement Store and Bulletin back this example's storage APIs. /// assert( - /// result.value.outcomes.slice(0, 3).every((outcome) => outcome === "Allocated"), - /// "one or more on-chain allowances are unavailable:", + /// result.value.outcomes.slice(0, 2).every((outcome) => outcome === "Allocated"), + /// "statement-store or bulletin allowance was not allocated:", /// result.value, /// ); + /// // Smart-contract allowance and auto-signing are host capabilities: + /// // unsupported hosts report NotAvailable rather than rejecting the request. /// assert( - /// result.value.outcomes[3] === "NotAvailable", - /// "AutoSigning support changed; update this example:", + /// result.value.outcomes.slice(2).every((outcome) => outcome !== "Rejected"), + /// "an optional allocation was rejected:", /// result.value, /// ); - /// console.log("statement-store, bulletin, and smart-contract allowances allocated"); + /// console.log("resource allocation outcomes:", result.value.outcomes); /// ``` #[wire(request_id = 130)] async fn request( diff --git a/rust/crates/truapi/src/api/signing.rs b/rust/crates/truapi/src/api/signing.rs index f688f5ed..c2b44148 100644 --- a/rust/crates/truapi/src/api/signing.rs +++ b/rust/crates/truapi/src/api/signing.rs @@ -50,11 +50,13 @@ pub trait Signing: Send + Sync { /// ```ts /// import { PASEO_NEXT_V2_INDIVIDUALITY } 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"); - /// console.log("selected legacy account:", legacyAccount); + /// const accountResult = await truapi.account.getAccount({ + /// productAccountId: { + /// dotNsIdentifier: "truapi-playground.dot", + /// derivationIndex: 0, + /// }, + /// }); + /// assert(accountResult.isOk(), "getAccount failed:", accountResult); /// /// const payload = await buildCreateTransactionPayload({ /// signer: { @@ -68,7 +70,7 @@ pub trait Signing: Send + Sync { /// /// const result = await truapi.signing.createTransactionWithLegacyAccount({ /// ...payload.value, - /// signer: legacyAccount.publicKey, + /// signer: accountResult.value.account.publicKey, /// }); /// assert(result.isOk(), "createTransactionWithLegacyAccount failed:", result); /// console.log("transaction created:", result.value); @@ -116,13 +118,16 @@ 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 accountResult = await truapi.account.getAccount({ + /// productAccountId: { + /// dotNsIdentifier: "truapi-playground.dot", + /// derivationIndex: 0, + /// }, + /// }); + /// assert(accountResult.isOk(), "getAccount failed:", accountResult); /// /// const result = await truapi.signing.signPayloadWithLegacyAccount({ - /// signer: legacyAccount.publicKey, + /// signer: accountResult.value.account.publicKey, /// payload: { /// blockHash: "0xd6eec26135305a8ad257a20d003357284c8aa03d0bdb2b357ab0a22371e11ef2", /// blockNumber: "0x00000000", From 5dd8ef1aeb9bac0478310da89a18f3b37410f73f Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 23 Jul 2026 16:46:21 +0200 Subject: [PATCH 07/34] ci: refresh generated fixture and licenses --- CLAUDE.md | 12 ++++++++---- deny.toml | 2 ++ .../truapi-codegen/tests/golden/host-callbacks.ts | 9 ++++++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6a494b06..b18cab4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,10 +31,14 @@ scripts/codegen.sh regenerate the TS client from the Rust crate syscall traits and host-side runtime types live in `truapi-platform` and `truapi-server`, not in `truapi`. Any additions to `truapi` itself are limited to additive `Display` impls. -- Outside the canonical `truapi` crate and its version-conversion impls, use - structs from `truapi::latest` for concrete protocol payload/error types. - Runtime crates should take envelopes from `truapi::versioned::*` and unwrap - them into latest payloads instead of spelling `truapi::v01::*` directly. +- Treat concrete modules such as `truapi::v01` as implementation details of + the canonical `truapi` crate and its version-conversion impls. Everywhere + else, import concrete protocol payload and error types from `truapi::latest`. + This includes structs reused by host-internal APIs that are not exposed to + products; if such a type is missing, re-export it through `truapi::latest` + rather than importing a concrete protocol version. Runtime crates may use + `truapi::versioned::*` for wire envelopes, but should unwrap them into latest + payloads immediately. - `truapi-server` WASM artifacts live under `js/packages/truapi-host/dist/wasm/web/` and are gitignored. Build them locally with `make wasm` (rerun whenever diff --git a/deny.toml b/deny.toml index 1bdbccea..7acc3441 100644 --- a/deny.toml +++ b/deny.toml @@ -9,6 +9,8 @@ allow = [ "BSD-2-Clause", "BSD-3-Clause", "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", "Unicode-3.0", "Unlicense", "Zlib", diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 26c75aa6..6c30b178 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -195,7 +195,11 @@ export type PermissionAuthorizationRequest = /** * Product-scoped permission to disclose the user's primary identity. */ - | { tag: "IdentityDisclosure"; value?: undefined }; + | { tag: "IdentityDisclosure"; value?: undefined } + /** + * Product-scoped permission to access another product's account context. + */ + | { tag: "AccountAccess"; value: { targetProductId: string } }; /** * Authorization status for a permission request. @@ -420,6 +424,9 @@ export const PermissionAuthorizationRequest: S.Codec, }), ); From 6868f0b4e7605a1807e03eeec41fd439d8f88cfc Mon Sep 17 00:00:00 2001 From: tarikgul Date: Thu, 23 Jul 2026 21:23:48 -0400 Subject: [PATCH 08/34] fix(sso-responder): bound the replay-dedup set to cap memory --- .../src/runtime/signing_host/sso_responder.rs | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index e4d09759..25c239f6 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -11,7 +11,7 @@ //! same seam browser hosts use for their confirmation modals; a headless host //! implements it with its approval policy. -use std::collections::HashSet; +use std::collections::{HashSet, VecDeque}; use std::sync::Arc; use std::time::Instant; @@ -64,6 +64,44 @@ const CHAT_KEY_LABEL: &[u8] = b"chat-encryption"; #[cfg(not(target_arch = "wasm32"))] const BULLETIN_AUTHORIZATION_WAIT: std::time::Duration = std::time::Duration::from_secs(240); +/// Upper bound on remembered request ids for replay dedup within a serve loop. +/// A peer that holds the session open cannot grow this without bound; requests +/// older than the eviction window are past their statement expiry and can no +/// longer be validly replayed. +const MAX_SERVED_REQUEST_IDS: usize = 1024; + +/// Bounded set of served request ids for replay dedup. Evicts the oldest id +/// once the capacity is reached so a peer cannot force unbounded memory growth +/// by streaming fresh request ids. +struct ServedRequestIds { + seen: HashSet, + order: VecDeque, +} + +impl ServedRequestIds { + fn new() -> Self { + Self { + seen: HashSet::new(), + order: VecDeque::new(), + } + } + + /// Record `request_id`, returning `true` if it was not already served. + /// Evicts the oldest id when the capacity is exceeded. + fn insert(&mut self, request_id: String) -> bool { + if !self.seen.insert(request_id.clone()) { + return false; + } + self.order.push_back(request_id); + if self.order.len() > MAX_SERVED_REQUEST_IDS { + if let Some(evicted) = self.order.pop_front() { + self.seen.remove(&evicted); + } + } + true + } +} + /// Terminal outcome of one responder serve loop. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ResponderExit { @@ -151,7 +189,7 @@ async fn serve_session( statement_store_rpc::subscribe_match_all(&rpc_client, &[session.session_id_peer]) .await .map_err(|err| format!("sso-responder subscribe failed: {err}"))?; - let mut served_request_ids = HashSet::new(); + let mut served_request_ids = ServedRequestIds::new(); while let Some(item) = subscription.next().await { let value = item.map_err(|err| format!("sso-responder subscription failed: {err}"))?; @@ -1446,4 +1484,27 @@ mod tests { .is_ok() ); } + + #[test] + fn served_request_ids_dedup_and_bound() { + let mut served = ServedRequestIds::new(); + + // First sighting is served; an immediate duplicate is rejected. + assert!(served.insert("req-a".to_string())); + assert!(!served.insert("req-a".to_string())); + + // Fill to capacity with distinct ids; the set never exceeds the bound. + for i in 0..MAX_SERVED_REQUEST_IDS { + served.insert(format!("fill-{i}")); + } + assert_eq!(served.seen.len(), MAX_SERVED_REQUEST_IDS); + assert_eq!(served.order.len(), MAX_SERVED_REQUEST_IDS); + + // The oldest id ("req-a") has been evicted, so it is accepted again, + // while a recent id is still deduped — memory stays bounded regardless + // of how many ids a peer streams. + assert!(served.insert("req-a".to_string())); + assert!(!served.insert(format!("fill-{}", MAX_SERVED_REQUEST_IDS - 1))); + assert_eq!(served.seen.len(), MAX_SERVED_REQUEST_IDS); + } } From f7999e3350798a6dfe57a3303ac79fa73649b2dd Mon Sep 17 00:00:00 2001 From: tarikgul Date: Thu, 23 Jul 2026 21:46:44 -0400 Subject: [PATCH 09/34] chore(codegen): refresh stale host-callbacks golden --- .../crates/truapi-codegen/tests/golden/host-callbacks.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 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 26c75aa6..6c30b178 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -195,7 +195,11 @@ export type PermissionAuthorizationRequest = /** * Product-scoped permission to disclose the user's primary identity. */ - | { tag: "IdentityDisclosure"; value?: undefined }; + | { tag: "IdentityDisclosure"; value?: undefined } + /** + * Product-scoped permission to access another product's account context. + */ + | { tag: "AccountAccess"; value: { targetProductId: string } }; /** * Authorization status for a permission request. @@ -420,6 +424,9 @@ export const PermissionAuthorizationRequest: S.Codec, }), ); From eaaa22b40d94f070254bfa52e449797273cd2c23 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Thu, 23 Jul 2026 21:47:16 -0400 Subject: [PATCH 10/34] fix(signing-host): name the beneficiary product in the allocation review --- rust/crates/truapi-platform/src/lib.rs | 17 ++++++++-- rust/crates/truapi-server/src/runtime.rs | 11 +++++-- .../src/runtime/signing_host/sso_responder.rs | 31 +++++++++++++------ rust/crates/truapi-server/src/test_support.rs | 20 ++++++++---- 4 files changed, 57 insertions(+), 22 deletions(-) diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 920f99e4..5d0714d7 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -16,10 +16,10 @@ use unicode_normalization::UnicodeNormalization; pub use async_trait::async_trait; use truapi::latest::{ - GenericError, HostDevicePermissionRequest, HostDevicePermissionResponse, + AllocatableResource, GenericError, HostDevicePermissionRequest, HostDevicePermissionResponse, HostFeatureSupportedRequest, HostFeatureSupportedResponse, HostLocalStorageReadError, HostNavigateToError, HostPushNotificationRequest, HostPushNotificationResponse, - HostRequestResourceAllocationRequest, HostSignPayloadRequest, + HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, NotificationId, ProductAccountTxPayload, ProductProofContext, RemotePermissionRequest, @@ -581,6 +581,17 @@ pub struct CreateProofReview { pub message: Vec, } +/// Review shown before allocating resources for a product. Names the +/// beneficiary product so the user knows which product receives the +/// (signing-capable) allowance key they are approving. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ResourceAllocationReview { + /// Product the allocation is requested for. + pub calling_product_id: String, + /// Resources to allocate. + pub resources: Vec, +} + /// Review shown before a product asks to access another product account. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct AccountAccessReview { @@ -621,7 +632,7 @@ pub enum UserConfirmationReview { /// Allow a product to learn the user's primary identity. IdentityDisclosure(IdentityDisclosureReview), /// Allocate resources for the requesting product. - ResourceAllocation(HostRequestResourceAllocationRequest), + ResourceAllocation(ResourceAllocationReview), /// Submit a preimage to the host-selected backend. PreimageSubmit(PreimageSubmitReview), /// Allow a product to access another product account. diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index fe16f748..a98814b9 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -154,8 +154,8 @@ use truapi_platform::Platform; use truapi_platform::{ AccountAccessReview, CreateTransactionReview, IdentityDisclosureReview, PermissionAuthorizationRequest, PermissionAuthorizationStatus, PreimageSubmitReview, - ProductContext, SessionUiInfo, SignPayloadReview, SignRawReview, UserConfirmationReview, - normalize_product_identifier, + ProductContext, ResourceAllocationReview, SessionUiInfo, SignPayloadReview, SignRawReview, + UserConfirmationReview, normalize_product_identifier, }; /// Error reason surfaced to products when a remote permission is not granted. @@ -1823,7 +1823,12 @@ impl ResourceAllocation for ProductRuntimeHost { let confirmed = self .services .platform - .confirm_user_action(UserConfirmationReview::ResourceAllocation(inner.clone())) + .confirm_user_action(UserConfirmationReview::ResourceAllocation( + ResourceAllocationReview { + calling_product_id: self.product_id(), + resources: inner.resources.clone(), + }, + )) .await .map_err(|err| CallError::HostFailure { reason: format!("resource allocation confirmation failed: {err:?}"), diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index 25c239f6..a213f54d 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -19,7 +19,8 @@ use parity_scale_codec::Encode; use tracing::{debug, instrument, trace, warn}; use truapi::{CallContext, latest as api}; use truapi_platform::{ - CreateTransactionReview, SignPayloadReview, SignRawReview, UserConfirmationReview, + CreateTransactionReview, ResourceAllocationReview, SignPayloadReview, SignRawReview, + UserConfirmationReview, }; use super::SigningHost; @@ -708,14 +709,14 @@ async fn resource_allocation_response( signing_host: &Arc, request: messages::ResourceAllocationRequest, ) -> ResourceAllocationAnswer { - let review = - UserConfirmationReview::ResourceAllocation(api::HostRequestResourceAllocationRequest { - resources: request - .resources - .iter() - .map(public_allocatable_resource) - .collect(), - }); + let review = UserConfirmationReview::ResourceAllocation(ResourceAllocationReview { + calling_product_id: request.calling_product_id.clone(), + resources: request + .resources + .iter() + .map(public_allocatable_resource) + .collect(), + }); match services.platform.confirm_user_action(review).await { Ok(true) => {} Ok(false) => { @@ -1411,7 +1412,8 @@ mod tests { #[test] fn resource_allocation_requires_confirmation_before_allocation() { - let (services, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); + let platform = Arc::new(StubPlatform::default()); + let (services, signing_host) = signing_fixture(platform.clone()); let response = futures::executor::block_on(answer_remote_message( &services, @@ -1433,6 +1435,15 @@ mod tests { response.payload.unwrap(), vec![SsoAllocationOutcome::Rejected] ); + + // The confirmation review names the beneficiary product so the user + // knows which product receives the delegated allowance key. + let reviews = platform + .resource_allocation_reviews + .lock() + .expect("resource allocation review list mutex poisoned"); + assert_eq!(reviews.len(), 1); + assert_eq!(reviews[0].calling_product_id, "myapp.dot"); } #[test] diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index d678f091..b00c28a4 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -37,8 +37,8 @@ use truapi_platform::{ CoreStorage as PlatformCoreStorage, CoreStorageKey, Features as PlatformFeatures, HostInfo, JsonRpcConnection, Navigation as PlatformNavigation, Notifications as PlatformNotifications, PairingHostConfig, Permissions as PlatformPermissions, PlatformInfo, PreimageHost, - ProductContext, ProductStorage as PlatformProductStorage, ThemeHost, UserConfirmation, - UserConfirmationReview, + ProductContext, ProductStorage as PlatformProductStorage, ResourceAllocationReview, ThemeHost, + UserConfirmation, UserConfirmationReview, }; /// Test spawner that matches the current target. @@ -88,6 +88,8 @@ pub(crate) struct StubPlatform { pub(crate) create_transaction_error: Option<&'static str>, pub(crate) resource_allocation_confirmed: bool, pub(crate) resource_allocation_error: Option<&'static str>, + /// Every `ResourceAllocation` review passed to `confirm_user_action`, in order. + pub(crate) resource_allocation_reviews: Arc>>, pub(crate) session_blob: Option>, pub(crate) session_error: Option<&'static str>, pub(crate) session_clears: Arc>, @@ -1368,10 +1370,16 @@ impl UserConfirmation for StubPlatform { self.identity_disclosure_confirmed, ) } - UserConfirmationReview::ResourceAllocation(_) => ( - self.resource_allocation_error, - self.resource_allocation_confirmed, - ), + UserConfirmationReview::ResourceAllocation(review) => { + self.resource_allocation_reviews + .lock() + .expect("resource allocation review list mutex poisoned") + .push(review); + ( + self.resource_allocation_error, + self.resource_allocation_confirmed, + ) + } UserConfirmationReview::PreimageSubmit(_) => (None, true), }; if let Some(reason) = error { From d8168adf30c0ecccada33449bfdbe91036220239 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Thu, 23 Jul 2026 21:52:03 -0400 Subject: [PATCH 11/34] chore(codegen): refresh host-callbacks golden for ResourceAllocationReview --- .../tests/golden/host-callbacks.ts | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 6c30b178..dfbef287 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -7,8 +7,8 @@ import * as S from "@parity/truapi/scale"; import { + AllocatableResource, HostDevicePermissionRequest, - HostRequestResourceAllocationRequest, HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, @@ -222,6 +222,23 @@ export interface PreimageSubmitReview { size: bigint; } +/** + * Review shown before allocating resources for a product. Names the + * beneficiary product so the user knows which product receives the + * (signing-capable) allowance key they are approving. + */ +export interface ResourceAllocationReview { + /** + * Product the allocation is requested for. + */ + callingProductId: string; + + /** + * Resources to allocate. + */ + resources: Array; +} + /** * Decoded session fields a host shell needs to render account UI without * parsing the opaque session blob the core persists through `CoreStorage`. @@ -305,7 +322,7 @@ export type UserConfirmationReview = /** * Allocate resources for the requesting product. */ - | { tag: "ResourceAllocation"; value: HostRequestResourceAllocationRequest } + | { tag: "ResourceAllocation"; value: ResourceAllocationReview } /** * Submit a preimage to the host-selected backend. */ @@ -450,6 +467,20 @@ export const PreimageSubmitReview: S.Codec = S.lazy( S.Struct({ size: S.u64 }) as S.Codec, ); +/** + * Review shown before allocating resources for a product. Names the + * beneficiary product so the user knows which product receives the + * (signing-capable) allowance key they are approving. + */ +export const ResourceAllocationReview: S.Codec = + S.lazy( + (): S.Codec => + S.Struct({ + callingProductId: S.str, + resources: S.Vector(AllocatableResource), + }) as S.Codec, + ); + /** * Decoded session fields a host shell needs to render account UI without * parsing the opaque session blob the core persists through `CoreStorage`. @@ -498,7 +529,7 @@ export const UserConfirmationReview: S.Codec = S.lazy( AccountAlias: AccountAliasReview, CreateProof: CreateProofReview, IdentityDisclosure: IdentityDisclosureReview, - ResourceAllocation: HostRequestResourceAllocationRequest, + ResourceAllocation: ResourceAllocationReview, PreimageSubmit: PreimageSubmitReview, AccountAccess: AccountAccessReview, }), From 0bed52d36d1bf5c59ae4409aee1ba2350deb8a01 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Thu, 23 Jul 2026 22:01:44 -0400 Subject: [PATCH 12/34] fix(signing-host): use a dedicated review for statement-store proof signing --- .../tests/golden/host-callbacks.ts | 39 +++++++++++++++++++ rust/crates/truapi-platform/src/lib.rs | 16 +++++++- .../src/runtime/signing_host/sso_responder.rs | 26 +++++++++---- rust/crates/truapi-server/src/test_support.rs | 14 ++++++- 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index dfbef287..aaf409d2 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -14,6 +14,7 @@ import { HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, + ProductAccountId, ProductAccountTxPayload, ProductProofContext, RemotePermissionRequest, @@ -291,6 +292,24 @@ export type SignRawReview = */ | { tag: "LegacyAccount"; value: HostSignRawWithLegacyAccountRequest }; +/** + * Review shown before a product account signs a Statement Store proof + * payload. Distinct from raw-message signing: the payload is the exact + * unsigned statement, signed as-is (no `` envelope), so the host must + * not present it with the raw-signing convention. + */ +export interface StatementStoreProductSignReview { + /** + * Product account that will sign the statement payload. + */ + account: ProductAccountId; + + /** + * Exact unsigned statement payload to be signed. + */ + payload: Uint8Array; +} + /** * Review shown before a user-confirmed core action continues. */ @@ -303,6 +322,10 @@ export type UserConfirmationReview = * Sign raw bytes with a product or legacy account. */ | { tag: "SignRaw"; value: SignRawReview } + /** + * Sign a Statement Store proof payload with a product account. + */ + | { tag: "StatementStoreProductSign"; value: StatementStoreProductSignReview } /** * Create a transaction with a product or legacy account. */ @@ -517,6 +540,21 @@ export const SignRawReview: S.Codec = S.lazy( }), ); +/** + * Review shown before a product account signs a Statement Store proof + * payload. Distinct from raw-message signing: the payload is the exact + * unsigned statement, signed as-is (no `` envelope), so the host must + * not present it with the raw-signing convention. + */ +export const StatementStoreProductSignReview: S.Codec = + S.lazy( + (): S.Codec => + S.Struct({ + account: ProductAccountId, + payload: S.Bytes(), + }) as S.Codec, + ); + /** * Review shown before a user-confirmed core action continues. */ @@ -525,6 +563,7 @@ export const UserConfirmationReview: S.Codec = S.lazy( S.TaggedUnion({ SignPayload: SignPayloadReview, SignRaw: SignRawReview, + StatementStoreProductSign: StatementStoreProductSignReview, CreateTransaction: CreateTransactionReview, AccountAlias: AccountAliasReview, CreateProof: CreateProofReview, diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 5d0714d7..3a947879 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -22,7 +22,7 @@ use truapi::latest::{ HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, NotificationId, - ProductAccountTxPayload, ProductProofContext, RemotePermissionRequest, + ProductAccountId, ProductAccountTxPayload, ProductProofContext, RemotePermissionRequest, RemotePermissionResponse, RingLocation, ThemeVariant, }; use url::Url; @@ -548,6 +548,18 @@ pub enum SignRawReview { LegacyAccount(HostSignRawWithLegacyAccountRequest), } +/// Review shown before a product account signs a Statement Store proof +/// payload. Distinct from raw-message signing: the payload is the exact +/// unsigned statement, signed as-is (no `` envelope), so the host must +/// not present it with the raw-signing convention. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct StatementStoreProductSignReview { + /// Product account that will sign the statement payload. + pub account: ProductAccountId, + /// Exact unsigned statement payload to be signed. + pub payload: Vec, +} + /// Review shown before a transaction-creation request is sent to the paired wallet. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum CreateTransactionReview { @@ -623,6 +635,8 @@ pub enum UserConfirmationReview { SignPayload(SignPayloadReview), /// Sign raw bytes with a product or legacy account. SignRaw(SignRawReview), + /// Sign a Statement Store proof payload with a product account. + StatementStoreProductSign(StatementStoreProductSignReview), /// Create a transaction with a product or legacy account. CreateTransaction(CreateTransactionReview), /// Allow a product to derive a contextual alias for a ring. diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index a213f54d..7cd21724 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -20,7 +20,7 @@ use tracing::{debug, instrument, trace, warn}; use truapi::{CallContext, latest as api}; use truapi_platform::{ CreateTransactionReview, ResourceAllocationReview, SignPayloadReview, SignRawReview, - UserConfirmationReview, + StatementStoreProductSignReview, UserConfirmationReview, }; use super::SigningHost; @@ -1087,12 +1087,10 @@ async fn statement_store_product_sign_response( validate_unsigned_statement_signing_payload(&request.payload)?; confirm( services, - UserConfirmationReview::SignRaw(SignRawReview::Product(api::HostSignRawRequest { + UserConfirmationReview::StatementStoreProductSign(StatementStoreProductSignReview { account: request.product_account_id.clone(), - payload: api::RawPayload::Bytes { - bytes: request.payload.clone(), - }, - })), + payload: request.payload.clone(), + }), ) .await?; let session = signing_host @@ -1291,13 +1289,15 @@ mod tests { #[test] fn statement_store_product_sign_requires_confirmation() { - let (services, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); + let platform = Arc::new(StubPlatform::default()); + let (services, signing_host) = signing_fixture(platform.clone()); + let payload = statement_payload(); let response = futures::executor::block_on(answer_remote_message( &services, &signing_host, "request-1".to_string(), - statement_sign_request(statement_payload()), + statement_sign_request(payload.clone()), )) .expect("response is emitted"); @@ -1307,6 +1307,16 @@ mod tests { panic!("expected statement sign response"); }; assert_eq!(response.signature.unwrap_err(), "Rejected"); + + // The confirmation is a statement-store proof review carrying the exact + // unsigned payload, not a raw-message (``-wrapped) signing review. + let reviews = platform + .statement_store_product_sign_reviews + .lock() + .expect("statement store product sign review list mutex poisoned"); + assert_eq!(reviews.len(), 1); + assert_eq!(reviews[0].account.dot_ns_identifier, "myapp.dot"); + assert_eq!(reviews[0].payload, payload); } #[test] diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index b00c28a4..dc2291b0 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -37,8 +37,8 @@ use truapi_platform::{ CoreStorage as PlatformCoreStorage, CoreStorageKey, Features as PlatformFeatures, HostInfo, JsonRpcConnection, Navigation as PlatformNavigation, Notifications as PlatformNotifications, PairingHostConfig, Permissions as PlatformPermissions, PlatformInfo, PreimageHost, - ProductContext, ProductStorage as PlatformProductStorage, ResourceAllocationReview, ThemeHost, - UserConfirmation, UserConfirmationReview, + ProductContext, ProductStorage as PlatformProductStorage, ResourceAllocationReview, + StatementStoreProductSignReview, ThemeHost, UserConfirmation, UserConfirmationReview, }; /// Test spawner that matches the current target. @@ -84,6 +84,9 @@ pub(crate) struct StubPlatform { pub(crate) sign_payload_error: Option<&'static str>, pub(crate) sign_raw_confirmed: bool, pub(crate) sign_raw_error: Option<&'static str>, + /// Every `StatementStoreProductSign` review passed to `confirm_user_action`, in order. + pub(crate) statement_store_product_sign_reviews: + Arc>>, pub(crate) create_transaction_confirmed: bool, pub(crate) create_transaction_error: Option<&'static str>, pub(crate) resource_allocation_confirmed: bool, @@ -1345,6 +1348,13 @@ impl UserConfirmation for StubPlatform { (self.sign_payload_error, self.sign_payload_confirmed) } UserConfirmationReview::SignRaw(_) => (self.sign_raw_error, self.sign_raw_confirmed), + UserConfirmationReview::StatementStoreProductSign(review) => { + self.statement_store_product_sign_reviews + .lock() + .expect("statement store product sign review list mutex poisoned") + .push(review); + (self.sign_raw_error, self.sign_raw_confirmed) + } UserConfirmationReview::CreateTransaction(_) => ( self.create_transaction_error, self.create_transaction_confirmed, From 16009a1752e1e2200d8110587c7cd0543598c426 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Thu, 23 Jul 2026 22:12:02 -0400 Subject: [PATCH 13/34] Clippy / fmt --- rust/crates/truapi-platform/src/lib.rs | 7 +++---- .../src/runtime/signing_host/sso_responder.rs | 8 ++++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 3a947879..6de95ba5 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -19,10 +19,9 @@ use truapi::latest::{ AllocatableResource, GenericError, HostDevicePermissionRequest, HostDevicePermissionResponse, HostFeatureSupportedRequest, HostFeatureSupportedResponse, HostLocalStorageReadError, HostNavigateToError, HostPushNotificationRequest, HostPushNotificationResponse, - HostSignPayloadRequest, - HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, - HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, NotificationId, - ProductAccountId, ProductAccountTxPayload, ProductProofContext, RemotePermissionRequest, + HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, + HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, NotificationId, ProductAccountId, + ProductAccountTxPayload, ProductProofContext, RemotePermissionRequest, RemotePermissionResponse, RingLocation, ThemeVariant, }; use url::Url; diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index 7cd21724..b6f1ad34 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -94,10 +94,10 @@ impl ServedRequestIds { return false; } self.order.push_back(request_id); - if self.order.len() > MAX_SERVED_REQUEST_IDS { - if let Some(evicted) = self.order.pop_front() { - self.seen.remove(&evicted); - } + if self.order.len() > MAX_SERVED_REQUEST_IDS + && let Some(evicted) = self.order.pop_front() + { + self.seen.remove(&evicted); } true } From 103431cf1b0efc63586ff83207a0e08d5afdcab6 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 23 Jul 2026 23:05:30 +0200 Subject: [PATCH 14/34] fix(server): align runtime SCALE codecs Use codec-derived runtime payloads and metadata-aware storage projections so field ordering and variant encoding stay aligned with chain types. Derive SSO message labels once and keep normal transcript events at debug. --- README.md | 4 +- .../src/host_logic/attestation.rs | 74 +++++++- .../truapi-server/src/host_logic/extrinsic.rs | 12 +- .../truapi-server/src/host_logic/identity.rs | 32 +++- .../src/host_logic/sso/messages.rs | 7 +- .../src/host_logic/sso/messages/v1.rs | 18 +- .../src/host_logic/transaction.rs | 48 +++-- .../src/runtime/signing_host/sso_responder.rs | 108 ++++-------- .../src/runtime/statement_allowance.rs | 28 ++- .../runtime/statement_allowance/dynamic.rs | 164 ------------------ .../runtime/statement_allowance/extrinsic.rs | 109 ++++++++++-- .../src/runtime/statement_allowance/ring.rs | 123 +++++++++++-- .../src/runtime/statement_allowance/slot.rs | 35 +++- 13 files changed, 440 insertions(+), 322 deletions(-) delete mode 100644 rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs diff --git a/README.md b/README.md index af0ad7ad..3f2e0f00 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,11 @@ See [`js/packages/truapi/README.md`](js/packages/truapi/README.md) for the full ``` rust/crates/ - truapi/ Rust trait and type definitions (v01, v02) + truapi/ Rust traits, versioned envelopes, and latest payload re-exports truapi-codegen/ rustdoc JSON to TypeScript client + Rust dispatcher truapi-macros/ #[wire(id = N)] proc-macro truapi-platform/ Host syscall traits used by truapi-server (storage, navigation, consent, ...) - truapi-server/ Rust runtime that hosts implement: dispatcher, frames, SCALE, WASM surface + truapi-server/ Host runtime: dispatcher, typed SCALE logic, chain signing, WASM surface js/packages/ truapi/ @parity/truapi TypeScript client truapi-host/ @parity/truapi-host: WASM-backed host runtime; entries `.` diff --git a/rust/crates/truapi-server/src/host_logic/attestation.rs b/rust/crates/truapi-server/src/host_logic/attestation.rs index 4befc7fe..f0ef86ee 100644 --- a/rust/crates/truapi-server/src/host_logic/attestation.rs +++ b/rust/crates/truapi-server/src/host_logic/attestation.rs @@ -10,7 +10,7 @@ //! parity. The registered account is the account whose secret signs here; the //! paired host resolves the username from `Resources.Consumers[that account]`. -use parity_scale_codec::Encode; +use parity_scale_codec::{Decode, Encode}; use verifiable::GenerateVerifiable; use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; @@ -24,6 +24,19 @@ const REGISTER_PREFIX: &[u8] = b"pop:people-lite:register using"; /// Domain label for the P-256 identifier key advertised to the backend. const IDENTIFIER_KEY_LABEL: &[u8] = b"chat-encryption"; +/// SCALE payload signed for a lite consumer registration. +/// +/// This mirrors the People runtime's tuple of account, verifier, identifier +/// key, username base, and optional reserved username. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +struct ConsumerRegistrationSigningPayload { + account: [u8; 32], + verifier: [u8; 32], + identifier_key: [u8; 65], + username: Vec, + reserved_username: Option>, +} + /// Client-computed parameters for `POST /usernames`. pub struct LiteRegistration { /// SS58 (prefix 42) of the candidate account. @@ -75,13 +88,14 @@ pub fn build_lite_registration( derive_p256_keypair_from_entropy(entropy, IDENTIFIER_KEY_LABEL) .map_err(|err| format!("identifier key derivation failed: {err}"))?; - // SCALE Tuple(Bytes(32), Bytes(32), Bytes(65), str, Option(Bytes())=None). - let mut consumer_message = Vec::new(); - consumer_message.extend_from_slice(&candidate_public_key); - consumer_message.extend_from_slice(&verifier_account_id); - consumer_message.extend_from_slice(&identifier_key); - consumer_message.extend_from_slice(&username_base.encode()); - consumer_message.push(0u8); + let consumer_message = ConsumerRegistrationSigningPayload { + account: candidate_public_key, + verifier: verifier_account_id, + identifier_key, + username: username_base.as_bytes().to_vec(), + reserved_username: None, + } + .encode(); let consumer_registration_signature = candidate .secret .sign_simple( @@ -153,6 +167,50 @@ mod tests { ), "ring-VRF proof-of-ownership validates against the member key" ); + + // Verify against the runtime tuple independently of the production + // payload struct so field-order or optional-field regressions fail. + let consumer_message = ( + reg.candidate_public_key, + verifier, + reg.identifier_key, + b"headlesstester".as_slice(), + None::>, + ) + .encode(); + let sig = Signature::from_bytes(®.consumer_registration_signature).unwrap(); + assert!( + public + .verify_simple(SR25519_SIGNING_CONTEXT, &consumer_message, &sig) + .is_ok(), + "consumer registration signature verifies against the runtime tuple" + ); + } + + #[test] + fn consumer_registration_payload_matches_runtime_tuple_codec() { + let payload = ConsumerRegistrationSigningPayload { + account: [0x11; 32], + verifier: [0x22; 32], + identifier_key: [0x04; 65], + username: b"headlesstester".to_vec(), + reserved_username: None, + }; + let encoded = payload.encode(); + let runtime_tuple = ( + payload.account, + payload.verifier, + payload.identifier_key, + payload.username.as_slice(), + payload.reserved_username.as_ref(), + ) + .encode(); + + assert_eq!(encoded, runtime_tuple); + assert_eq!( + ConsumerRegistrationSigningPayload::decode(&mut encoded.as_slice()).unwrap(), + payload + ); } #[test] diff --git a/rust/crates/truapi-server/src/host_logic/extrinsic.rs b/rust/crates/truapi-server/src/host_logic/extrinsic.rs index 0e96ef91..ffab348e 100644 --- a/rust/crates/truapi-server/src/host_logic/extrinsic.rs +++ b/rust/crates/truapi-server/src/host_logic/extrinsic.rs @@ -11,7 +11,7 @@ use schnorrkel::{PublicKey, SecretKey}; use subxt::config::substrate::SubstrateConfig; use subxt::tx::Signer; use subxt::utils::{AccountId32, MultiAddress, MultiSignature}; -use truapi::v01; +use truapi::latest::TxPayloadExtension; use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; @@ -78,7 +78,7 @@ impl Signer for Sr25519Signer { /// /// Note the order differs from the extrinsic body (which puts `extra` before /// the call): the call comes first here, extras next, implicits last. -fn v4_signer_payload(call_data: &[u8], extensions: &[v01::TxPayloadExtension]) -> Vec { +fn v4_signer_payload(call_data: &[u8], extensions: &[TxPayloadExtension]) -> Vec { let mut payload = Vec::with_capacity(call_data.len()); payload.extend_from_slice(call_data); for ext in extensions { @@ -115,7 +115,7 @@ fn v4_signer_payload(call_data: &[u8], extensions: &[v01::TxPayloadExtension]) - pub(crate) fn build_signed_extrinsic_v4( signer: &Sr25519Signer, call_data: &[u8], - extensions: &[v01::TxPayloadExtension], + extensions: &[TxPayloadExtension], ) -> Vec { let signature = signer.sign(&v4_signer_payload(call_data, extensions)); build_signed_extrinsic_v4_with_signature(signer.account_id(), &signature, call_data, extensions) @@ -129,7 +129,7 @@ pub(crate) fn build_signed_extrinsic_v4_with_signature( account_id: AccountId32, signature: &MultiSignature, call_data: &[u8], - extensions: &[v01::TxPayloadExtension], + extensions: &[TxPayloadExtension], ) -> Vec { /// `0b1000_0000 | 4`: the "signed" bit plus extrinsic format version 4. const EXTRINSIC_V4_SIGNED: u8 = 0x84; @@ -275,8 +275,8 @@ pub(crate) mod tests { Sr25519Signer::from_keypair(&keypair) } - fn ext(id: &str, extra: &[u8], additional: &[u8]) -> v01::TxPayloadExtension { - v01::TxPayloadExtension { + fn ext(id: &str, extra: &[u8], additional: &[u8]) -> TxPayloadExtension { + TxPayloadExtension { id: id.to_string(), extra: extra.to_vec(), additional_signed: additional.to_vec(), diff --git a/rust/crates/truapi-server/src/host_logic/identity.rs b/rust/crates/truapi-server/src/host_logic/identity.rs index a1202e27..bcf0d834 100644 --- a/rust/crates/truapi-server/src/host_logic/identity.rs +++ b/rust/crates/truapi-server/src/host_logic/identity.rs @@ -8,7 +8,7 @@ //! Host-spec G defines the cross-host identity model and lookup behavior: //! -use parity_scale_codec::Decode; +use parity_scale_codec::{Decode, Encode}; use sp_crypto_hashing::{blake2_128, twox_128}; /// Username fields read from a People-chain `Resources.Consumers` record. @@ -20,8 +20,9 @@ pub struct PeopleIdentity { pub full_username: Option, } -#[derive(Debug, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] struct ConsumerUsernamePrefix { + identifier_key: [u8; 65], full_username: Option>, lite_username: Vec, } @@ -45,9 +46,7 @@ pub fn decode_people_identity(value: &[u8]) -> Result { )); } - // ConsumerInfo starts with a fixed 65-byte P-256 identifier key. The - // username fields follow immediately after it. - let mut input = &value[65..]; + let mut input = value; let decoded = ConsumerUsernamePrefix::decode(&mut input) .map_err(|err| format!("invalid Resources.Consumers record: {err}"))?; let lite_username = non_empty_string(decoded.lite_username)?; @@ -74,7 +73,6 @@ fn non_empty_string(bytes: Vec) -> Result, String> { #[cfg(test)] mod tests { use super::*; - use parity_scale_codec::Encode; #[test] fn resources_consumers_key_uses_expected_prefix() { @@ -106,6 +104,28 @@ mod tests { assert_eq!(decoded.lite_username.as_deref(), Some("alice.01")); } + #[test] + fn consumer_username_prefix_matches_runtime_field_codec() { + let prefix = ConsumerUsernamePrefix { + identifier_key: [0x04; 65], + full_username: Some(b"Alice Smith".to_vec()), + lite_username: b"alice.01".to_vec(), + }; + let encoded = prefix.encode(); + let runtime_fields = ( + prefix.identifier_key, + prefix.full_username.as_ref(), + prefix.lite_username.as_slice(), + ) + .encode(); + + assert_eq!(encoded, runtime_fields); + assert_eq!( + ConsumerUsernamePrefix::decode(&mut encoded.as_slice()).unwrap(), + prefix + ); + } + #[test] fn empty_full_username_is_none() { let mut value = vec![0x04; 65]; 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 5b23fd4b..26d5ddd9 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -72,7 +72,8 @@ impl TryFrom for SsoResponseCode { } /// Top-level remote message sent over the encrypted SSO channel. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::Display)] +#[display("{data}")] pub struct RemoteMessage { /// Correlation id used to match signing-host responses to pairing-host requests. pub message_id: String, @@ -81,9 +82,10 @@ pub struct RemoteMessage { } /// Versioned remote message body. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::Display)] pub enum RemoteMessageData { /// Version 1 of the remote message catalog. + #[display("{_0}")] V1(v1::RemoteMessage), } @@ -1044,6 +1046,7 @@ mod tests { }; assert_eq!(message.encode(), vec![0, 0, 0]); + assert_eq!(message.to_string(), "disconnected"); } #[test] 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 cf2da454..21ba6193 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 @@ -19,39 +19,55 @@ use super::{ /// /// The variant order is part of the SCALE wire protocol used inside /// statement-store session statements. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::Display)] pub enum RemoteMessage { /// The peer is ending the SSO session. + #[display("disconnected")] Disconnected, /// Ask the signing host to sign a payload or raw data with a product account. + #[display("sign_request")] SignRequest(Box), /// Signing host's answer to [`RemoteMessage::SignRequest`]. + #[display("sign_response")] SignResponse(SigningResponse), /// Ask the Account Holder for a contextual alias. + #[display("get_account_alias")] RingVrfAliasRequest(RingVrfAliasRequest), /// Account Holder's answer to [`RemoteMessage::RingVrfAliasRequest`]. + #[display("get_account_alias_response")] RingVrfAliasResponse(RingVrfAliasResponse), /// Ask the signing host to allocate SSO-backed resources. + #[display("resource_allocation")] ResourceAllocationRequest(ResourceAllocationRequest), /// Signing host's answer to [`RemoteMessage::ResourceAllocationRequest`]. + #[display("resource_allocation_response")] ResourceAllocationResponse(ResourceAllocationResponse), /// Ask the signing host to create a signed product-account transaction. + #[display("create_transaction")] CreateTransactionRequest(CreateTransactionRequest), /// Signing host's answer to either transaction-creation request. + #[display("create_transaction_response")] CreateTransactionResponse(CreateTransactionResponse), /// Ask the signing host to create a signed legacy-account transaction. + #[display("create_transaction_legacy")] CreateTransactionLegacyRequest(CreateTransactionLegacyRequest), /// Ask the signing host to sign raw data with a legacy account. + #[display("sign_raw_legacy")] SignRawLegacyRequest(SignRawLegacyRequest), /// Signing host's answer to [`RemoteMessage::SignRawLegacyRequest`]. + #[display("sign_raw_legacy_response")] SignRawLegacyResponse(SignRawLegacyResponse), /// Ask the Account Holder for a ring-VRF proof. + #[display("create_account_proof")] RingVrfProofRequest(RingVrfProofRequest), /// Account Holder's answer to [`RemoteMessage::RingVrfProofRequest`]. + #[display("create_account_proof_response")] RingVrfProofResponse(RingVrfProofResponse), /// Ask the signing host to sign an exact statement-store payload. + #[display("statement_store_product_sign")] StatementStoreProductSignRequest(StatementStoreProductSignRequest), /// Signing host's answer to /// [`RemoteMessage::StatementStoreProductSignRequest`]. + #[display("statement_store_product_sign_response")] StatementStoreProductSignResponse(StatementStoreProductSignResponse), } diff --git a/rust/crates/truapi-server/src/host_logic/transaction.rs b/rust/crates/truapi-server/src/host_logic/transaction.rs index a1b08ac8..758c2660 100644 --- a/rust/crates/truapi-server/src/host_logic/transaction.rs +++ b/rust/crates/truapi-server/src/host_logic/transaction.rs @@ -7,6 +7,8 @@ //! of being silently omitted. Preimages longer than 256 bytes are BLAKE2b-256 //! hashed before signing (standard Substrate signed-payload rule). +use parity_scale_codec::Encode; +use sp_crypto_hashing::blake2_256; use truapi::latest::{HostSignPayloadData, TxPayloadExtension}; /// Preimages longer than this are hashed before signing. @@ -42,7 +44,7 @@ pub(crate) fn extrinsic_payload_extensions( let mut extra = payload.tip.clone(); match &payload.asset_id { Some(asset_id) => extra.extend_from_slice(asset_id), - None => extra.push(0), + None => None::<()>.encode_to(&mut extra), } (extra, Vec::new()) } @@ -51,15 +53,19 @@ pub(crate) fn extrinsic_payload_extensions( let mode = u8::try_from(mode).map_err(|_| { format!("CheckMetadataHash mode {mode} does not fit in a u8") })?; - let mut additional_signed = Vec::new(); - match &payload.metadata_hash { - Some(hash) => { - additional_signed.push(1); - additional_signed.extend_from_slice(hash); - } - None => additional_signed.push(0), - } - (vec![mode], additional_signed) + let metadata_hash = payload + .metadata_hash + .as_deref() + .map(|hash| { + <[u8; 32]>::try_from(hash).map_err(|_| { + format!( + "CheckMetadataHash metadata hash is {} bytes, expected 32", + hash.len() + ) + }) + }) + .transpose()?; + (mode.encode(), metadata_hash.encode()) } unsupported => { return Err(format!( @@ -96,11 +102,11 @@ pub fn extrinsic_payload_preimage(payload: &HostSignPayloadData) -> Result) -> Vec { if preimage.len() > MAX_SIGNED_PREIMAGE_LEN { - blake2b_simd::Params::new() - .hash_length(32) - .hash(&preimage) - .as_bytes() - .to_vec() + // This is the same primitive and threshold used by Subxt's + // `frame_decode::extrinsics::encode_v4_signer_payload`. That builder + // itself is not applicable here because this API receives pre-encoded + // fields without runtime metadata or structured call arguments. + blake2_256(&preimage).to_vec() } else { preimage } @@ -264,6 +270,18 @@ mod tests { ); } + #[test] + fn payload_preimage_rejects_invalid_metadata_hash_length() { + let mut payload = payload(); + payload.signed_extensions = vec!["CheckMetadataHash".to_string()]; + payload.metadata_hash = Some(vec![0xBB; 31]); + + assert_eq!( + extrinsic_payload_preimage(&payload), + Err("CheckMetadataHash metadata hash is 31 bytes, expected 32".to_string()) + ); + } + #[test] fn long_preimages_are_blake2b_hashed() { let mut payload = payload(); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index b6f1ad34..2ed3ca6c 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use std::time::Instant; use parity_scale_codec::Encode; -use tracing::{debug, instrument, trace, warn}; +use tracing::{debug, instrument, warn}; use truapi::{CallContext, latest as api}; use truapi_platform::{ CreateTransactionReview, ResourceAllocationReview, SignPayloadReview, SignRawReview, @@ -231,31 +231,16 @@ async fn serve_session( for message in &incoming.messages { let cli_summary = format!( "Incoming SSO request · {}\nstatement_request_id={}\nremote_message_id={}", - remote_message_name(&message.data), - incoming.request_id, - message.message_id + message, incoming.request_id, message.message_id ); tracing::event!( target: "truapi_server::sso_transcript", - tracing::Level::INFO, + tracing::Level::DEBUG, cli_summary = cli_summary.as_str(), cli_event = "request_received", - request = remote_message_name(&message.data), + request = %message, statement_request_id = %incoming.request_id, remote_message_id = %message.message_id, - remote_message = remote_message_name(&message.data), - ); - debug!( - statement_request_id = %incoming.request_id, - remote_message_id = %message.message_id, - remote_message = ?message.data, - "decoded SSO request" - ); - trace!( - statement_request_id = %incoming.request_id, - remote_message_id = %message.message_id, - remote_message = ?message.data, - "received SSO message" ); } if !served_request_ids.insert(incoming.request_id.clone()) { @@ -293,7 +278,7 @@ async fn serve_request( debug!("pairing host disconnected the SSO session"); return Ok(Some(ResponderExit::PeerDisconnected)); } - let request_name = remote_v1_message_name(&request); + let request_name = request.to_string(); let responding_to = message.message_id.clone(); let started = Instant::now(); let Some(answer) = @@ -306,22 +291,6 @@ async fn serve_request( let response_result = answer .response_result .unwrap_or_else(|| remote_response_result(&response.data)); - debug!( - statement_request_id = %incoming.request_id, - responding_to = %responding_to, - %response_message_id, - response = remote_message_name(&response.data), - outcome = response_result.outcome, - reason = response_result.reason.as_deref().unwrap_or_default(), - "prepared SSO response" - ); - trace!( - statement_request_id = %incoming.request_id, - responding_to = %responding_to, - %response_message_id, - response = ?response.data, - "prepared SSO response payload" - ); let statement_request_id = format!("resp:{}", response.message_id); let statement = build_outgoing_request_statement( session, @@ -338,7 +307,7 @@ async fn serve_request( Ok(()) => { let cli_summary = response_cli_summary( "SSO response sent", - request_name, + &request_name, &incoming.request_id, &responding_to, &response_message_id, @@ -347,10 +316,10 @@ async fn serve_request( ); tracing::event!( target: "truapi_server::sso_transcript", - tracing::Level::INFO, + tracing::Level::DEBUG, cli_summary = cli_summary.as_str(), cli_event = "response_sent", - request = request_name, + request = request_name.as_str(), statement_request_id = %incoming.request_id, responding_to = %responding_to, %response_message_id, @@ -366,7 +335,7 @@ async fn serve_request( }; let cli_summary = response_cli_summary( "SSO response failed", - request_name, + &request_name, &incoming.request_id, &responding_to, &response_message_id, @@ -378,7 +347,7 @@ async fn serve_request( tracing::Level::WARN, cli_summary = cli_summary.as_str(), cli_event = "response_failed", - request = request_name, + request = request_name.as_str(), statement_request_id = %incoming.request_id, responding_to = %responding_to, %response_message_id, @@ -393,35 +362,6 @@ async fn serve_request( Ok(None) } -fn remote_message_name(message: &RemoteMessageData) -> &'static str { - match message { - RemoteMessageData::V1(message) => remote_v1_message_name(message), - } -} - -fn remote_v1_message_name(message: &v1::RemoteMessage) -> &'static str { - match message { - v1::RemoteMessage::Disconnected => "disconnected", - v1::RemoteMessage::SignRequest(_) => "sign_request", - v1::RemoteMessage::SignResponse(_) => "sign_response", - v1::RemoteMessage::RingVrfAliasRequest(_) => "get_account_alias", - v1::RemoteMessage::RingVrfAliasResponse(_) => "get_account_alias_response", - v1::RemoteMessage::ResourceAllocationRequest(_) => "resource_allocation", - v1::RemoteMessage::ResourceAllocationResponse(_) => "resource_allocation_response", - v1::RemoteMessage::CreateTransactionRequest(_) => "create_transaction", - v1::RemoteMessage::CreateTransactionResponse(_) => "create_transaction_response", - v1::RemoteMessage::CreateTransactionLegacyRequest(_) => "create_transaction_legacy", - v1::RemoteMessage::SignRawLegacyRequest(_) => "sign_raw_legacy", - v1::RemoteMessage::SignRawLegacyResponse(_) => "sign_raw_legacy_response", - v1::RemoteMessage::RingVrfProofRequest(_) => "create_account_proof", - v1::RemoteMessage::RingVrfProofResponse(_) => "create_account_proof_response", - v1::RemoteMessage::StatementStoreProductSignRequest(_) => "statement_store_product_sign", - v1::RemoteMessage::StatementStoreProductSignResponse(_) => { - "statement_store_product_sign_response" - } - } -} - struct ResponseResult { outcome: &'static str, reason: Option, @@ -439,9 +379,6 @@ struct ResourceAllocationAnswer { fn remote_response_result(message: &RemoteMessageData) -> ResponseResult { let RemoteMessageData::V1(message) = message; - if let v1::RemoteMessage::ResourceAllocationResponse(response) = message { - return resource_allocation_payload_result(&response.payload, &[]); - } let error = match message { v1::RemoteMessage::SignResponse(response) => response.payload.as_ref().err().cloned(), v1::RemoteMessage::RingVrfAliasResponse(response) => { @@ -450,7 +387,9 @@ fn remote_response_result(message: &RemoteMessageData) -> ResponseResult { v1::RemoteMessage::RingVrfProofResponse(response) => { response.payload.as_ref().err().map(ring_vrf_error_reason) } - v1::RemoteMessage::ResourceAllocationResponse(_) => unreachable!(), + v1::RemoteMessage::ResourceAllocationResponse(response) => { + return resource_allocation_payload_result(&response.payload, &[]); + } v1::RemoteMessage::CreateTransactionResponse(response) => { response.signed_transaction.as_ref().err().cloned() } @@ -1420,6 +1359,27 @@ mod tests { ); } + #[test] + fn response_summary_classifies_resource_allocation_batches() { + let response = RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse( + ResourceAllocationResponse { + responding_to: "allocation-1".to_string(), + payload: Ok(vec![ + SsoAllocationOutcome::Rejected, + SsoAllocationOutcome::NotAvailable, + ]), + }, + )); + + let result = remote_response_result(&response); + + assert_eq!(result.outcome, "rejected"); + assert_eq!( + result.reason.as_deref(), + Some("No resources allocated; 1 rejected; 1 unavailable") + ); + } + #[test] fn resource_allocation_requires_confirmation_before_allocation() { let platform = Arc::new(StubPlatform::default()); diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index a8e721a5..0774f270 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -6,7 +6,6 @@ //! resulting unsigned General (v5) extrinsic. Native only (needs the //! `verifiable` prover and live chain reads). -pub mod dynamic; pub mod extension; pub mod extrinsic; pub mod proof; @@ -17,7 +16,7 @@ pub mod slot; use std::time::{Duration, Instant}; use futures::FutureExt; -use parity_scale_codec::Decode; +use parity_scale_codec::{Decode, Encode}; use serde_json::{Value, json}; use sp_crypto_hashing::twox_128; use tracing::{debug, warn}; @@ -159,6 +158,11 @@ pub struct BulletinAllowanceInfo { pub fetched_at: u32, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +enum BulletinAuthorizationScope { + Account([u8; 32]), +} + impl BulletinAllowanceInfo { /// Returns whether the snapshot still permits at least one submission. pub fn available(self) -> bool { @@ -408,9 +412,7 @@ fn authorization_refreshed( /// `TransactionStorage.Authorizations[AuthorizationScope::Account(target)]`. fn bulletin_authorization_key(target: &[u8; 32]) -> Vec { - let mut scope = Vec::with_capacity(1 + 32); - scope.push(0x00); - scope.extend_from_slice(target); + let scope = BulletinAuthorizationScope::Account(*target).encode(); [ twox_128(b"TransactionStorage").as_slice(), twox_128(b"Authorizations").as_slice(), @@ -530,12 +532,22 @@ mod tests { assert_eq!(classified, vec![true, true, false, false]); } + #[test] + fn bulletin_account_scope_matches_runtime_enum_codec() { + let scope = BulletinAuthorizationScope::Account([0x42; 32]); + let encoded = scope.encode(); + + assert_eq!(encoded, [vec![0x00], vec![0x42; 32]].concat()); + assert_eq!( + BulletinAuthorizationScope::decode(&mut encoded.as_slice()).unwrap(), + scope + ); + } + /// `StmtStoreAllowanceEntry { account_id, seq: 0, since: 0 }` as a scripted /// JSON storage result. fn slot_entry(account: [u8; 32]) -> String { - let mut entry = account.to_vec(); - entry.extend_from_slice(&0u32.to_le_bytes()); - entry.extend_from_slice(&0u64.to_le_bytes()); + let entry = (account, 0u32, 0u64).encode(); format!(r#""0x{}""#, hex::encode(entry)) } diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs deleted file mode 100644 index 42e1df77..00000000 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! Minimal metadata-driven SCALE walker. -//! -//! Just enough to read one field out of a storage struct without a full dynamic -//! codec: `skip` advances a cursor past one value of a given type, -//! `read_field_variant_name` walks a composite to a named field and returns its -//! enum variant name (used for `CollectionInfo.ring_size` -> `R2e9`/`R2e10`/`R2e14`), -//! and `read_field_u32` reads simple numeric fields such as `RingRoot.revision`. - -use parity_scale_codec::{Compact, Decode}; -use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; - -/// Advance `input` past exactly one SCALE-encoded value of `type_id`. -pub fn skip(registry: &PortableRegistry, type_id: u32, input: &mut &[u8]) -> Result<(), String> { - let ty = registry - .resolve(type_id) - .ok_or_else(|| format!("unknown type id {type_id}"))?; - match &ty.type_def { - TypeDef::Composite(c) => { - for field in &c.fields { - skip(registry, field.ty.id, input)?; - } - } - TypeDef::Tuple(t) => { - for field in &t.fields { - skip(registry, field.id, input)?; - } - } - TypeDef::Array(a) => { - for _ in 0..a.len { - skip(registry, a.type_param.id, input)?; - } - } - TypeDef::Sequence(s) => { - let len = read_compact(input)?; - for _ in 0..len { - skip(registry, s.type_param.id, input)?; - } - } - TypeDef::Variant(v) => { - let index = read_u8(input)?; - let variant = v - .variants - .iter() - .find(|var| var.index == index) - .ok_or_else(|| format!("unknown variant index {index}"))?; - for field in &variant.fields { - skip(registry, field.ty.id, input)?; - } - } - TypeDef::Compact(_) => { - read_compact(input)?; - } - TypeDef::BitSequence(_) => { - let bits = read_compact(input)?; - advance(input, bits.div_ceil(8))?; - } - TypeDef::Primitive(p) => { - let len = match p { - TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => 1, - TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => 2, - TypeDefPrimitive::Char | TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => 4, - TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => 8, - TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => 16, - TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => 32, - // Length-prefixed UTF-8: compact byte length then the bytes. - TypeDefPrimitive::Str => read_compact(input)?, - }; - advance(input, len)?; - } - } - Ok(()) -} - -/// Walk composite `struct_type_id` to `field_name` and return the enum variant -/// name selected there (the field must be a fieldless/simple enum). -pub fn read_field_variant_name( - registry: &PortableRegistry, - struct_type_id: u32, - field_name: &str, - bytes: &[u8], -) -> Result { - let ty = registry - .resolve(struct_type_id) - .ok_or_else(|| format!("unknown type id {struct_type_id}"))?; - let TypeDef::Composite(composite) = &ty.type_def else { - return Err(format!("type {struct_type_id} is not a composite")); - }; - - let mut input = bytes; - for field in &composite.fields { - if field.name.as_deref() == Some(field_name) { - let field_ty = registry - .resolve(field.ty.id) - .ok_or_else(|| format!("unknown field type id {}", field.ty.id))?; - let TypeDef::Variant(variant) = &field_ty.type_def else { - return Err(format!("field `{field_name}` is not an enum")); - }; - let index = read_u8(&mut input)?; - return variant - .variants - .iter() - .find(|var| var.index == index) - .map(|var| var.name.clone()) - .ok_or_else(|| format!("unknown variant index {index} for `{field_name}`")); - } - skip(registry, field.ty.id, &mut input)?; - } - Err(format!("field `{field_name}` not found")) -} - -/// Walk composite `struct_type_id` to `field_name` and decode the field as a -/// SCALE `u32`. -pub fn read_field_u32( - registry: &PortableRegistry, - struct_type_id: u32, - field_name: &str, - bytes: &[u8], -) -> Result { - let ty = registry - .resolve(struct_type_id) - .ok_or_else(|| format!("unknown type id {struct_type_id}"))?; - let TypeDef::Composite(composite) = &ty.type_def else { - return Err(format!("type {struct_type_id} is not a composite")); - }; - - let mut input = bytes; - for field in &composite.fields { - if field.name.as_deref() == Some(field_name) { - let field_ty = registry - .resolve(field.ty.id) - .ok_or_else(|| format!("unknown field type id {}", field.ty.id))?; - if !matches!(field_ty.type_def, TypeDef::Primitive(TypeDefPrimitive::U32)) { - return Err(format!("field `{field_name}` is not a u32")); - } - return u32::decode(&mut input).map_err(|err| format!("field `{field_name}`: {err}")); - } - skip(registry, field.ty.id, &mut input)?; - } - Err(format!("field `{field_name}` not found")) -} - -/// Decode a SCALE compact-encoded length, advancing `input`. -fn read_compact(input: &mut &[u8]) -> Result { - let Compact(value) = Compact::::decode(input).map_err(|err| format!("compact: {err}"))?; - usize::try_from(value).map_err(|_| "compact length overflow".to_string()) -} - -/// Read one byte, advancing `input`. -fn read_u8(input: &mut &[u8]) -> Result { - let (&first, rest) = input - .split_first() - .ok_or_else(|| "unexpected end".to_string())?; - *input = rest; - Ok(first) -} - -/// Advance `input` by `n` bytes. -fn advance(input: &mut &[u8], n: usize) -> Result<(), String> { - if input.len() < n { - return Err(format!("need {n} bytes, have {}", input.len())); - } - *input = &input[n..]; - Ok(()) -} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs index af353b07..5c700d81 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs @@ -4,7 +4,7 @@ //! metadata, so a re-indexed runtime fails loudly instead of encoding a wrong //! call. -use parity_scale_codec::{Compact, Encode}; +use parity_scale_codec::{Decode, Encode}; use super::extension::{ChainState, Metadata}; @@ -15,6 +15,35 @@ const EXTENSION_VERSION: u8 = 0x00; /// `Option::Some` discriminant for the `AsResources` extension `extra`. const OPTION_SOME: u8 = 0x01; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +struct SetStatementStoreAccountCallArgs { + period: u32, + seq: u32, + target: [u8; 32], +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +struct ClaimLongTermStorageCallArgs { + period: u32, + counter: u8, + account_id: [u8; 32], +} + +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +struct RegisterStatementStoreAllowanceInfo { + proof: Vec, + ring_index: u32, + personhood: u8, +} + +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +struct ClaimLongTermStorageInfo { + proof: Vec, + ring_index: u32, + revision: u32, + personhood: u8, +} + /// Encode `Resources.set_statement_store_account(period, seq, target)`: /// `pallet ‖ call ‖ period_u32LE ‖ seq_u32LE ‖ target[32]`, with the dispatch /// indices resolved from `metadata`. @@ -27,9 +56,12 @@ pub fn build_set_statement_store_account_call( let indices = metadata.call_indices("Resources", "set_statement_store_account")?; let mut call = Vec::with_capacity(2 + 4 + 4 + 32); call.extend_from_slice(&indices); - call.extend_from_slice(&period.to_le_bytes()); - call.extend_from_slice(&seq.to_le_bytes()); - call.extend_from_slice(target); + SetStatementStoreAccountCallArgs { + period, + seq, + target: *target, + } + .encode_to(&mut call); Ok(call) } @@ -45,9 +77,12 @@ pub fn build_claim_long_term_storage_call( let indices = metadata.call_indices("Resources", "claim_long_term_storage")?; let mut call = Vec::with_capacity(2 + 4 + 1 + 32); call.extend_from_slice(&indices); - call.extend_from_slice(&period.to_le_bytes()); - call.push(counter); - call.extend_from_slice(account_id); + ClaimLongTermStorageCallArgs { + period, + counter, + account_id: *account_id, + } + .encode_to(&mut call); Ok(call) } @@ -64,10 +99,12 @@ pub fn build_as_resources_extra( let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 1); extra.push(OPTION_SOME); extra.push(info_index); - extra.extend_from_slice(&Compact(proof.len() as u32).encode()); - extra.extend_from_slice(proof); - extra.extend_from_slice(&ring_index.to_le_bytes()); - extra.push(lite_people); + RegisterStatementStoreAllowanceInfo { + proof: proof.to_vec(), + ring_index, + personhood: lite_people, + } + .encode_to(&mut extra); Ok(extra) } @@ -85,11 +122,13 @@ pub fn build_long_term_storage_extra( let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 4 + 1); extra.push(OPTION_SOME); extra.push(info_index); - extra.extend_from_slice(&Compact(proof.len() as u32).encode()); - extra.extend_from_slice(proof); - extra.extend_from_slice(&ring_index.to_le_bytes()); - extra.extend_from_slice(&revision.to_le_bytes()); - extra.push(lite_people); + ClaimLongTermStorageInfo { + proof: proof.to_vec(), + ring_index, + revision, + personhood: lite_people, + } + .encode_to(&mut extra); Ok(extra) } @@ -116,14 +155,13 @@ pub fn build_unsigned_extrinsic( } body.extend_from_slice(call_data); - let mut extrinsic = Compact(body.len() as u32).encode(); - extrinsic.extend_from_slice(&body); - Ok(extrinsic) + Ok(body.encode()) } #[cfg(test)] mod tests { use super::*; + use parity_scale_codec::Compact; const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); @@ -150,6 +188,14 @@ mod tests { ] .concat() ); + assert_eq!( + SetStatementStoreAccountCallArgs::decode(&mut &call[2..]).unwrap(), + SetStatementStoreAccountCallArgs { + period: 7, + seq: 0, + target: [0; 32], + } + ); } #[test] @@ -166,6 +212,14 @@ mod tests { ] .concat() ); + assert_eq!( + ClaimLongTermStorageCallArgs::decode(&mut &call[2..]).unwrap(), + ClaimLongTermStorageCallArgs { + period: 7, + counter: 3, + account_id: [0; 32], + } + ); } #[test] @@ -185,6 +239,14 @@ mod tests { ] .concat() ); + assert_eq!( + RegisterStatementStoreAllowanceInfo::decode(&mut &extra[2..]).unwrap(), + RegisterStatementStoreAllowanceInfo { + proof: vec![0xEE; 785], + ring_index: 3, + personhood: 1, + } + ); } #[test] @@ -206,6 +268,15 @@ mod tests { ] .concat() ); + assert_eq!( + ClaimLongTermStorageInfo::decode(&mut &extra[2..]).unwrap(), + ClaimLongTermStorageInfo { + proof: vec![0xEE; 785], + ring_index: 3, + revision: 9, + personhood: 1, + } + ); } #[test] diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs index 320c560b..1a37ae5a 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs @@ -5,9 +5,9 @@ //! current ring. Mirrors signing-bot `ring-proof.ts`. use parity_scale_codec::{Compact, Decode}; +use scale_decode::DecodeAsType; use sp_crypto_hashing::{blake2_128, twox_64, twox_128}; -use super::dynamic::{read_field_u32, read_field_variant_name}; use super::extension::Metadata; use super::rpc::RpcClient; @@ -16,6 +16,37 @@ const LITE_PEOPLE_IDENTIFIER: &[u8; 32] = b"pop:polkadot.network/people-lite"; /// Ring member public key length. const MEMBER_LEN: usize = 32; +/// Fields read from `Members.Collections`. +#[derive(Debug, PartialEq, Eq, DecodeAsType)] +struct CollectionInfo { + ring_size: RingExponent, +} + +/// Supported LitePeople ring domain sizes. +#[derive(Debug, PartialEq, Eq, DecodeAsType)] +enum RingExponent { + R2e9, + R2e10, + R2e14, +} + +impl RingExponent { + /// Return the exponent represented by the runtime enum variant. + fn exponent(self) -> u8 { + match self { + Self::R2e9 => 9, + Self::R2e10 => 10, + Self::R2e14 => 14, + } + } +} + +/// Fields read from `Members.Root`. +#[derive(Debug, PartialEq, Eq, DecodeAsType)] +struct RingRoot { + revision: u32, +} + /// On-chain LitePeople ring parameters for building a verifying proof. pub struct RingParams { /// Ring members, sliced to the baked-in `included` prefix. @@ -92,16 +123,6 @@ fn twox_64_concat(x: &[u8]) -> Vec { [twox_64(x).as_slice(), x].concat() } -/// Map a `RingExponent` variant name to its exponent. -fn ring_exponent_from_name(name: &str) -> Result { - match name { - "R2e9" => Ok(9), - "R2e10" => Ok(10), - "R2e14" => Ok(14), - other => Err(format!("unsupported RingExponent variant `{other}`")), - } -} - /// Read the current LitePeople ring index at the current best block /// (absent => 0). pub async fn read_current_ring_index(rpc: &RpcClient) -> Result { @@ -145,9 +166,10 @@ pub async fn read_ring_exponent( let value_type = metadata .storage_value_type("Members", "Collections") .ok_or_else(|| "Members.Collections type not in metadata".to_string())?; - let variant = - read_field_variant_name(metadata.registry(), value_type, "ring_size", &collection)?; - ring_exponent_from_name(&variant) + let mut input = collection.as_slice(); + CollectionInfo::decode_as_type(&mut input, value_type, metadata.registry()) + .map(|collection| collection.ring_size.exponent()) + .map_err(|err| format!("Members.Collections: {err}")) } /// Read the members of `ring_index`, sliced to the baked-in `included` @@ -220,8 +242,10 @@ pub async fn read_ring_revision( let value_type = metadata .storage_value_type("Members", "Root") .ok_or_else(|| "Members.Root type not in metadata".to_string())?; - read_field_u32(metadata.registry(), value_type, "revision", &bytes) - .map_err(|e| format!("ring revision: {e}")) + let mut input = bytes.as_slice(); + RingRoot::decode_as_type(&mut input, value_type, metadata.registry()) + .map(|root| root.revision) + .map_err(|err| format!("ring revision: {err}")) } None => Ok(0), } @@ -229,11 +253,78 @@ pub async fn read_ring_revision( #[cfg(test)] mod tests { + use parity_scale_codec::Encode; + use scale_info::TypeInfo; use subxt_rpcs::RpcClient as HostRpcClient; use super::super::rpc::testing::ScriptedRpc; use super::*; + fn decode_as(source: Source) -> Target + where + Source: Encode + TypeInfo + 'static, + Target: DecodeAsType, + { + let mut registry = scale_info::Registry::new(); + let type_id = registry + .register_type(&scale_info::meta_type::()) + .id; + let registry: scale_info::PortableRegistry = registry.into(); + let encoded = source.encode(); + Target::decode_as_type(&mut encoded.as_slice(), type_id, ®istry) + .expect("metadata-aware partial decode succeeds") + } + + #[test] + fn ring_metadata_projections_ignore_unneeded_runtime_fields() { + #[derive(Encode, TypeInfo)] + enum SourceRingExponent { + R2e14, + R2e9, + R2e10, + } + + #[derive(Encode, TypeInfo)] + struct SourceCollectionInfo { + owner: u8, + mode: u8, + ring_size: SourceRingExponent, + self_inclusion_delay: Option, + } + + #[derive(Encode, TypeInfo)] + struct SourceRingRoot { + root: [u8; 4], + revision: u32, + intermediate: [u8; 8], + } + + let collection: CollectionInfo = decode_as(SourceCollectionInfo { + owner: 7, + mode: 3, + ring_size: SourceRingExponent::R2e10, + self_inclusion_delay: Some(42), + }); + let root: RingRoot = decode_as(SourceRingRoot { + root: [0xaa; 4], + revision: 12, + intermediate: [0xbb; 8], + }); + + assert_eq!( + collection, + CollectionInfo { + ring_size: RingExponent::R2e10, + } + ); + assert_eq!(root, RingRoot { revision: 12 }); + + // Keep every source variant in the metadata so index order differs + // from the projection and variant-name decoding is exercised. + let _ = SourceRingExponent::R2e14; + let _ = SourceRingExponent::R2e9; + } + #[test] fn member_reads_are_pinned_and_truncated_to_included() { // Page 0 holds two members; RingStatus { total: 2, included: 1, None }. diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs index f05ebb55..2bdd35a9 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -6,6 +6,7 @@ //! derived from OUR bandersnatch entropy in that slot context. Mirrors //! signing-bot `allowance.ts` / `allowance-slots.ts`. +use parity_scale_codec::{Decode, Encode}; use sp_crypto_hashing::twox_128; use verifiable::GenerateVerifiable; use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; @@ -19,6 +20,13 @@ pub const STATEMENT_STORE_PERIOD_SECONDS: u64 = 86_400; /// Bulletin long-term-storage claim context prefix. const LONG_TERM_STORAGE_CONTEXT_PREFIX: &[u8] = b"pop:polkadot.net/rsc-lts"; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +struct StatementStoreAllowanceEntry { + account_id: [u8; 32], + seq: u32, + since: u64, +} + /// The current allowance period for `now_seconds`. pub fn current_period(now_seconds: u64) -> u32 { (now_seconds / STATEMENT_STORE_PERIOD_SECONDS) as u32 @@ -135,7 +143,10 @@ pub fn long_term_storage_period_duration(metadata: &Metadata) -> Result Option<[u8; 32]> { - bytes.get(..32).map(|s| s.try_into().expect("32 bytes")) + let mut input = bytes; + let entry = StatementStoreAllowanceEntry::decode(&mut input).ok()?; + let _ = (entry.seq, entry.since); + Some(entry.account_id) } /// The account holding our alias slot `(period, seq)`, read pinned to @@ -263,4 +274,26 @@ mod tests { 20, ); } + + #[test] + fn allowance_entry_matches_runtime_field_codec() { + let entry = StatementStoreAllowanceEntry { + account_id: [0x42; 32], + seq: 7, + since: 99, + }; + let encoded = entry.encode(); + + assert_eq!(encoded, (entry.account_id, entry.seq, entry.since).encode()); + assert_eq!(entry_account_id(&encoded), Some(entry.account_id)); + assert_eq!( + StatementStoreAllowanceEntry::decode(&mut encoded.as_slice()).unwrap(), + entry + ); + } + + #[test] + fn truncated_allowance_entry_has_no_account() { + assert_eq!(entry_account_id(&[0x42; 32]), None); + } } From 0e7e96e695c3e71750bd5396611cab4cbfd9caea Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 24 Jul 2026 09:24:11 +0200 Subject: [PATCH 15/34] update --- .../truapi-server/src/host_logic/transaction.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/rust/crates/truapi-server/src/host_logic/transaction.rs b/rust/crates/truapi-server/src/host_logic/transaction.rs index 758c2660..7fc6e68f 100644 --- a/rust/crates/truapi-server/src/host_logic/transaction.rs +++ b/rust/crates/truapi-server/src/host_logic/transaction.rs @@ -14,14 +14,6 @@ use truapi::latest::{HostSignPayloadData, TxPayloadExtension}; /// Preimages longer than this are hashed before signing. const MAX_SIGNED_PREIMAGE_LEN: usize = 256; -/// Signed extension contributing the `asset_id` extra after `tip`. -const CHARGE_ASSET_TX_PAYMENT: &str = "ChargeAssetTxPayment"; -/// Signed extension contributing the `mode` extra and `metadata_hash` implicit. -const CHECK_METADATA_HASH: &str = "CheckMetadataHash"; - -/// Standard signed extensions with no extra or implicit bytes. -const EMPTY_EXTENSIONS: &[&str] = &["CheckNonZeroSender", "CheckWeight"]; - /// Encode the standard signed extensions in the order declared by the target /// runtime. Unknown extensions are rejected because this wire payload has no /// field carrying their extra or implicit bytes. @@ -33,14 +25,14 @@ pub(crate) fn extrinsic_payload_extensions( .iter() .map(|id| { let (extra, additional_signed) = match id.as_str() { - id if EMPTY_EXTENSIONS.contains(&id) => (Vec::new(), Vec::new()), + "CheckNonZeroSender" | "CheckWeight" => (Vec::new(), Vec::new()), "CheckSpecVersion" => (Vec::new(), payload.spec_version.clone()), "CheckTxVersion" => (Vec::new(), payload.transaction_version.clone()), "CheckGenesis" => (Vec::new(), payload.genesis_hash.clone()), "CheckMortality" => (payload.era.clone(), payload.block_hash.clone()), "CheckNonce" => (payload.nonce.clone(), Vec::new()), "ChargeTransactionPayment" => (payload.tip.clone(), Vec::new()), - CHARGE_ASSET_TX_PAYMENT => { + "ChargeAssetTxPayment" => { let mut extra = payload.tip.clone(); match &payload.asset_id { Some(asset_id) => extra.extend_from_slice(asset_id), @@ -48,7 +40,7 @@ pub(crate) fn extrinsic_payload_extensions( } (extra, Vec::new()) } - CHECK_METADATA_HASH => { + "CheckMetadataHash" => { let mode = payload.mode.unwrap_or(0); let mode = u8::try_from(mode).map_err(|_| { format!("CheckMetadataHash mode {mode} does not fit in a u8") From fa9f1fbfb448bf0613165ce13dca816d1c5de708 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 24 Jul 2026 17:44:30 +0200 Subject: [PATCH 16/34] refactor(api): adopt Send async traits Native executors need dispatch futures that can move across Tokio worker threads. Keep concise async signatures while teaching rustdoc codegen to unwrap async-trait futures. --- Cargo.lock | 1 + rust/crates/truapi-codegen/src/rustdoc.rs | 207 +++++++++++++++++- rust/crates/truapi-server/src/dispatcher.rs | 23 +- rust/crates/truapi-server/src/host_core.rs | 19 ++ rust/crates/truapi-server/src/runtime.rs | 14 ++ .../src/runtime/statement_store.rs | 1 + rust/crates/truapi-server/src/subscription.rs | 4 +- rust/crates/truapi/Cargo.toml | 1 + rust/crates/truapi/src/api/account.rs | 1 + rust/crates/truapi/src/api/chain.rs | 1 + rust/crates/truapi/src/api/chat.rs | 1 + rust/crates/truapi/src/api/coin_payment.rs | 1 + rust/crates/truapi/src/api/entropy.rs | 1 + rust/crates/truapi/src/api/local_storage.rs | 1 + rust/crates/truapi/src/api/notifications.rs | 1 + rust/crates/truapi/src/api/payment.rs | 1 + rust/crates/truapi/src/api/permissions.rs | 1 + rust/crates/truapi/src/api/preimage.rs | 1 + .../truapi/src/api/resource_allocation.rs | 1 + rust/crates/truapi/src/api/signing.rs | 1 + rust/crates/truapi/src/api/statement_store.rs | 1 + rust/crates/truapi/src/api/system.rs | 1 + rust/crates/truapi/src/api/theme.rs | 1 + rust/crates/truapi/src/lib.rs | 7 +- 24 files changed, 276 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 30c55676..4ba5ec08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3961,6 +3961,7 @@ dependencies = [ name = "truapi" version = "0.5.1" dependencies = [ + "async-trait", "derive_more 2.1.1", "futures", "hex", diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index 1a51a671..933e23ef 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -650,7 +650,8 @@ fn extract_method(item_id: &str, item: &Item, names: &NameContext) -> Result bool { .unwrap_or(false) } +/// Resolve the `Output = T` binding from a Send future method return. +/// +/// `async_trait` represents `async fn` as +/// `Pin + Send + 'async_trait>>` in rustdoc JSON. +/// Explicit `impl Future + Send` returns are also accepted so the +/// parser remains compatible with older TrUAPI trait snapshots. +fn unwrap_future_output(output: &serde_json::Value) -> Result<&serde_json::Value> { + if let Some(future_output) = extract_async_trait_future_output(output) { + return Ok(future_output); + } + let Some(bounds) = output + .get("impl_trait") + .and_then(serde_json::Value::as_array) + else { + return Ok(output); + }; + let future = bounds + .iter() + .filter_map(|bound| bound.get("trait_bound")) + .filter_map(|bound| bound.get("trait")) + .find(|bound| { + bound + .get("path") + .and_then(serde_json::Value::as_str) + .is_some_and(|path| path_suffix(path) == "Future") + }) + .context("impl Trait return is missing its Future bound")?; + let constraints = future + .get("args") + .and_then(|args| args.get("angle_bracketed")) + .and_then(|args| args.get("constraints")) + .and_then(serde_json::Value::as_array) + .context("Future bound is missing its associated-type constraints")?; + constraints + .iter() + .find(|constraint| { + constraint.get("name").and_then(serde_json::Value::as_str) == Some("Output") + }) + .and_then(|constraint| constraint.get("binding")) + .and_then(|binding| binding.get("equality")) + .and_then(|equality| equality.get("type")) + .context("Future bound is missing its Output equality") +} + +fn extract_async_trait_future_output(output: &serde_json::Value) -> Option<&serde_json::Value> { + let pin = output.get("resolved_path")?; + if resolved_path_leaf(pin) != Some("Pin") { + return None; + } + let boxed = generic_type_arg(pin, 0)?.get("resolved_path")?; + if resolved_path_leaf(boxed) != Some("Box") { + return None; + } + let dyn_trait = generic_type_arg(boxed, 0)?.get("dyn_trait")?; + let traits = dyn_trait.get("traits")?.as_array()?; + traits + .iter() + .filter_map(|entry| entry.get("trait")) + .find(|trait_| resolved_path_leaf(trait_) == Some("Future"))? + .get("args")? + .get("angle_bracketed")? + .get("constraints")? + .as_array()? + .iter() + .find(|constraint| constraint.get("name").and_then(|name| name.as_str()) == Some("Output"))? + .get("binding")? + .get("equality")? + .get("type") +} + +fn resolved_path_leaf(resolved: &serde_json::Value) -> Option<&str> { + let path = resolved.get("path")?.as_str()?; + Some(path_suffix(path)) +} + +fn generic_type_arg(resolved: &serde_json::Value, index: usize) -> Option<&serde_json::Value> { + resolved + .get("args")? + .get("angle_bracketed")? + .get("args")? + .as_array()? + .iter() + .filter_map(|entry| entry.get("type")) + .nth(index) +} + fn is_result_subscription_return(output: &serde_json::Value) -> bool { if !is_result_return(output) { return false; @@ -1398,4 +1485,122 @@ mod tests { "unexpected error: {err}" ); } + + #[test] + fn unwraps_send_future_output() { + let output = serde_json::json!({ + "impl_trait": [ + { + "trait_bound": { + "trait": { + "path": "core::future::Future", + "args": { + "angle_bracketed": { + "args": [], + "constraints": [ + { + "name": "Output", + "binding": { + "equality": { + "type": { + "resolved_path": { + "path": "Result", + "id": 1, + "args": null + } + } + } + } + } + ] + } + } + } + } + }, + { + "trait_bound": { + "trait": { + "path": "Send", + "id": 2, + "args": null + } + } + } + ] + }); + + let unwrapped = unwrap_future_output(&output).expect("future output"); + + assert_eq!(get_resolved_name(unwrapped).as_deref(), Some("Result")); + } + + #[test] + fn unwraps_async_trait_send_future_output() { + let output = serde_json::json!({ + "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": { + "resolved_path": { + "path": "Result", + "id": 1, + "args": null + } + } + } + } + }] + } + } + } + }, + { + "trait": { + "path": "::core::marker::Send", + "args": null + } + } + ], + "lifetime": "'async_trait" + } + } + }], + "constraints": [] + } + } + } + } + }], + "constraints": [] + } + } + } + }); + + let unwrapped = unwrap_future_output(&output).expect("async-trait future output"); + + assert_eq!(get_resolved_name(unwrapped).as_deref(), Some("Result")); + } } diff --git a/rust/crates/truapi-server/src/dispatcher.rs b/rust/crates/truapi-server/src/dispatcher.rs index e0c3db60..45164c61 100644 --- a/rust/crates/truapi-server/src/dispatcher.rs +++ b/rust/crates/truapi-server/src/dispatcher.rs @@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use futures::future::LocalBoxFuture; +use futures::future::BoxFuture; use tracing::instrument; use crate::frame::{Payload, ProtocolMessage}; @@ -17,19 +17,20 @@ use crate::generated::wire_table::{RequestFrameIds, SubscriptionFrameIds}; use crate::subscription::{Spawner, SubscriptionManager, SubscriptionStream}; use crate::transport::Transport; -/// A handler for a request-response method. The returned future is not -/// required to be `Send` because the truapi trait uses `async fn`, whose -/// auto-Send-ness is not guaranteed. The `request_id` is the per-frame -/// identifier; handlers thread it into the `CallContext` so trait methods -/// can correlate logs/cancellation with the originating request. On the -/// error path handlers return the complete SCALE-encoded response payload. +/// A handler for a request-response method. TrUAPI service traits require +/// their returned futures to be [`Send`], allowing native dispatch to move +/// across executor threads while WASM remains free to poll the same future on +/// its local executor. The `request_id` is the per-frame identifier; handlers +/// thread it into the `CallContext` so trait methods can correlate +/// logs/cancellation with the originating request. On the error path handlers +/// return the complete SCALE-encoded response payload. pub type RequestHandler = - Arc) -> LocalBoxFuture<'static, Result, Vec>> + Send + Sync>; + Arc) -> BoxFuture<'static, Result, Vec>> + Send + Sync>; /// A handler for a subscription method. On the error path the handler returns /// the complete SCALE-encoded `_interrupt` payload. pub type SubscriptionHandler = Arc< - dyn Fn(String, Vec) -> LocalBoxFuture<'static, Result>> + dyn Fn(String, Vec) -> BoxFuture<'static, Result>> + Send + Sync, >; @@ -72,7 +73,7 @@ impl Dispatcher { /// since each request id must own exactly one handler. pub fn on_request(&mut self, ids: RequestFrameIds, handler: F) -> Option where - F: Fn(String, Vec) -> LocalBoxFuture<'static, Result, Vec>> + F: Fn(String, Vec) -> BoxFuture<'static, Result, Vec>> + Send + Sync + 'static, @@ -95,7 +96,7 @@ impl Dispatcher { handler: F, ) -> Option where - F: Fn(String, Vec) -> LocalBoxFuture<'static, Result>> + F: Fn(String, Vec) -> BoxFuture<'static, Result>> + Send + Sync + 'static, diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index ee561f8a..d1e8ccc5 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -580,6 +580,25 @@ mod tests { } } + fn assert_send(_: T) {} + + fn assert_send_sync() {} + + #[test] + fn product_runtime_and_dispatch_future_are_send() { + assert_send_sync::(); + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + Arc::new(RecordingSink::default()), + ); + + assert_send(runtime.receive_frame(Vec::new())); + } + #[test] fn dispose_cancels_active_subscriptions() { let theme_stream_dropped = Arc::new(AtomicBool::new(false)); diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index a98814b9..c012751d 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -693,6 +693,7 @@ fn runtime_failure_to_call_error(failure: RuntimeFailure) -> CallError { // System // --------------------------------------------------------------------------- +#[truapi::async_trait] impl System for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "system.feature_supported"))] async fn feature_supported( @@ -742,6 +743,7 @@ impl System for ProductRuntimeHost { // Permissions // --------------------------------------------------------------------------- +#[truapi::async_trait] impl Permissions for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "permissions.request_device_permission"))] async fn request_device_permission( @@ -798,6 +800,7 @@ impl Permissions for ProductRuntimeHost { // LocalStorage // --------------------------------------------------------------------------- +#[truapi::async_trait] impl LocalStorage for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "local_storage.read"))] async fn read( @@ -855,6 +858,7 @@ impl LocalStorage for ProductRuntimeHost { // Account-management flows live in the Rust core itself, backed by the shared // session state and, for alias/proof/login success paths, the SSO service. +#[truapi::async_trait] impl Account for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "account.get_account"))] async fn get_account( @@ -1131,6 +1135,7 @@ fn transaction_call_error( })) } +#[truapi::async_trait] impl Signing for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "signing.sign_payload"))] async fn sign_payload( @@ -1512,6 +1517,7 @@ impl Signing for ProductRuntimeHost { // translated into `RemoteChainHeadFollowItem` items on the subscription // stream. +#[truapi::async_trait] impl Chain for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "chain.follow_head_subscribe"))] async fn follow_head_subscribe( @@ -1743,8 +1749,11 @@ impl Chain for ProductRuntimeHost { const PAYMENTS_NOT_IMPLEMENTED: &str = "Payments are not supported in dot.li"; +#[truapi::async_trait] impl Chat for ProductRuntimeHost {} +#[truapi::async_trait] impl CoinPayment for ProductRuntimeHost {} +#[truapi::async_trait] impl Payment for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "payment.balance_subscribe"))] async fn balance_subscribe( @@ -1803,6 +1812,7 @@ impl Payment for ProductRuntimeHost { } } +#[truapi::async_trait] impl ResourceAllocation for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "resource_allocation.request"))] async fn request( @@ -1864,6 +1874,7 @@ impl ResourceAllocation for ProductRuntimeHost { // Entropy // --------------------------------------------------------------------------- +#[truapi::async_trait] impl Entropy for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "entropy.derive"))] async fn derive( @@ -1900,6 +1911,7 @@ impl Entropy for ProductRuntimeHost { // Preimage // --------------------------------------------------------------------------- +#[truapi::async_trait] impl Preimage for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "preimage.lookup_subscribe"))] async fn lookup_subscribe( @@ -2085,6 +2097,7 @@ fn bulletin_allowance_error_reason(err: AuthorityError) -> String { // Theme // --------------------------------------------------------------------------- +#[truapi::async_trait] impl Theme for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "theme.subscribe"))] async fn subscribe(&self, _cx: &CallContext) -> Subscription { @@ -2109,6 +2122,7 @@ impl Theme for ProductRuntimeHost { // `Notifications` delegates to the platform so hosts can own scheduling and // cancellation while the core preserves the typed TrUAPI wire shape. +#[truapi::async_trait] impl Notifications for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "notifications.send_push_notification"))] async fn send_push_notification( diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index 7e2bca0f..270dfe1d 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -35,6 +35,7 @@ use truapi::versioned::statement_store::{ }; use truapi::{CallContext, CallError, Subscription}; +#[truapi::async_trait] impl StatementStore for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "statement_store.subscribe"))] async fn subscribe( diff --git a/rust/crates/truapi-server/src/subscription.rs b/rust/crates/truapi-server/src/subscription.rs index b6766d16..cd026bd0 100644 --- a/rust/crates/truapi-server/src/subscription.rs +++ b/rust/crates/truapi-server/src/subscription.rs @@ -23,8 +23,8 @@ type StopFn = Box; /// future is `Send` because the inner [`SubscriptionStream`] is a /// `BoxStream<'static, _>` and every captured value the manager threads /// through it is also `Send`. Each platform bridge supplies an -/// implementation that hands the future to the runtime driving its -/// transport (tokio `LocalSet`, `wasm_bindgen_futures::spawn_local`, ...). +/// implementation that hands the future to the runtime driving its transport +/// (`tokio::spawn`, `wasm_bindgen_futures::spawn_local`, ...). pub type Spawner = Arc) + Send + Sync>; /// Convenience spawner for tests and embedders that don't yet wire a diff --git a/rust/crates/truapi/Cargo.toml b/rust/crates/truapi/Cargo.toml index 434bd5f3..6d27a071 100644 --- a/rust/crates/truapi/Cargo.toml +++ b/rust/crates/truapi/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true description = "TrUAPI trait and type definitions" [dependencies] +async-trait = "0.1" derive_more = { version = "2", features = ["display"] } futures = "0.3" hex = "0.4" diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index a3074048..f839ab1a 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -13,6 +13,7 @@ use crate::wire; use crate::{CallContext, CallError, Subscription}; /// Account lookup, aliasing, and proof generation. +#[crate::async_trait] pub trait Account: Send + Sync { /// Subscribe to account connection status changes. /// diff --git a/rust/crates/truapi/src/api/chain.rs b/rust/crates/truapi/src/api/chain.rs index 1f47c85b..1b33b210 100644 --- a/rust/crates/truapi/src/api/chain.rs +++ b/rust/crates/truapi/src/api/chain.rs @@ -22,6 +22,7 @@ use crate::wire; use crate::{CallContext, CallError, Subscription}; /// Chain interaction methods. +#[crate::async_trait] pub trait Chain: Send + Sync { /// Follow the chain head and receive block events. /// diff --git a/rust/crates/truapi/src/api/chat.rs b/rust/crates/truapi/src/api/chat.rs index 823d0f68..c9e9e826 100644 --- a/rust/crates/truapi/src/api/chat.rs +++ b/rust/crates/truapi/src/api/chat.rs @@ -11,6 +11,7 @@ use crate::wire; use crate::{CallContext, CallError, Subscription}; /// Chat room, bot, and message APIs. +#[crate::async_trait] pub trait Chat: Send + Sync { /// Create a chat room. /// diff --git a/rust/crates/truapi/src/api/coin_payment.rs b/rust/crates/truapi/src/api/coin_payment.rs index 25ae3ece..5839b8e3 100644 --- a/rust/crates/truapi/src/api/coin_payment.rs +++ b/rust/crates/truapi/src/api/coin_payment.rs @@ -22,6 +22,7 @@ use crate::{CallContext, CallError, Subscription}; /// RFC 0017 describes `Resolvable` values for long-running operations. /// TrUAPI represents those as subscriptions whose items are the RFC status /// updates. +#[crate::async_trait] pub trait CoinPayment: Send + Sync { /// Create a new firewalled CoinPayment purse. /// diff --git a/rust/crates/truapi/src/api/entropy.rs b/rust/crates/truapi/src/api/entropy.rs index 2e52f212..32f510b9 100644 --- a/rust/crates/truapi/src/api/entropy.rs +++ b/rust/crates/truapi/src/api/entropy.rs @@ -7,6 +7,7 @@ use crate::wire; use crate::{CallContext, CallError}; /// Deterministic entropy derivation. +#[crate::async_trait] pub trait Entropy: Send + Sync { /// Derive deterministic entropy. /// diff --git a/rust/crates/truapi/src/api/local_storage.rs b/rust/crates/truapi/src/api/local_storage.rs index 8cf17123..ec0bc634 100644 --- a/rust/crates/truapi/src/api/local_storage.rs +++ b/rust/crates/truapi/src/api/local_storage.rs @@ -9,6 +9,7 @@ use crate::wire; use crate::{CallContext, CallError}; /// Local key/value storage scoped to the calling product. +#[crate::async_trait] pub trait LocalStorage: Send + Sync { /// Read a value by key. /// diff --git a/rust/crates/truapi/src/api/notifications.rs b/rust/crates/truapi/src/api/notifications.rs index 69287bcd..01bf6769 100644 --- a/rust/crates/truapi/src/api/notifications.rs +++ b/rust/crates/truapi/src/api/notifications.rs @@ -9,6 +9,7 @@ use crate::wire; use crate::{CallContext, CallError}; /// Notification methods for locally-rendered push notifications. +#[crate::async_trait] pub trait Notifications: Send + Sync { /// Send a push notification to the user. /// diff --git a/rust/crates/truapi/src/api/payment.rs b/rust/crates/truapi/src/api/payment.rs index cd1d3aae..55f53b19 100644 --- a/rust/crates/truapi/src/api/payment.rs +++ b/rust/crates/truapi/src/api/payment.rs @@ -11,6 +11,7 @@ use crate::wire; use crate::{CallContext, CallError, Subscription}; /// Payment request and balance/status subscription methods. +#[crate::async_trait] pub trait Payment: Send + Sync { /// Subscribe to payment balance updates. /// diff --git a/rust/crates/truapi/src/api/permissions.rs b/rust/crates/truapi/src/api/permissions.rs index 60fc685f..a190984d 100644 --- a/rust/crates/truapi/src/api/permissions.rs +++ b/rust/crates/truapi/src/api/permissions.rs @@ -8,6 +8,7 @@ use crate::wire; use crate::{CallContext, CallError}; /// Permission request methods. +#[crate::async_trait] pub trait Permissions: Send + Sync { /// Request a device-capability permission from the user. /// diff --git a/rust/crates/truapi/src/api/preimage.rs b/rust/crates/truapi/src/api/preimage.rs index ad8769c9..5037f8cb 100644 --- a/rust/crates/truapi/src/api/preimage.rs +++ b/rust/crates/truapi/src/api/preimage.rs @@ -8,6 +8,7 @@ use crate::wire; use crate::{CallContext, CallError, Subscription}; /// Preimage lookup and submission methods. +#[crate::async_trait] pub trait Preimage: Send + Sync { /// Subscribe to preimage lookups for a given key. /// diff --git a/rust/crates/truapi/src/api/resource_allocation.rs b/rust/crates/truapi/src/api/resource_allocation.rs index 9d50a335..3de1003b 100644 --- a/rust/crates/truapi/src/api/resource_allocation.rs +++ b/rust/crates/truapi/src/api/resource_allocation.rs @@ -8,6 +8,7 @@ use crate::wire; use crate::{CallContext, CallError}; /// Resource pre-allocation (allowance management). +#[crate::async_trait] pub trait ResourceAllocation: Send + Sync { /// Request the host to pre-allocate one or more resources. /// diff --git a/rust/crates/truapi/src/api/signing.rs b/rust/crates/truapi/src/api/signing.rs index c2b44148..234d9a51 100644 --- a/rust/crates/truapi/src/api/signing.rs +++ b/rust/crates/truapi/src/api/signing.rs @@ -16,6 +16,7 @@ use crate::wire; use crate::{CallContext, CallError}; /// Signing operations. +#[crate::async_trait] pub trait Signing: Send + Sync { /// Construct a signed transaction for a product account. /// diff --git a/rust/crates/truapi/src/api/statement_store.rs b/rust/crates/truapi/src/api/statement_store.rs index 1110155c..38b16fb7 100644 --- a/rust/crates/truapi/src/api/statement_store.rs +++ b/rust/crates/truapi/src/api/statement_store.rs @@ -13,6 +13,7 @@ use crate::wire; use crate::{CallContext, CallError, Subscription}; /// Statement store methods. +#[crate::async_trait] pub trait StatementStore: Send + Sync { /// Subscribe to statements matching a topic filter. /// diff --git a/rust/crates/truapi/src/api/system.rs b/rust/crates/truapi/src/api/system.rs index 216cd45f..b08c5da9 100644 --- a/rust/crates/truapi/src/api/system.rs +++ b/rust/crates/truapi/src/api/system.rs @@ -10,6 +10,7 @@ use crate::{CallContext, CallError}; /// General-purpose TrUAPI methods for handshake, feature detection, /// and navigation. +#[crate::async_trait] pub trait System: Send + Sync { /// Negotiate the wire codec version with the product. /// diff --git a/rust/crates/truapi/src/api/theme.rs b/rust/crates/truapi/src/api/theme.rs index 5f78c3af..7fcbad81 100644 --- a/rust/crates/truapi/src/api/theme.rs +++ b/rust/crates/truapi/src/api/theme.rs @@ -5,6 +5,7 @@ use crate::wire; use crate::{CallContext, Subscription}; /// Host theme subscription. +#[crate::async_trait] pub trait Theme: Send + Sync { /// Subscribe to host theme changes. /// diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index a42fc689..5165ea55 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -2,8 +2,9 @@ //! //! Concrete wire types live in per-version modules. Versioned envelopes are in //! [`versioned`]. - -#![allow(async_fn_in_trait)] +//! Async API traits use the `async_trait` macro so their concise `async fn` methods +//! still guarantee `Send` futures. Implementations must annotate their impl +//! blocks with `#[truapi::async_trait]`. use core::convert::Infallible; use core::fmt; @@ -18,6 +19,8 @@ use std::sync::Mutex; use futures::Stream; use parity_scale_codec::{Decode, Encode}; +pub use async_trait::async_trait; + pub mod api; pub mod v01; pub mod versioned; From 925fbad56a1e109bb6a4578a0f7efd92f1103cf2 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 24 Jul 2026 17:48:31 +0200 Subject: [PATCH 17/34] feat(cli): integrate local signing host --- .gitignore | 1 + CLAUDE.md | 37 +- Cargo.lock | 1125 +++++- Makefile | 32 +- README.md | 14 + .../issue-drafts/bulletin-preimage-in-core.md | 336 ++ docs/local-e2e-testing.md | 18 +- .../diagnosis-reports/pairing-host-cli.md | 68 + .../diagnosis-reports/signing-host-cli.md | 68 + hosts/dotli | 2 +- playground/CLAUDE.md | 8 +- playground/src/components/MethodView.tsx | 2 +- playground/src/lib/example-helpers.ts | 146 +- playground/tests/e2e/dotli-diagnosis.ts | 245 +- .../tests/e2e/dotli/helpers/signer-bot.ts | 278 -- .../dotli/helpers/signing-host-cli.test.ts | 69 + .../e2e/dotli/helpers/signing-host-cli.ts | 160 + .../tests/golden/host-callbacks.ts | 3 +- rust/crates/truapi-host-cli/Cargo.toml | 49 + rust/crates/truapi-host-cli/README.md | 387 ++ rust/crates/truapi-host-cli/SPEC.md | 1549 ++++++++ .../truapi-host-cli/js/battery-reporter.ts | 148 + .../truapi-host-cli/js/diagnosis-report.ts | 75 + .../truapi-host-cli/js/diagnosis.test.ts | 169 + rust/crates/truapi-host-cli/js/diagnosis.ts | 173 + rust/crates/truapi-host-cli/js/runner.ts | 107 + .../truapi-host-cli/js/scripts/battery.ts | 67 + .../js/scripts/preimage-smoke.ts | 31 + .../js/scripts/ring-vrf-smoke.ts | 53 + .../js/scripts/signing-smoke.ts | 29 + .../truapi-host-cli/js/scripts/whoami.ts | 9 + rust/crates/truapi-host-cli/js/ws-provider.ts | 57 + .../recordings/screenshots/command-menu.png | Bin 0 -> 74902 bytes .../recordings/signing-host-diagnosis.tape | 37 + rust/crates/truapi-host-cli/src/accounts.rs | 703 ++++ .../crates/truapi-host-cli/src/attestation.rs | 278 ++ rust/crates/truapi-host-cli/src/chain.rs | 240 ++ .../truapi-host-cli/src/frame_server.rs | 266 ++ rust/crates/truapi-host-cli/src/main.rs | 2077 +++++++++++ rust/crates/truapi-host-cli/src/network.rs | 95 + rust/crates/truapi-host-cli/src/platform.rs | 1425 ++++++++ .../truapi-host-cli/src/script_runner.rs | 278 ++ rust/crates/truapi-host-cli/src/sessions.rs | 606 ++++ .../truapi-host-cli/src/signing_shell.rs | 693 ++++ .../crates/truapi-host-cli/src/terminal_ui.rs | 3227 +++++++++++++++++ .../truapi-host-cli/tests/signing_host_cli.rs | 191 + rust/crates/truapi-platform/src/lib.rs | 77 +- rust/crates/truapi-platform/tests/bounds.rs | 14 +- rust/crates/truapi-server/src/host_core.rs | 41 + rust/crates/truapi-server/src/runtime.rs | 53 +- .../truapi-server/src/runtime/pairing_host.rs | 24 + 51 files changed, 15289 insertions(+), 551 deletions(-) create mode 100644 docs/issue-drafts/bulletin-preimage-in-core.md create mode 100644 explorer/diagnosis-reports/pairing-host-cli.md create mode 100644 explorer/diagnosis-reports/signing-host-cli.md delete mode 100644 playground/tests/e2e/dotli/helpers/signer-bot.ts create mode 100644 playground/tests/e2e/dotli/helpers/signing-host-cli.test.ts create mode 100644 playground/tests/e2e/dotli/helpers/signing-host-cli.ts create mode 100644 rust/crates/truapi-host-cli/Cargo.toml create mode 100644 rust/crates/truapi-host-cli/README.md create mode 100644 rust/crates/truapi-host-cli/SPEC.md create mode 100644 rust/crates/truapi-host-cli/js/battery-reporter.ts create mode 100644 rust/crates/truapi-host-cli/js/diagnosis-report.ts create mode 100644 rust/crates/truapi-host-cli/js/diagnosis.test.ts create mode 100644 rust/crates/truapi-host-cli/js/diagnosis.ts create mode 100644 rust/crates/truapi-host-cli/js/runner.ts create mode 100644 rust/crates/truapi-host-cli/js/scripts/battery.ts create mode 100644 rust/crates/truapi-host-cli/js/scripts/preimage-smoke.ts create mode 100644 rust/crates/truapi-host-cli/js/scripts/ring-vrf-smoke.ts create mode 100644 rust/crates/truapi-host-cli/js/scripts/signing-smoke.ts create mode 100644 rust/crates/truapi-host-cli/js/scripts/whoami.ts create mode 100644 rust/crates/truapi-host-cli/js/ws-provider.ts create mode 100644 rust/crates/truapi-host-cli/recordings/screenshots/command-menu.png create mode 100644 rust/crates/truapi-host-cli/recordings/signing-host-diagnosis.tape create mode 100644 rust/crates/truapi-host-cli/src/accounts.rs create mode 100644 rust/crates/truapi-host-cli/src/attestation.rs create mode 100644 rust/crates/truapi-host-cli/src/chain.rs create mode 100644 rust/crates/truapi-host-cli/src/frame_server.rs create mode 100644 rust/crates/truapi-host-cli/src/main.rs create mode 100644 rust/crates/truapi-host-cli/src/network.rs create mode 100644 rust/crates/truapi-host-cli/src/platform.rs create mode 100644 rust/crates/truapi-host-cli/src/script_runner.rs create mode 100644 rust/crates/truapi-host-cli/src/sessions.rs create mode 100644 rust/crates/truapi-host-cli/src/signing_shell.rs create mode 100644 rust/crates/truapi-host-cli/src/terminal_ui.rs create mode 100644 rust/crates/truapi-host-cli/tests/signing_host_cli.rs diff --git a/.gitignore b/.gitignore index 9dc237bb..d19caf60 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ local.properties .env .env.* !.env.example +/.e2e-dotli/ # Editor / OS .vscode/* diff --git a/CLAUDE.md b/CLAUDE.md index b18cab4f..bb360b08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -182,33 +182,32 @@ still verify that the playground renders, the TrUAPI debug panel receives host/product events, generated examples can call non-confirmation methods, and logout/relogin does not restore a stale session. -The dotli Playwright e2e suite under `hosts/dotli/apps/host/tests/e2e/` -pairs through the signer-bot service. It requires `SIGNER_BOT_SVC_TOKEN`; -`SIGNER_BOT_BASE_URL` and `SIGNER_BOT_NETWORK` default to dotli CI's -`https://signing-bot-dev.novasama-tech.org/` and `paseo-next-v2`. Without the -token, do not treat the full suite as locally runnable. Use -`E2E_DOTLI_SMOKE=1 make e2e-dotli` for the no-phone QR smoke path. -If those signer-bot variables are not available in a worktree, check for a -repo-root `.env` and load or copy the values from there before falling back to -smoke mode. Prefer the current worktree's `.env` when it exists. +The root `make e2e-dotli` target builds the local `truapi-host` binary and +drives the dotli/playground diagnosis through a non-interactive signing-host +CLI process. The CLI answers the QR-derived pairing deeplink, auto-approves +remote requests, stays alive for the SSO session, and is launched again to +verify same-account reconnect after host sign-out. It uses +an explicitly exported `HOST_CLI_SIGNER_MNEMONIC` when present. Without one, +it auto-manages a reusable isolated identity under `.e2e-dotli/`. Set +`E2E_DOTLI_SIGNING_HOST_BASE_PATH` to preserve and reuse signing-host state +while debugging. Use `E2E_DOTLI_SMOKE=1 make e2e-dotli` for the QR-only smoke +path. For a fully automated local playground diagnosis run, use: ```bash -SIGNER_BOT_SVC_TOKEN=... \ make e2e-dotli ``` `make e2e-dotli` starts dotli preview and the playground, signs out any -restored host session, signs in through signer-bot by extracting the QR payload, -runs the playground Diagnosis screen, auto-accepts host-side Allow/Sign modals, -and writes `hosts/dotli/test-results/e2e-dotli/diagnosis-report.md`. - -Root CI runs the same target when it can read the private dotli submodule. It -needs `DOTLI_CHECKOUT_TOKEN` for submodule checkout; without that token, the -job warns and skips dotli e2e rather than failing unrelated PR checks. With -dotli access but without `SIGNER_BOT_SVC_TOKEN`, CI runs the no-phone smoke -path only. +restored host session, signs in through the local signing-host CLI by extracting +the QR payload, runs the playground Diagnosis screen, auto-accepts host-side +Allow/Sign modals, and writes +`playground/test-results/e2e-dotli/diagnosis-report.md`. + +Any CI job running the same target needs `DOTLI_CHECKOUT_TOKEN` for private +submodule checkout; without dotli access it should skip this integration gate +rather than fail unrelated checks. A useful no-phone smoke assertion is: diff --git a/Cargo.lock b/Cargo.lock index 4ba5ec08..656f335c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -49,6 +49,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -111,6 +120,32 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + [[package]] name = "ark-bls12-381" version = "0.5.0" @@ -261,7 +296,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" dependencies = [ "num-traits", - "rand", + "rand 0.8.6", "rayon", ] @@ -275,7 +310,7 @@ dependencies = [ "ark-serialize", "ark-std", "digest 0.10.7", - "rand_core", + "rand_core 0.6.4", "sha3", ] @@ -505,6 +540,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ "bitcoin_hashes", + "rand 0.8.6", + "rand_core 0.6.4", + "serde", + "unicode-normalization", ] [[package]] @@ -518,9 +557,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitvec" @@ -623,6 +662,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "byte-slice-cast" version = "1.2.3" @@ -647,6 +692,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.62" @@ -669,6 +723,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.9.1" @@ -677,7 +737,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -730,6 +801,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -746,6 +826,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -847,6 +941,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -881,6 +984,34 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more 2.1.1", + "document-features", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -894,7 +1025,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -906,7 +1037,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -936,7 +1067,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto", @@ -962,8 +1093,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -980,17 +1121,47 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "der" version = "0.7.10" @@ -1002,6 +1173,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive-where" version = "1.6.1" @@ -1076,6 +1253,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1087,6 +1274,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1113,7 +1309,7 @@ dependencies = [ "ed25519", "hashbrown 0.16.1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sha2 0.10.9", "subtle", "zeroize", @@ -1150,7 +1346,7 @@ dependencies = [ "generic-array", "group", "hkdf", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -1192,6 +1388,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -1213,6 +1415,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastbloom" version = "0.17.0" @@ -1237,7 +1445,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1270,7 +1478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" dependencies = [ "byteorder", - "rand", + "rand 0.8.6", "rustc-hex", "static_assertions", ] @@ -1332,6 +1540,16 @@ dependencies = [ "serde", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "funty" version = "2.0.0" @@ -1460,6 +1678,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1480,10 +1708,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1492,8 +1723,8 @@ version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea1015b5a70616b688dc230cfe50c8af89d972cb132d5a622814d29773b10b9" dependencies = [ - "rand", - "rand_core", + "rand 0.8.6", + "rand_core 0.6.4", ] [[package]] @@ -1559,7 +1790,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1601,6 +1832,11 @@ name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -1678,12 +1914,94 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.9", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1858,6 +2176,25 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2067,13 +2404,24 @@ dependencies = [ "url", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + [[package]] name = "keccak" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -2138,7 +2486,7 @@ dependencies = [ "libsecp256k1-core", "libsecp256k1-gen-ecmult", "libsecp256k1-gen-genmult", - "rand", + "rand 0.8.6", "serde", "sha2 0.9.9", "typenum", @@ -2173,6 +2521,15 @@ dependencies = [ "libsecp256k1-core", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2185,6 +2542,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2210,8 +2573,32 @@ dependencies = [ ] [[package]] -name = "memchr" -version = "2.8.0" +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.0", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" @@ -2223,7 +2610,7 @@ checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" dependencies = [ "byteorder", "keccak", - "rand_core", + "rand_core 0.6.4", "zeroize", ] @@ -2244,6 +2631,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2260,7 +2648,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" dependencies = [ - "rand", + "rand 0.8.6", ] [[package]] @@ -2297,6 +2685,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -2327,6 +2721,88 @@ dependencies = [ "libm", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2367,6 +2843,30 @@ dependencies = [ "primeorder", ] +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -2521,7 +3021,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2533,7 +3033,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2553,6 +3053,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2634,6 +3140,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2663,7 +3225,18 @@ checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -2673,7 +3246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2685,6 +3258,87 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags", + "compact_str", + "hashbrown 0.17.0", + "itertools 0.14.0", + "kasuari", + "lru 0.18.1", + "palette", + "serde", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags", + "hashbrown 0.17.0", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rayon" version = "1.12.0" @@ -2714,6 +3368,61 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.9", +] + [[package]] name = "ring" version = "0.17.14" @@ -2795,6 +3504,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -2896,7 +3606,7 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cb245f7fdb489e7ba43a616cbd34427fe3ba6fe0edc1d0d250085e6c84f3ec" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn", @@ -2923,7 +3633,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17020f2d59baabf2ddcdc20a4e567f8210baf089b8a8d4785f5fd5e716f92038" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro-crate", "proc-macro2", "quote", @@ -3034,7 +3744,7 @@ dependencies = [ "curve25519-dalek", "getrandom_or_panic", "merlin", - "rand_core", + "rand_core 0.6.4", "serde_bytes", "sha2 0.10.9", "subtle", @@ -3157,6 +3867,18 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -3177,7 +3899,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -3189,7 +3911,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -3201,7 +3923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -3230,6 +3952,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -3304,7 +4047,7 @@ dependencies = [ "bip39", "blake2-rfc", "bs58", - "chacha20", + "chacha20 0.9.1", "crossbeam-queue", "derive_more 2.1.1", "ed25519-zebra", @@ -3328,7 +4071,7 @@ dependencies = [ "pbkdf2", "pin-project", "poly1305", - "rand", + "rand 0.8.6", "rand_chacha", "ruzstd", "schnorrkel", @@ -3360,7 +4103,7 @@ dependencies = [ "bip39", "blake2-rfc", "bs58", - "chacha20", + "chacha20 0.9.1", "crossbeam-queue", "derive_more 2.1.1", "ed25519-zebra", @@ -3384,7 +4127,7 @@ dependencies = [ "pbkdf2", "pin-project", "poly1305", - "rand", + "rand 0.8.6", "rand_chacha", "ruzstd", "schnorrkel", @@ -3424,10 +4167,10 @@ dependencies = [ "hex", "itertools 0.14.0", "log", - "lru", + "lru 0.16.4", "parking_lot", "pin-project", - "rand", + "rand 0.8.6", "rand_chacha", "serde", "serde_json", @@ -3459,7 +4202,7 @@ dependencies = [ "futures", "httparse", "log", - "rand", + "rand 0.8.6", "sha1", ] @@ -3511,6 +4254,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "substrate-bip39" version = "0.6.1" @@ -3620,7 +4384,7 @@ version = "0.50.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5132bb78596d4957bf3039b9c54f5f88cefefd64ab61f0e71592526e14ef7f13" dependencies = [ - "darling", + "darling 0.20.11", "parity-scale-codec", "proc-macro-error2", "quote", @@ -3712,6 +4476,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -3791,6 +4564,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -3831,9 +4625,11 @@ version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -3869,6 +4665,23 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -3915,6 +4728,51 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -3944,6 +4802,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", ] [[package]] @@ -3952,9 +4822,16 @@ version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", + "smallvec", "thread_local", + "tracing", "tracing-core", + "tracing-log", ] [[package]] @@ -3983,6 +4860,41 @@ dependencies = [ "truapi", ] +[[package]] +name = "truapi-host-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "async-trait", + "bip39", + "clap", + "crossterm", + "fs2", + "futures", + "futures-util", + "hex", + "parity-scale-codec", + "ratatui", + "reqwest", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "shlex", + "subxt-rpcs", + "tempfile", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "truapi", + "truapi-platform", + "truapi-server", + "unicode-width", +] + [[package]] name = "truapi-macros" version = "0.1.0" @@ -4052,6 +4964,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "twox-hash" version = "1.6.3" @@ -4060,7 +4998,7 @@ checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ "cfg-if", "digest 0.10.7", - "rand", + "rand 0.8.6", "static_assertions", ] @@ -4109,6 +5047,23 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -4149,6 +5104,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4161,6 +5122,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "verifiable" version = "0.5.0" @@ -4210,7 +5177,7 @@ dependencies = [ "ark-serialize", "ark-std", "getrandom_or_panic", - "rand_core", + "rand_core 0.6.4", "rayon", "w3f-pcs", ] @@ -4242,6 +5209,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4482,6 +5458,40 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -4491,6 +5501,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -4772,6 +5788,23 @@ dependencies = [ "tap", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -4779,7 +5812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "serde", "zeroize", ] diff --git a/Makefile b/Makefile index 35f26bc0..b6c65785 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # Run `make help` for the list of targets. .DEFAULT_GOAL := help -.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test dotli-link dev dev-bootstrap dev-link-check e2e-dotli matrix explorer +.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test dotli-link dev dev-bootstrap dev-link-check e2e-dotli headless install matrix explorer TRUAPI_PKG := js/packages/truapi PLAYGROUND := playground @@ -21,13 +21,7 @@ DOTLI_TRUAPI_LINK := $(DOTLI_NODE_MODULES)/@parity/truapi DOTLI_HOST_WASM_LINK := $(DOTLI_NODE_MODULES)/@parity/truapi-host DOTLI_UI_TRUAPI_SHADOW := $(DOTLI_UI)/node_modules/@parity/truapi DOTLI_UI_HOST_WASM_SHADOW := $(DOTLI_UI)/node_modules/@parity/truapi-host -SIGNER_BOT_BASE_URL ?= https://signing-bot-dev.novasama-tech.org/ -SIGNER_BOT_NETWORK ?= paseo-next-v2 -SIGNER_BOT_BASE_URL_ORIGIN := $(origin SIGNER_BOT_BASE_URL) -SIGNER_BOT_NETWORK_ORIGIN := $(origin SIGNER_BOT_NETWORK) VITE_NETWORKS ?= paseo-next-v2,previewnet -export SIGNER_BOT_BASE_URL -export SIGNER_BOT_NETWORK export VITE_NETWORKS # Local product URLs (`http://localhost:5173/localhost:3000`) are intentionally @@ -55,6 +49,13 @@ build: ## Build the Rust workspace and the TypeScript client. cd $(TRUAPI_PKG) && npm run build cd $(HOST_WASM_PKG) && npm run build +headless: ## Build the truapi-host CLI and generated TypeScript client. + cargo build -p truapi-host-cli + cd $(TRUAPI_PKG) && npm run build + +install: headless ## Install the truapi-host CLI into Cargo's bin dir; use as `make headless install`. + cargo install --path rust/crates/truapi-host-cli --bin truapi-host --locked --force + codegen: ## Regenerate generated TS/Rust artifacts from the Rust crates. ./scripts/codegen.sh cd $(PLAYGROUND) && rm -rf node_modules/@parity && yarn install @@ -138,20 +139,9 @@ dev: dev-bootstrap ## Start dotli host (:5173) + playground (:3000) together; op ( until curl -fsS http://localhost:3000/ >/dev/null 2>&1; do sleep 1; done; curl -fsS http://localhost:3000/diagnostics >/dev/null 2>&1 || true ) & \ wait -e2e-dotli: ## Fully automated dotli + playground diagnosis e2e. Requires SIGNER_BOT_SVC_TOKEN unless E2E_DOTLI_SMOKE=1. - @SIGNER_BOT_SVC_TOKEN_ENV="$$SIGNER_BOT_SVC_TOKEN"; \ - SIGNER_BOT_BASE_URL_ENV="$$SIGNER_BOT_BASE_URL"; \ - SIGNER_BOT_NETWORK_ENV="$$SIGNER_BOT_NETWORK"; \ - SIGNER_BOT_BASE_URL_ORIGIN="$(SIGNER_BOT_BASE_URL_ORIGIN)"; \ - SIGNER_BOT_NETWORK_ORIGIN="$(SIGNER_BOT_NETWORK_ORIGIN)"; \ - set -a; \ - if [ -f .env ]; then . ./.env; fi; \ - set +a; \ - if [ -n "$$SIGNER_BOT_SVC_TOKEN_ENV" ]; then SIGNER_BOT_SVC_TOKEN="$$SIGNER_BOT_SVC_TOKEN_ENV"; export SIGNER_BOT_SVC_TOKEN; fi; \ - if [ "$$SIGNER_BOT_BASE_URL_ORIGIN" != "file" ] && [ -n "$$SIGNER_BOT_BASE_URL_ENV" ]; then SIGNER_BOT_BASE_URL="$$SIGNER_BOT_BASE_URL_ENV"; export SIGNER_BOT_BASE_URL; fi; \ - if [ "$$SIGNER_BOT_NETWORK_ORIGIN" != "file" ] && [ -n "$$SIGNER_BOT_NETWORK_ENV" ]; then SIGNER_BOT_NETWORK="$$SIGNER_BOT_NETWORK_ENV"; export SIGNER_BOT_NETWORK; fi; \ - if [ "$$E2E_DOTLI_SMOKE" != "1" ]; then test -n "$$SIGNER_BOT_SVC_TOKEN" || (echo "Missing SIGNER_BOT_SVC_TOKEN. e2e-dotli requires signer-bot; without it a human phone scan is required."; exit 1); fi; \ - $(MAKE) dev-bootstrap; \ +e2e-dotli: ## Fully automated dotli + playground diagnosis e2e using the local signing-host CLI. + @$(MAKE) dev-bootstrap + cargo build -p truapi-host-cli cd $(PLAYGROUND) && bun tests/e2e/dotli-diagnosis.ts matrix: ## Regenerate the host compatibility matrix from explorer/diagnosis-reports. diff --git a/README.md b/README.md index 3f2e0f00..e5b62cd4 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,14 @@ make wasm # rebuild truapi-server WASM artifacts under js/packages/truapi-ho CI regenerates the shared bindings before building and testing both npm packages, so generated client and host callback changes are checked together. +The native `truapi-host` utility can run pairing and signing hosts against the +real SSO transport for local end-to-end work. Both roles provide a +transcript-based terminal UI with commands such as `/product` and `/script`; +the signing host also provides `/pair` and a non-interactive `exec` form for +automation. See the +[`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) for setup, +controls, and examples. + To run the playground locally: ```bash @@ -125,6 +133,12 @@ make playground # rebuild the playground against the refreshed snapshot This repopulates the ignored generated TS under `js/packages/truapi/`, including the playground metadata. `make dev` and `make e2e-dotli` run this generation step unconditionally before starting their local stacks. +The full `make e2e-dotli` diagnosis builds and launches the local +`truapi-host signing-host` CLI to answer dotli's pairing QR and auto-approve +remote signing requests. It does not require the external signer-bot service. +When `HOST_CLI_SIGNER_MNEMONIC` is absent, the CLI manages a reusable isolated +test identity under `.e2e-dotli/`. Set `E2E_DOTLI_SIGNING_HOST_BASE_PATH` to +use a different state directory while debugging. ## Protocol versions diff --git a/docs/issue-drafts/bulletin-preimage-in-core.md b/docs/issue-drafts/bulletin-preimage-in-core.md new file mode 100644 index 00000000..c2f25664 --- /dev/null +++ b/docs/issue-drafts/bulletin-preimage-in-core.md @@ -0,0 +1,336 @@ +# Move Bulletin preimage submission into the Rust core + +## TL;DR + +Preimage submission to the Bulletin chain now happens **entirely inside the Rust +core** (`truapi-server`). The core builds, signs, and submits the +`TransactionStorage.store` extrinsic itself, routing the chain traffic through +the host's existing `chain.connect` byte-pipe — the same transport products +already use for any chain access. + +Previously the core handed the host a _signing capability_ and the host built +and submitted the transaction with PAPI. That seam is gone. As a result: + +- The wallet-delegated **allowance secret never leaves the core** (it no longer + crosses the host / FFI boundary as a signer handle). +- The host's only remaining preimage job is **content lookup** (Helia / IPFS). +- All hosts (web, and later mobile over uniffi) get identical submission + behaviour for free, because the logic lives in the shared core rather than in + each host's JS/native code. + +This issue explains the moving parts and, most importantly, **the messages +exchanged** between the product, the Rust core, the host, and the network. + +--- + +## Why + +The base branch already routes every chain call through one host callback, +`chain.connect(genesisHash)`, which returns a JSON-RPC byte pipe. Preimage +submission was the one chain write that did _not_ use it: instead the core +derived an allowance signer and passed a `sign(payload)` closure out to the +host, which reconstructed a PAPI transaction and submitted it. That meant: + +- Every host reimplemented extrinsic construction/submission (and dotli pulled + `@novasamatech` + PAPI signer deps back in to do it). +- The allowance secret's signing capability crossed the worker / FFI boundary. +- Submission behaviour (nonce, mortality, dispatch-error handling, retries) + diverged per host. + +Folding it into the core removes all three. + +--- + +## Before vs after: who owns what + +The topology is unchanged — the core still reaches the network only through the +host — what changes is **ownership**. Each box below lists what that side owns; +every arrow crossing the vertical boundary is labelled with exactly what +crosses it. + +Before, the *secret bytes* were core-owned, but the *capability to use them* — +and everything that decides what gets signed — was host-owned: + +``` +BEFORE + core-owned (WASM worker) host-owned (JS main thread) + +------------------------------+ || +------------------------------+ + | Rust core | || | dotli (PAPI) | + | | || | | + | owns: | || | owns: | + | - allowance secret (64 B) | || | - signer handle | + | - sign() execution | || | { publicKey, sign() } | + | | || | - tx builder (PAPI) | + | | || | - decides WHAT is signed | + | | || | - submission + watch | + +------------------------------+ || +------------------------------+ + + crosses ||: core --[ signer handle ]--> host (a live signing capability) + core <--[ sign(payload) ]-- host (one request per tx) + core --[ signature ]--> host --> Bulletin +``` + +After, everything signing-related sits on the core side; the only thing that +crosses the boundary during submission is opaque JSON-RPC text: + +``` +AFTER + core-owned (WASM worker) host-owned (JS main thread) + +------------------------------+ || +------------------------------+ + | Rust core | || | dotli | + | | || | | + | owns: | || | owns: | + | - allowance secret (64 B) | || | - permission/confirm modals | + | - signer (crate-private, | || | - chain.connect byte pipe | + | never exported) | || | (forwards verbatim) | + | - tx builder (subxt) | || | - lookupPreimage backend | + | - decides WHAT is signed | || | (Helia / IPFS) | + | - submission + watch | || | | + +------------------------------+ || +------------------------------+ + + crosses ||: core --[ JSON-RPC request ]--> host --> Bulletin + core <--[ JSON-RPC response ]-- host + opaque strings only: no key, no capability, no sign requests +``` + +The security consequence: before, host code held a live capability and chose +the payloads it signed; a compromised host could sign arbitrary Bulletin +transactions with the user's allowance. After, the host can at worst drop or +corrupt bytes — a lie in either direction produces a transaction the real +chain rejects, never a signature over attacker-chosen content. + +--- + +## Actors and responsibilities + +| Actor | Runs where | Owns | +| -------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------ | +| **Product** | sandboxed iframe / app | speaks TrUAPI; never sees chains or keys | +| **Rust core** (`truapi-server`) | WASM in a Web Worker (web); native lib (mobile) | wire protocol, tx build + sign, submit + watch, allowance key | +| **Host** (dotli / mobile shell) | main thread / OS shell | user modals, `chain.connect` transport, `lookupPreimage` content backend | +| **Wallet** (paired signing host) | phone | allocates the Bulletin allowance key over SSO | +| **Network** | — | Bulletin chain (writes) + People chain (allowance allocation / SSO) | + +--- + +## Message flow: preimage submission (happy path) + +This is the core of the change. The product calls one wire method, +`preimage.submit(value)`; everything below it is new in-core work. + +The columns are ownership lanes. A boundary glyph shows what crosses it on that +row: `>` left-to-right, `<` right-to-left, `:` nothing. Everything in the CORE +lane stays in the WASM worker unless a glyph carries it out. + +``` + PRODUCT | CORE (holds keys, builds+signs) : HOST (modals+pipe) : NET + ---------+------------------------------------+--------------------+--- + submit > receive : : + | -- gate (unchanged) -- : : + | remotePermission > prompt user : + | confirmUserAction > prompt user : + | -- allowance key (cached) -- : : + | requestResourceAllocation > relay SSO > wlt + | slotAccountKey < passthrough < key + | [[ 64-B secret stays in core ]] : : + | -- build + submit via pipe -- : : + | chain.connect(bulletin) > open pipe : + | chainHead_v1_follow > forward > N + | Metadata_metadata_at_version > forward > N + | AccountNonceApi_account_nonce > forward > N + | [[ build + SIGN store(value) ]] : : + | validate_transaction (dry-run) > forward > N + | transaction_v1_broadcast > forward > N + | chainHead_v1_body (watch) > forward > N + | chainHead_v1_storage(Events) > forward > N + key< return blake2_256(value) : : + | [[ prime lookup cache ]] : : + + boundary glyphs: > message crosses left->right < crosses right->left + : boundary, no message on this row + CORE:HOST is the WASM-worker edge; [[..]] work never leaves the core; NET is + the Bulletin chain, reached only through the host's pipe (wlt = paired wallet) +``` + +Key points on this flow: + +- The **gate** (permission + confirmation) is unchanged from the base protocol; + it still guards the write. +- The **dry-run** is load-bearing: `transaction_v1_broadcast` is + spec-guaranteed to be _silent_ on invalid transactions, so without a + `validate_transaction` call the core could never distinguish "rejected" from + "still pending" and would only ever see a timeout. +- Inclusion is confirmed by **matching the extrinsic hash in a block body**, + then the **dispatch outcome** is read from `System.Events` — matching the + fidelity the old PAPI `signSubmitAndWatch` path had. + +--- + +## Message flow: allowance key (the one secret) + +The allowance key is a wallet-delegated, scoped signing capability. The wallet +owns *minting* it; the core owns *holding and using* it; the host only relays +the SSO messages and never sees the resulting secret used. It is allocated once +per (session, product), cached in the core, and used only to sign the `store` +call. + +``` + CORE (holds + uses the key) : HOST (relays SSO) : WALLET (mints) + ---------------------------------------+--------------------+--- + -- first submit for this product -- : : + requestResourceAllocation > relay (Ignore) > allocate + slotAccountKey < passthrough < { key } + [[ store in memory + persisted ]] : : + [[ used only to sign store() calls ]] : : + : : + -- later: allowance is exhausted -- : : + [[ evict the cached key ]] : : + requestResourceAllocation > relay (Increase) > mint fresh, + slotAccountKey < passthrough < larger one + [[ retry the submit exactly once ]] : : + + > crosses core -> host -> wallet < the reply crossing back + the key lands in CORE and is used there; the host never sees it signing +``` + +The retry is bounded: the core refreshes the allowance and retries **at most +once**, and only when the failure is a typed "allowance rejected" signal (from +the dry-run or from a `TransactionStorage` authorization error in the events) — +never on transport errors or nonce races. + +--- + +## Message flow: lookup (unchanged owner, new integrity check) + +The *content backend* is host-owned (the host chooses Helia P2P or an IPFS +gateway); the *cache* and the *integrity gate* are core-owned. So even though +the bytes originate on the host side, the core decides whether they reach the +product. + +``` + CORE (cache + integrity gate) : HOST (content backend) + -----------------------------------------+----------------------- + lookup_subscribe(key) from product : + : + cache hit (primed by in-core submit): : + emit value once, keep open : + : + cache miss: : + lookupPreimage(key) > poll Helia / IPFS + value | miss < host-owned bytes + [[ gate: blake2_256(value)==key? ]] : + match: forward mismatch: -> miss : + + > request crosses into the host < host's bytes cross back + the backend is host-owned; the cache + integrity gate are core-owned, + so the core decides what reaches the product (a mismatch is warn-logged) +``` + +A host that returns bytes not matching the requested key can no longer feed a +product forged content: the core's integrity gate downgrades the mismatch to a +miss before it reaches the product. + +--- + +## Failure handling and the inclusion watch + +The submit flow returns a typed error that maps to a stable reason string on the +wire (`PreimageSubmitError::Unknown { reason }` — the product wire is +unchanged). The decision the core makes at each failure: + +``` + dry-run: validate_transaction + | + +-- Valid ................................. broadcast + watch (see below) + +-- Invalid::Payment / Custom / BadSigner . allowance rejected -> refresh + retry once + +-- Invalid::Future / Stale ............... nonce race (no retry) + +-- other Invalid ......................... invalid (no retry) + + broadcast + watch: included? + | + +-- ExtrinsicSuccess ...................... return the preimage key + +-- TransactionStorage auth error ......... allowance rejected -> refresh + retry once + +-- other dispatch error .................. dispatch failed + +-- 120s elapsed / follow stopped ......... broadcast, inclusion unverified +``` + +"Allowance rejected" is the only outcome that refreshes the key +(`onExisting=Increase`) and retries — at most once, in both the dry-run and the +dispatch branches. + +The inclusion watch is a single event loop over one ephemeral chainHead follow. +It fetches a block body only after the allowance account's nonce is seen to +advance (so it does not download every block), matches by extrinsic hash, and +releases pins as it goes. Because the follow is dropped when the 120 s budget or +a cancellation fires, its pins and connection lease are released automatically. + +--- + +## Security invariants + +Because the chain provider is an untrusted byte pipe (especially in RPC-gateway +mode), the core enforces four invariants so a hostile or buggy provider cannot +subvert the allowance key: + +1. **Genesis is config-pinned.** The signed payload's genesis hash always comes + from host _configuration_, never from provider-echoed chain data — so a + provider cannot redirect the allowance-key signature to a different chain. + Every other provider-supplied input (spec/tx version, nonce, mortality + anchor, metadata) is then fail-closed: lying about it yields a signature the + real chain rejects, never one valid elsewhere. +2. **Call is pinned.** Name resolution of `TransactionStorage.store` is + hard-asserted against audited pallet/call indices, and the metadata-built + call bytes are checked byte-for-byte against a canonically built copy — so + provider metadata cannot make the key sign a different call. +3. **Signer is confined.** The only allowance-key -> signer conversion is + crate-private to the bulletin module; the signer is transient, never stored, + never logged, and the key type is zeroized on drop. +4. **Lookup is content-addressed.** Host-returned bytes are verified against the + requested key before reaching the product. + +A crafted `NewBlock` parent link (self-referential or cyclic) is also guarded in +the inclusion watch so the provider cannot spin the worker. + +--- + +## What changed (high-level map) + +- `truapi-server/src/host_logic/extrinsic.rs` — offline subxt assembler: + config-pinned `SubstrateConfig`, sr25519 signer, metadata / validity / events + / header decoders. +- `truapi-server/src/host_logic/bulletin.rs` — `store{data}` build + sign, call + pinning, byte-level call-data encoder. +- `truapi-server/src/runtime/bulletin_rpc.rs` — the submit flow (follow -> + metadata -> nonce -> dry-run -> broadcast -> watch -> events) and its typed + errors. +- `truapi-server/src/runtime.rs` — `Preimage::submit` ordering + refresh/retry; + `lookup_subscribe` cache + integrity check. +- `truapi-platform` — `PreimageHost` keeps only `lookupPreimage`; + `BulletinAllowanceKey` is zeroized on drop; configs gain an optional Bulletin + genesis hash. +- Host TS (`@parity/truapi-host`) + dotli — the signer bridge is removed; dotli + keeps only lookup, threads the Bulletin genesis into its runtime config, and + routes the Bulletin genesis through `chain.connect` on both backends. + +The **product-facing wire is unchanged** — `preimage.submit` / +`preimage.lookup_subscribe` keep their wire ids and shapes, so products need no +changes. + +--- + +## Verification + +- Rust: build / fmt / clippy (`-D warnings`) / tests all green; wasm32 compiles; + `cargo deny` licenses ok. New unit tests cover extrinsic construction against + real Bulletin metadata, genesis-binding, call pinning, the extension-encoding + rules, and the inclusion-watch cycle guard. +- Host TS: `tsc` + `bun` tests green. WASM bundle grows ~0.5 MB (subxt offline). +- **Manual / e2e gates** (network + signing-host CLI, not in CI): the live-chain + dry-run encoding proof and `make e2e-dotli` preimage flow. + +## Follow-ups + +- Signing-host (mobile local-key) role: derive the Bulletin allowance key from + root entropy so submission works there too — everything downstream of the + allowance-key fetch is already shared. +- Reuse `host_logic/extrinsic.rs` for the local `create_transaction` path. diff --git a/docs/local-e2e-testing.md b/docs/local-e2e-testing.md index 7ffde321..1547c917 100644 --- a/docs/local-e2e-testing.md +++ b/docs/local-e2e-testing.md @@ -27,18 +27,20 @@ for failure modes — both the skills and CI cite it. `make e2e-dotli` is the end-to-end dotli + playground diagnosis harness. It starts the local dotli preview and playground, opens Chromium, signs out any -restored host session, signs in through the signer-bot SSO service, runs the -playground Diagnosis screen, and writes -`playground/test-results/e2e-dotli/diagnosis-report.md`. Full automation -requires `SIGNER_BOT_SVC_TOKEN`; `SIGNER_BOT_BASE_URL` and -`SIGNER_BOT_NETWORK` default to dotli CI's signer-bot service and -`paseo-next-v2`. Without the token, use +restored host session, builds and launches the local `truapi-host signing-host` +CLI to answer the pairing deeplink and auto-approve SSO requests, runs the +playground Diagnosis screen, verifies sign-out and same-account reconnect, and +writes `playground/test-results/e2e-dotli/diagnosis-report.md`. The harness +uses `HOST_CLI_SIGNER_MNEMONIC` when supplied. Otherwise the CLI provisions +and reuses an isolated test identity under `.e2e-dotli/`. Set +`E2E_DOTLI_SIGNING_HOST_BASE_PATH` to use a different state directory while +debugging. Set `E2E_DOTLI_NETWORK` to override the default `paseo-next-v2` +network. Use `E2E_DOTLI_SMOKE=1 make e2e-dotli` to verify the local stack, browser launch, login click, TrUAPI debug logs, and QR/deeplink extraction without a phone. In root CI, the job also needs `DOTLI_CHECKOUT_TOKEN` to read the private dotli submodule. Without dotli access it reports a warning and skips the e2e -job; with dotli access but without `SIGNER_BOT_SVC_TOKEN`, it runs the smoke -path only. +job. The order matters: each layer assumes the layer below it builds clean. Skip a step only if you are certain the change cannot affect that layer. diff --git a/explorer/diagnosis-reports/pairing-host-cli.md b/explorer/diagnosis-reports/pairing-host-cli.md new file mode 100644 index 00000000..c32ca3e5 --- /dev/null +++ b/explorer/diagnosis-reports/pairing-host-cli.md @@ -0,0 +1,68 @@ +## Truapi Pairing Host CLI Diagnosis + +| Method | Status | Details | +| --- | --- | --- | +| `Account/connection_status_subscribe` | ✅ | | +| `Account/get_account` | ✅ | | +| `Account/get_account_alias` | ✅ | | +| `Account/create_account_proof` | ✅ | | +| `Account/get_legacy_accounts` | ✅ | | +| `Account/get_user_id` | ✅ | | +| `Account/request_login` | ✅ | | +| `Chain/follow_head_subscribe` | ✅ | | +| `Chain/get_head_header` | ✅ | | +| `Chain/get_head_body` | ✅ | | +| `Chain/get_head_storage` | ✅ | | +| `Chain/call_head` | ✅ | | +| `Chain/unpin_head` | ✅ | | +| `Chain/continue_head` | ✅ | | +| `Chain/stop_head_operation` | ✅ | | +| `Chain/get_spec_genesis_hash` | ✅ | | +| `Chain/get_spec_chain_name` | ✅ | | +| `Chain/get_spec_properties` | ✅ | | +| `Chain/broadcast_transaction` | ✅ | | +| `Chain/stop_transaction` | ✅ | | +| `Chat/create_room` | ❌ | createRoom failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/register_bot` | ❌ | registerBot failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/list_subscribe` | ❌ | no elements in sequence | +| `Chat/post_message` | ❌ | postMessage failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/action_subscribe` | ❌ | no elements in sequence | +| `Chat/custom_message_render_subscribe` | ❌ | no elements in sequence | +| `Coin Payment/create_purse` | ❌ | createPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/query_purse` | ❌ | queryPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/rebalance_purse` | ❌ | Subscription interrupted | +| `Coin Payment/delete_purse` | ❌ | Subscription interrupted | +| `Coin Payment/create_receivable` | ❌ | createReceivable failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/create_cheque` | ❌ | createCheque failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/deposit` | ❌ | Subscription interrupted | +| `Coin Payment/refund` | ❌ | Subscription interrupted | +| `Coin Payment/listen_for_payment` | ❌ | Subscription interrupted | +| `Entropy/derive` | ✅ | | +| `Local Storage/read` | ✅ | | +| `Local Storage/write` | ✅ | | +| `Local Storage/clear` | ✅ | | +| `Notifications/send_push_notification` | ✅ | | +| `Notifications/cancel_push_notification` | ✅ | | +| `Payment/balance_subscribe` | ❌ | Subscription interrupted | +| `Payment/top_up` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Payment/request` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Payment/status_subscribe` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Permissions/request_device_permission` | ✅ | | +| `Permissions/request_remote_permission` | ✅ | | +| `Preimage/lookup_subscribe` | ✅ | | +| `Preimage/submit` | ✅ | | +| `Resource Allocation/request` | ✅ | | +| `Signing/create_transaction` | ✅ | | +| `Signing/create_transaction_with_legacy_account` | ✅ | | +| `Signing/sign_raw_with_legacy_account` | ✅ | | +| `Signing/sign_payload_with_legacy_account` | ✅ | | +| `Signing/sign_raw` | ✅ | | +| `Signing/sign_payload` | ✅ | | +| `Statement Store/subscribe` | ✅ | | +| `Statement Store/create_proof` | ✅ | | +| `Statement Store/submit` | ✅ | | +| `Statement Store/create_proof_authorized` | ✅ | | +| `System/handshake` | ✅ | | +| `System/feature_supported` | ✅ | | +| `System/navigate_to` | ✅ | | +| `Theme/subscribe` | ✅ | | diff --git a/explorer/diagnosis-reports/signing-host-cli.md b/explorer/diagnosis-reports/signing-host-cli.md new file mode 100644 index 00000000..bf343c44 --- /dev/null +++ b/explorer/diagnosis-reports/signing-host-cli.md @@ -0,0 +1,68 @@ +## Truapi Signing Host CLI Diagnosis + +| Method | Status | Details | +| --- | --- | --- | +| `Account/connection_status_subscribe` | ✅ | | +| `Account/get_account` | ✅ | | +| `Account/get_account_alias` | ✅ | | +| `Account/create_account_proof` | ✅ | | +| `Account/get_legacy_accounts` | ✅ | | +| `Account/get_user_id` | ✅ | | +| `Account/request_login` | ✅ | | +| `Chain/follow_head_subscribe` | ✅ | | +| `Chain/get_head_header` | ✅ | | +| `Chain/get_head_body` | ✅ | | +| `Chain/get_head_storage` | ✅ | | +| `Chain/call_head` | ✅ | | +| `Chain/unpin_head` | ✅ | | +| `Chain/continue_head` | ✅ | | +| `Chain/stop_head_operation` | ✅ | | +| `Chain/get_spec_genesis_hash` | ✅ | | +| `Chain/get_spec_chain_name` | ✅ | | +| `Chain/get_spec_properties` | ✅ | | +| `Chain/broadcast_transaction` | ✅ | | +| `Chain/stop_transaction` | ✅ | | +| `Chat/create_room` | ❌ | createRoom failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/register_bot` | ❌ | registerBot failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/list_subscribe` | ❌ | no elements in sequence | +| `Chat/post_message` | ❌ | postMessage failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/action_subscribe` | ❌ | no elements in sequence | +| `Chat/custom_message_render_subscribe` | ❌ | no elements in sequence | +| `Coin Payment/create_purse` | ❌ | createPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/query_purse` | ❌ | queryPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/rebalance_purse` | ❌ | Subscription interrupted | +| `Coin Payment/delete_purse` | ❌ | Subscription interrupted | +| `Coin Payment/create_receivable` | ❌ | createReceivable failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/create_cheque` | ❌ | createCheque failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/deposit` | ❌ | Subscription interrupted | +| `Coin Payment/refund` | ❌ | Subscription interrupted | +| `Coin Payment/listen_for_payment` | ❌ | Subscription interrupted | +| `Entropy/derive` | ✅ | | +| `Local Storage/read` | ✅ | | +| `Local Storage/write` | ✅ | | +| `Local Storage/clear` | ✅ | | +| `Notifications/send_push_notification` | ✅ | | +| `Notifications/cancel_push_notification` | ✅ | | +| `Payment/balance_subscribe` | ❌ | Subscription interrupted | +| `Payment/top_up` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Payment/request` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Payment/status_subscribe` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Permissions/request_device_permission` | ✅ | | +| `Permissions/request_remote_permission` | ✅ | | +| `Preimage/lookup_subscribe` | ✅ | | +| `Preimage/submit` | ✅ | | +| `Resource Allocation/request` | ✅ | | +| `Signing/create_transaction` | ✅ | | +| `Signing/create_transaction_with_legacy_account` | ✅ | | +| `Signing/sign_raw_with_legacy_account` | ✅ | | +| `Signing/sign_payload_with_legacy_account` | ✅ | | +| `Signing/sign_raw` | ✅ | | +| `Signing/sign_payload` | ✅ | | +| `Statement Store/subscribe` | ✅ | | +| `Statement Store/create_proof` | ✅ | | +| `Statement Store/submit` | ✅ | | +| `Statement Store/create_proof_authorized` | ✅ | | +| `System/handshake` | ✅ | | +| `System/feature_supported` | ✅ | | +| `System/navigate_to` | ✅ | | +| `Theme/subscribe` | ✅ | | diff --git a/hosts/dotli b/hosts/dotli index 7921ce41..df33e179 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit 7921ce413a4a1661b36c3f5032d91287a8f160bf +Subproject commit df33e17966d65dc8bfa783a1daa5fe00472c7129 diff --git a/playground/CLAUDE.md b/playground/CLAUDE.md index db388e28..82d68b6e 100644 --- a/playground/CLAUDE.md +++ b/playground/CLAUDE.md @@ -7,9 +7,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co An interactive explorer for the TrUAPI, the Host API surface exposed to products running inside the Polkadot Desktop Browser webview. The app must be opened from within a Host environment. It talks to the host over iframe `postMessage` frames or the native webview `window.__HOST_API_PORT__` MessagePort. To develop locally, run `yarn dev` and open the app via `https://dot.li/localhost:3000` inside the Desktop Host. -For host-backed diagnosis/e2e runs, signer-bot settings may live in the -repo-root `.env`; if they are not present in the current worktree environment, -load or copy them from that file before treating signer-bot as unavailable. +For host-backed diagnosis/e2e runs, `make e2e-dotli` builds and launches the +repo's signing-host CLI. An explicitly exported `HOST_CLI_SIGNER_MNEMONIC` is +used when present; otherwise the CLI auto-manages a reusable isolated test +identity. Set `E2E_DOTLI_SIGNING_HOST_BASE_PATH` to use different state while +debugging. ## Commands diff --git a/playground/src/components/MethodView.tsx b/playground/src/components/MethodView.tsx index 9951a3e0..5eee6a15 100644 --- a/playground/src/components/MethodView.tsx +++ b/playground/src/components/MethodView.tsx @@ -9,7 +9,7 @@ import { methodTestId, revealInRail, serviceTestId } from "@/src/lib/rail"; import { services } from "@/src/lib/services"; import type { MethodInfo, ServiceInfo } from "@/src/lib/services"; -const CALL_TIMEOUT_MS = 30_000; +const CALL_TIMEOUT_MS = 45_000; const CARGO_DOC_BASE = process.env.NEXT_PUBLIC_CARGO_DOC_BASE ?? diff --git a/playground/src/lib/example-helpers.ts b/playground/src/lib/example-helpers.ts index 4789d917..47e436cc 100644 --- a/playground/src/lib/example-helpers.ts +++ b/playground/src/lib/example-helpers.ts @@ -119,6 +119,72 @@ export function createAccountIdForDotNsUsername( return new Promise>((resolve) => { let operationId: string | null = null; + let settled = false; + let eventQueue = Promise.resolve(); + const fail = (reason: unknown) => { + if (settled) return; + settled = true; + sub.unsubscribe(); + resolve(err(toError(reason))); + }; + const succeed = (account: HexString) => { + if (settled) return; + settled = true; + sub.unsubscribe(); + resolve(ok(account)); + }; + const handleItem = async (item: RemoteChainHeadFollowItem) => { + switch (item.tag) { + case "Initialized": { + const result = await truapi.chain.getHeadStorage({ + genesisHash: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + followSubscriptionId: sub.subscriptionId, + hash: item.value.finalizedBlockHashes[0], + items: [{ key, queryType: "Value" }], + }); + if (result.isErr()) { + fail(result.error); + return; + } + if (result.value.operation.tag !== "Started") { + fail(new Error("getHeadStorage operation limit reached")); + return; + } + operationId = result.value.operation.value.operationId; + return; + } + case "OperationStorageItems": + if (item.value.operationId === operationId) { + const account = findStorageValue(item.value.items, key); + if (!account) { + fail(`No account owns DotNS username "${dotNsUsername}"`); + return; + } + succeed(account); + } + return; + case "OperationStorageDone": + if (item.value.operationId === operationId) { + fail(`No account owns DotNS username "${dotNsUsername}"`); + } + return; + case "OperationError": + if (item.value.operationId === operationId) { + fail(`getHeadStorage failed: ${item.value.error}`); + } + return; + case "OperationInaccessible": + if (item.value.operationId === operationId) { + fail("getHeadStorage operation inaccessible"); + } + return; + case "Stop": + fail( + "chain head subscription stopped before username lookup finished", + ); + return; + } + }; const sub = truapi.chain .followHeadSubscribe({ request: { @@ -127,78 +193,24 @@ export function createAccountIdForDotNsUsername( }, }) .subscribe({ - next: async (item) => { - const fail = (reason: unknown) => { - sub.unsubscribe(); - resolve(err(toError(reason))); - }; - - try { - switch (item.tag) { - case "Initialized": { - const result = await truapi.chain.getHeadStorage({ - genesisHash: PASEO_NEXT_V2_INDIVIDUALITY.genesis, - followSubscriptionId: sub.subscriptionId, - hash: item.value.finalizedBlockHashes[0], - items: [{ key, queryType: "Value" }], - }); - if (result.isErr()) { - fail(result.error); - return; - } - if (result.value.operation.tag !== "Started") { - fail(new Error("getHeadStorage operation limit reached")); - return; - } - operationId = result.value.operation.value.operationId; - return; - } - case "OperationStorageItems": - if (item.value.operationId === operationId) { - const account = findStorageValue(item.value.items, key); - if (!account) { - fail(`No account owns DotNS username "${dotNsUsername}"`); - return; - } - sub.unsubscribe(); - resolve(ok(account)); - } - return; - case "OperationStorageDone": - if (item.value.operationId === operationId) { - fail(`No account owns DotNS username "${dotNsUsername}"`); - } - return; - case "OperationError": - if (item.value.operationId === operationId) { - fail(`getHeadStorage failed: ${item.value.error}`); - } - return; - case "OperationInaccessible": - if (item.value.operationId === operationId) { - fail("getHeadStorage operation inaccessible"); - } - return; - case "Stop": - fail( - "chain head subscription stopped before username lookup finished", - ); - return; - } - } catch (error) { - sub.unsubscribe(); - resolve(err(toError(error))); - } + next: (item) => { + // RxJS does not await async `next` handlers. Serialize follow + // events so storage items cannot overtake the unary response that + // tells us which operation ID to match. + eventQueue = eventQueue + .then(() => handleItem(item)) + .catch((error) => fail(error)); }, - error: (error) => resolve(err(toError(error))), - complete: () => - resolve( - err( + error: (error) => fail(error), + complete: () => { + eventQueue = eventQueue.then(() => + fail( new Error( "chain head subscription completed before username lookup finished", ), ), - ), + ); + }, }); }); }; diff --git a/playground/tests/e2e/dotli-diagnosis.ts b/playground/tests/e2e/dotli-diagnosis.ts index 5eb7c7a6..a7aa1387 100644 --- a/playground/tests/e2e/dotli-diagnosis.ts +++ b/playground/tests/e2e/dotli-diagnosis.ts @@ -13,12 +13,12 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { extractQrPayload } from "./dotli/helpers/extract-qr-payload"; import { - disconnect, - generateUsername, - health, - pair, - type PairResult, -} from "./dotli/helpers/signer-bot"; + formatSigningHostExit, + startSigningHostPair, + stopSigningHost, + type SigningHostCliConfig, + type SigningHostCliProcess, +} from "./dotli/helpers/signing-host-cli"; const currentDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(currentDir, "../../.."); @@ -36,18 +36,55 @@ const hostNetworks = const headless = process.env.HEADED === "1" ? false : true; const slowMo = process.env.SLOWMO ? Number(process.env.SLOWMO) : 0; const smokeOnly = process.env.E2E_DOTLI_SMOKE === "1"; -const defaultBotBase = "https://signing-bot-dev.novasama-tech.org/"; -const defaultBotNetwork = "paseo-next-v2"; const loginUserBadgeTimeoutMs = Number( - process.env.E2E_DOTLI_LOGIN_TIMEOUT_MS ?? "240000", + process.env.E2E_DOTLI_LOGIN_TIMEOUT_MS ?? "600000", ); - -const botToken = readEnv("SIGNER_BOT_SVC_TOKEN"); -const botBase = process.env.SIGNER_BOT_BASE_URL ?? defaultBotBase; -const botNetwork = process.env.SIGNER_BOT_NETWORK ?? defaultBotNetwork; -const botUsername = process.env.SIGNER_BOT_USERNAME; +const signingHostNetwork = + process.env.E2E_DOTLI_NETWORK ?? "paseo-next-v2"; +const signingHostConfig: SigningHostCliConfig = { + binary: resolve( + process.env.E2E_DOTLI_SIGNING_HOST_BIN ?? + resolve(repoRoot, "target/debug/truapi-host"), + ), + cwd: repoRoot, + basePath: resolve( + process.env.E2E_DOTLI_SIGNING_HOST_BASE_PATH ?? + resolve(repoRoot, ".e2e-dotli/signing-host"), + ), + network: signingHostNetwork, + liteUsernamePrefix: process.env.HOST_CLI_SIGNER_MNEMONIC?.trim() + ? undefined + : "dotlitest", +}; +const expectedHostGaps = [ + "Account/create_account_proof", + "Chat/create_room", + "Chat/register_bot", + "Chat/list_subscribe", + "Chat/post_message", + "Chat/action_subscribe", + "Chat/custom_message_render_subscribe", + "Coin Payment/create_purse", + "Coin Payment/query_purse", + "Coin Payment/rebalance_purse", + "Coin Payment/delete_purse", + "Coin Payment/create_receivable", + "Coin Payment/create_cheque", + "Coin Payment/deposit", + "Coin Payment/refund", + "Coin Payment/listen_for_payment", + "Payment/balance_subscribe", + "Payment/top_up", + "Payment/request", + "Payment/status_subscribe", + // These generated examples ask for truapi-playground.dot while this E2E + // loads the product as localhost:3000. Legacy payload/transaction methods + // intentionally accept only the current product's slot-zero key. + "Signing/create_transaction_with_legacy_account", + "Signing/sign_payload_with_legacy_account", +]; const allowedFailures = new Set( - (process.env.E2E_DOTLI_ALLOWED_FAILURES ?? "") + (process.env.E2E_DOTLI_ALLOWED_FAILURES ?? expectedHostGaps.join(",")) .split(",") .map((id) => id.trim()) .filter(Boolean), @@ -57,8 +94,14 @@ const serverProcesses: ChildProcess[] = []; const pageErrors: string[] = []; const browserLogs: string[] = []; const screenshots: string[] = []; +const signingHostLogs: string[] = []; let screenshotSeq = 0; +interface SignedInSession { + process: SigningHostCliProcess; + username: string; +} + type PlaygroundE2E = { waitForConnectionStatus?: ( status: "disconnected" | "connecting" | "connected", @@ -75,30 +118,6 @@ declare global { } } -function readEnv(name: string): string | undefined { - const value = process.env[name]; - if (!value && !smokeOnly) { - throw new Error( - `${name} is required for fully automated e2e-dotli. ` + - "This suite pairs through signer-bot; without it, a human phone scan is required.", - ); - } - return value; -} - -function requireBotEnv(): { - token: string; - base: string; - network: string; -} { - if (botToken === undefined) { - throw new Error( - "SIGNER_BOT_SVC_TOKEN is required outside E2E_DOTLI_SMOKE=1.", - ); - } - return { token: botToken, base: botBase, network: botNetwork }; -} - function startServer( label: string, command: string, @@ -259,38 +278,59 @@ async function openLoginQr(page: Page): Promise { return await extractQrPayload(page, "#auth-modal-qr canvas"); } -async function signInWithBot( - page: Page, - username = botUsername ?? generateUsername(), -): Promise { - const { token, base, network } = requireBotEnv(); +async function signInWithSigningHost(page: Page): Promise { const handshake = await openLoginQr(page); - console.log(`[e2e-dotli] pairing signer-bot user ${username}`); - const result = await pair(base, token, { - handshake, - username, - network, - }); - try { - await waitForSignedIn(page, result); - await captureStep(page, "signed-in"); - } catch (error) { - await disconnect(base, token, result.sessionId); - throw error; + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + console.log( + `[e2e-dotli] starting signing-host CLI pairing responder (${attempt}/${maxAttempts})`, + ); + const signingHost = startSigningHostPair(signingHostConfig, handshake); + try { + const username = await waitForSignedIn(page, signingHost); + await captureStep(page, "signed-in"); + return { process: signingHost, username }; + } catch (error) { + signingHostLogs.push(signingHost.output()); + await stopSigningHost(signingHost); + if ( + attempt === maxAttempts || + !isRetryableSigningHostPairError(error) + ) { + throw error; + } + console.warn( + `[e2e-dotli] transient signing-host pairing failure; retrying in 5s (${attempt}/${maxAttempts})`, + ); + await page.waitForTimeout(5_000); + } } - return result; + throw new Error("signing-host pairing retry exhausted"); +} + +function isRetryableSigningHostPairError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes("Invalid Transaction") || + message.includes("temporarily banned") || + message.includes("timed out waiting for author_submitAndWatchExtrinsic") + ); } -async function waitForSignedIn(page: Page, result: PairResult): Promise { +async function waitForSignedIn( + page: Page, + signingHost: SigningHostCliProcess, +): Promise { try { const existingFailure = await latestLoginFailureReason(page); if (existingFailure !== null) { throw new Error(`Login failed: ${existingFailure}`); } - await Promise.race([ + const outcome = await Promise.race([ page .locator("#auth-button .user-badge") - .waitFor({ state: "visible", timeout: loginUserBadgeTimeoutMs }), + .waitFor({ state: "visible", timeout: loginUserBadgeTimeoutMs }) + .then(() => ({ tag: "signed-in" as const })), page.evaluate( () => new Promise((_, reject) => { @@ -309,11 +349,25 @@ async function waitForSignedIn(page: Page, result: PairResult): Promise { window.addEventListener("dotli:truapi-auth-state", listener); }), ), + signingHost.completed.then((result) => ({ + tag: "signing-host-exit" as const, + result, + })), ]); + if (outcome.tag === "signing-host-exit") { + throw new Error(formatSigningHostExit(outcome.result, signingHost.output())); + } + const username = ( + await page.locator("#user-popover-username").innerText() + ).trim(); + if (username.length === 0) { + throw new Error("signed-in host did not expose the account username"); + } + return username; } catch (error) { await writeAuthDebug(page, { - stage: "post-pair-user-badge", - pairResult: redactedPairResult(result), + stage: "post-signing-host-pair-user-badge", + signingHostOutput: signingHost.output(), }); throw error; } @@ -334,19 +388,6 @@ async function latestLoginFailureReason(page: Page): Promise { }); } -function redactedPairResult(result: PairResult): PairResult { - return { - sessionId: result.sessionId, - user: { - username: result.user.username, - network: result.user.network, - address: result.user.address, - publicKeyHex: result.user.publicKeyHex, - attested: result.user.attested, - }, - }; -} - async function writeAuthDebug( page: Page, extra: Record, @@ -512,18 +553,22 @@ async function waitForPlaygroundE2EHook(page: Page): Promise { async function assertHostSignOutAndReconnect( page: Page, - previous: PairResult, -): Promise { + previous: SignedInSession, +): Promise { console.log("[e2e-dotli] validating host sign-out"); await signOutIfNeeded(page); await page .locator("#auth-button .user-badge") .waitFor({ state: "hidden", timeout: 20_000 }); + await stopSigningHost(previous.process); + signingHostLogs.push(previous.process.output()); await captureStep(page, "signed-out"); console.log("[e2e-dotli] validating signer reconnect"); - const reconnected = await signInWithBot(page, previous.user.username); - if (reconnected.user.publicKeyHex !== previous.user.publicKeyHex) { + const reconnected = await signInWithSigningHost(page); + if (reconnected.username !== previous.username) { + signingHostLogs.push(reconnected.process.output()); + await stopSigningHost(reconnected.process); throw new Error("signer reconnect returned a different account"); } return reconnected; @@ -648,11 +693,13 @@ async function main(): Promise { ); } if (!smokeOnly) { - const { base, network } = requireBotEnv(); - console.log(`[e2e-dotli] bot=${base} network=${network}`); - const probe = await health(base); - if (!probe.ok) { - throw new Error(`signer-bot unavailable: ${probe.error ?? probe.status}`); + console.log( + `[e2e-dotli] signing-host=${signingHostConfig.binary} network=${signingHostNetwork}`, + ); + if (!existsSync(signingHostConfig.binary)) { + throw new Error( + `signing-host CLI not found at ${signingHostConfig.binary}; run cargo build -p truapi-host-cli`, + ); } } else { console.log("[e2e-dotli] smoke mode: validating local stack and QR only"); @@ -660,7 +707,7 @@ async function main(): Promise { let browser: Awaited> | undefined; let context: BrowserContext | undefined; - let pairResult: PairResult | undefined; + let signedInSession: SignedInSession | undefined; let page: Page | undefined; try { await startLocalStack(); @@ -729,7 +776,7 @@ async function main(): Promise { const params = new URLSearchParams({ chainBackend: "rpc-gateway", e2e: String(Date.now()), - network: botNetwork, + network: signingHostNetwork, }); const url = `http://localhost:${hostPort}/localhost:${playgroundPort}?${params.toString()}`; await page.goto(url, { timeout: 60_000, waitUntil: "domcontentloaded" }); @@ -760,14 +807,17 @@ async function main(): Promise { return; } await waitForPlaygroundE2EHook(page); - pairResult = await signInWithBot(page); + signedInSession = await signInWithSigningHost(page); + signedInSession = await assertHostSignOutAndReconnect( + page, + signedInSession, + ); const stopClicker = startHostModalClicker(page); try { const { summary, report, copyReportClicked, failedMethods } = await runDiagnosis(page); const reportPath = resolve(outputDir, "diagnosis-report.md"); writeFileSync(reportPath, report); - pairResult = await assertHostSignOutAndReconnect(page, pairResult); const allowedFailedMethods = failedMethods.filter((method) => allowedFailures.has(method), ); @@ -787,7 +837,18 @@ async function main(): Promise { reportPath, copyReportClicked, screenshots, - user: redactedPairResult(pairResult).user, + user: { + username: signedInSession.username, + network: signingHostNetwork, + }, + signingHost: { + binary: signingHostConfig.binary, + basePath: signingHostConfig.basePath, + output: [ + ...signingHostLogs, + signedInSession.process.output(), + ], + }, sessionLifecycle: "host-sign-out-reconnect", pageErrors, browserLogs, @@ -821,9 +882,9 @@ async function main(): Promise { } throw error; } finally { - if (pairResult) { - const { token, base } = requireBotEnv(); - await disconnect(base, token, pairResult.sessionId); + if (signedInSession) { + signingHostLogs.push(signedInSession.process.output()); + await stopSigningHost(signedInSession.process); } await context?.close().catch(() => {}); await browser?.close().catch(() => {}); diff --git a/playground/tests/e2e/dotli/helpers/signer-bot.ts b/playground/tests/e2e/dotli/helpers/signer-bot.ts deleted file mode 100644 index 7560a4b7..00000000 --- a/playground/tests/e2e/dotli/helpers/signer-bot.ts +++ /dev/null @@ -1,278 +0,0 @@ -// Copyright 2026 Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: AGPL-3.0-only - -import { setTimeout as sleep } from "node:timers/promises"; - -const TRANSIENT = new Set([502, 503, 504]); - -// Per-attempt request timeout. First-time pair can include user creation and -// People-chain attestation, so give the one-shot handshake room to finish -// instead of aborting and retrying the same QR payload. -const PAIR_REQUEST_TIMEOUT_MS = Number( - process.env.SIGNER_BOT_PAIR_TIMEOUT_MS ?? "120000", -); -const HEALTH_REQUEST_TIMEOUT_MS = 5_000; - -function shellQuote(value: string): string { - if (value.length === 0) return "''"; - return `'${value.replace(/'/g, "'\\''")}'`; -} - -function buildPairCurl(url: string, body: unknown): string { - return [ - `curl -sS -X POST ${shellQuote(url)}`, - '-H "Authorization: Bearer ${SIGNER_BOT_SVC_TOKEN}"', - "-H 'Content-Type: application/json'", - "-H 'Accept: application/json'", - `--data-raw ${shellQuote(JSON.stringify(body))}`, - ].join(" \\\n "); -} - -const SECRET_FIELD_NAMES = new Set([ - "mnemonic", - "privatekey", - "secret", - "secretphrase", - "secretseed", - "secreturi", - "seed", - "suri", -]); - -const QUOTED_JSON_FIELD = /"((?:\\.|[^"\\])+)"\s*:\s*"((?:\\.|[^"\\])*)"/g; - -function normalizeSecretFieldName(name: string): string { - return name.replace(/[-_]/g, "").toLowerCase(); -} - -function isSecretFieldName(name: string): boolean { - return SECRET_FIELD_NAMES.has(normalizeSecretFieldName(name)); -} - -export function redactSignerBotResponse(text: string): string { - try { - return JSON.stringify(JSON.parse(text), (key, value) => - isSecretFieldName(key) ? "[redacted]" : value, - ); - } catch { - return text.replace(QUOTED_JSON_FIELD, (field, name: string) => - isSecretFieldName(name) ? `"${name}":"[redacted]"` : field, - ); - } -} - -async function fetchWithTimeout( - url: string, - init: RequestInit, - timeoutMs: number, -): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(url, { ...init, signal: controller.signal }); - } finally { - clearTimeout(timer); - } -} - -interface TextResponse { - response: Response; - text: string; -} - -async function fetchTextWithTimeout( - url: string, - init: RequestInit, - timeoutMs: number, -): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetch(url, { ...init, signal: controller.signal }); - const text = await response.text(); - return { response, text }; - } finally { - clearTimeout(timer); - } -} - -async function fetchTextRetry( - url: string, - init: RequestInit, - attempts = 4, - timeoutMs = PAIR_REQUEST_TIMEOUT_MS, -): Promise { - let last: unknown = null; - for (let i = 1; i <= attempts; i++) { - try { - const result = await fetchTextWithTimeout(url, init, timeoutMs); - const { response, text } = result; - if (response.ok || !TRANSIENT.has(response.status) || i === attempts) { - return result; - } - console.warn( - `[bot] ${init.method ?? "GET"} ${url} response ${response.status} ${response.statusText} (attempt ${i}/${attempts}): ${redactSignerBotResponse(text)}`, - ); - } catch (e) { - last = e; - if (i === attempts) throw e; - console.warn( - `[bot] ${init.method ?? "GET"} ${url} threw "${(e as Error).message}" (attempt ${i}/${attempts})`, - ); - } - await sleep(1_000 * 2 ** (i - 1)); - } - throw last ?? new Error("fetchTextRetry exhausted"); -} - -/** - * Generate a per-run username for the Nova signing bot. - * - * Each test run gets its own throwaway user so network state (allowances, - * permissions, derived product accounts) doesn't leak between PRs. The - * format is `dotlitests` followed by 6 lowercase letters, a namespace of - * roughly 3·10^8 with no collisions in practice. It stays inside the bot's - * strictest username regex (^[a-z]+$) so it works for both regular - * `username` and `liteUsername` fields if we ever want one. - */ -export function generateUsername(): string { - const alphabet = "abcdefghijklmnopqrstuvwxyz"; - let suffix = ""; - for (let i = 0; i < 6; i++) { - suffix += alphabet[Math.floor(Math.random() * alphabet.length)]; - } - return `dotlitests${suffix}`; -} - -export interface PairResult { - sessionId: string; - user: { - username: string; - network: string; - address: string; - publicKeyHex: string; - attested?: boolean; - }; -} - -/** - * Pair the bot with a dot.li session via the QR-derived handshake deeplink. - * - * The Nova bot's `/api/pair` is one-shot: given a handshake, it - * (a) creates the user if `username` is new, (b) attests the account on - * People chain so it has Statement Store allowance, (c) completes the - * SSO handshake, (d) starts auto-signing future SignRequests for that - * session. No separate provisioning / poll step needed. - * - * `network` should match dot.li's default network (`paseo-next-v2` at time - * of writing). The bot's `/api/networks` endpoint lists supported IDs. - */ -export async function pair( - base: string, - svcToken: string, - args: { handshake: string; username: string; network: string }, -): Promise { - const url = `${base.replace(/\/$/, "")}/api/pair`; - console.log(`[bot] /api/pair curl:\n${buildPairCurl(url, args)}`); - const init: RequestInit = { - method: "POST", - headers: { - Authorization: `Bearer ${svcToken}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(args), - }; - let result: TextResponse; - try { - result = await fetchTextRetry(url, init); - } catch (e) { - console.error( - `[bot] /api/pair response unavailable: ${(e as Error).message}`, - ); - throw e; - } - const { response: r, text } = result; - console.log( - `[bot] /api/pair raw response ${r.status} ${r.statusText}: ${redactSignerBotResponse(text) || ""}`, - ); - if (!r.ok) { - throw new Error( - `pair ${r.status}: ${redactSignerBotResponse(text) || ""}`, - ); - } - return JSON.parse(text) as PairResult; -} - -/** - * Tear down a bot session at the end of a test worker. - * - * Best-effort. Failure to disconnect is non-fatal, the bot times - * sessions out anyway. - */ -export async function disconnect( - base: string, - svcToken: string, - sessionId: string, -): Promise { - await disconnectStrict(base, svcToken, sessionId).catch(() => {}); -} - -export async function disconnectStrict( - base: string, - svcToken: string, - sessionId: string, -): Promise { - const response = await fetchWithTimeout( - `${base.replace(/\/$/, "")}/api/disconnect`, - { - method: "POST", - headers: { - Authorization: `Bearer ${svcToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ sessionId }), - }, - PAIR_REQUEST_TIMEOUT_MS, - ); - if (!response.ok) { - throw new Error(`disconnect ${response.status}: ${await response.text()}`); - } - const body = (await response.json()) as { disconnected?: boolean }; - if (body.disconnected !== true) { - throw new Error(`disconnect did not find session ${sessionId}`); - } -} - -export interface BotHealth { - ok: boolean; - status?: string; - uptime?: number; - error?: string; -} - -/** - * Lightweight bot reachability probe. Used by globalSetup to fail-fast - * when the bot is unreachable, distinguishing "Nova is down" from - * "dot.li is broken" in CI output. No auth required. - */ -export async function health(base: string): Promise { - try { - const r = await fetchWithTimeout( - `${base.replace(/\/$/, "")}/api/health`, - { method: "GET", headers: { Accept: "application/json" } }, - HEALTH_REQUEST_TIMEOUT_MS, - ); - if (!r.ok) { - return { ok: false, error: `${r.status} ${r.statusText}` }; - } - const body = (await r.json()) as { status?: string; uptime?: number }; - return { - ok: body.status === "ok", - status: body.status, - uptime: body.uptime, - }; - } catch (e) { - return { ok: false, error: (e as Error).message }; - } -} diff --git a/playground/tests/e2e/dotli/helpers/signing-host-cli.test.ts b/playground/tests/e2e/dotli/helpers/signing-host-cli.test.ts new file mode 100644 index 00000000..03f9f891 --- /dev/null +++ b/playground/tests/e2e/dotli/helpers/signing-host-cli.test.ts @@ -0,0 +1,69 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + formatSigningHostExit, + sanitizeSigningHostOutput, + signingHostPairArgs, + type SigningHostCliConfig, +} from "./signing-host-cli"; + +const config: SigningHostCliConfig = { + binary: "/repo/target/debug/truapi-host", + cwd: "/repo", + basePath: "/repo/.e2e-dotli/signing-host", + network: "paseo-next-v2", + liteUsernamePrefix: "dotlitest", +}; + +describe("signing-host CLI pairing", () => { + it("builds an isolated auto-accept pair command", () => { + assert.deepEqual( + signingHostPairArgs(config, "polkadotapp://pair?handshake=01"), + [ + "signing-host", + "--network", + "paseo-next-v2", + "--base-path", + "/repo/.e2e-dotli/signing-host", + "--frame-listen", + "127.0.0.1:0", + "--auto-accept", + "--lite-username-prefix", + "dotlitest", + "exec", + "/pair polkadotapp://pair?handshake=01", + ], + ); + }); + + it("omits managed-account flags for an explicit mnemonic", () => { + assert.ok( + !signingHostPairArgs( + { ...config, liteUsernamePrefix: undefined }, + "polkadotapp://pair?handshake=01", + ).includes("--lite-username-prefix"), + ); + }); + + it("redacts pairing deeplinks from logs and failures", () => { + const output = + "pairing polkadotapp://pair?handshake=secret\nrequest accepted"; + assert.equal( + sanitizeSigningHostOutput(output), + "pairing \nrequest accepted", + ); + assert.ok( + formatSigningHostExit({ code: 1, signal: null }, output).includes( + "pairing ", + ), + ); + assert.ok( + !formatSigningHostExit({ code: 1, signal: null }, output).includes( + "secret", + ), + ); + }); +}); diff --git a/playground/tests/e2e/dotli/helpers/signing-host-cli.ts b/playground/tests/e2e/dotli/helpers/signing-host-cli.ts new file mode 100644 index 00000000..aef8b144 --- /dev/null +++ b/playground/tests/e2e/dotli/helpers/signing-host-cli.ts @@ -0,0 +1,160 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { spawn, type ChildProcess } from "node:child_process"; +import type { Readable, Writable } from "node:stream"; + +const MAX_CAPTURED_OUTPUT_BYTES = 256 * 1024; +const PAIRING_DEEPLINK = /polkadotapp:\/\/pair\?[^\s'"]+/g; + +export interface SigningHostCliConfig { + binary: string; + cwd: string; + basePath: string; + network: string; + liteUsernamePrefix?: string; +} + +export interface SigningHostExit { + code: number | null; + signal: NodeJS.Signals | null; + error?: string; +} + +export interface SigningHostCliProcess { + child: ChildProcess; + completed: Promise; + output: () => string; +} + +export function signingHostPairArgs( + config: SigningHostCliConfig, + deeplink: string, +): string[] { + const args = [ + "signing-host", + "--network", + config.network, + "--base-path", + config.basePath, + "--frame-listen", + "127.0.0.1:0", + "--auto-accept", + ]; + if (config.liteUsernamePrefix !== undefined) { + args.push("--lite-username-prefix", config.liteUsernamePrefix); + } + args.push("exec", `/pair ${deeplink}`); + return args; +} + +export function sanitizeSigningHostOutput(text: string): string { + return text.replace(PAIRING_DEEPLINK, ""); +} + +export function startSigningHostPair( + config: SigningHostCliConfig, + deeplink: string, +): SigningHostCliProcess { + const child = spawn( + config.binary, + signingHostPairArgs(config, deeplink), + { + cwd: config.cwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let captured = ""; + const append = (line: string): void => { + captured = `${captured}${line}\n`; + if (Buffer.byteLength(captured) > MAX_CAPTURED_OUTPUT_BYTES) { + captured = captured.slice(-MAX_CAPTURED_OUTPUT_BYTES); + } + }; + pipeLines(child.stdout, process.stdout, "[signing-host]", append); + pipeLines(child.stderr, process.stderr, "[signing-host]", append); + + const completed = new Promise((resolve) => { + child.once("error", (error) => { + resolve({ code: null, signal: null, error: error.message }); + }); + child.once("exit", (code, signal) => { + resolve({ code, signal }); + }); + }); + + return { + child, + completed, + output: () => captured.trimEnd(), + }; +} + +export async function stopSigningHost( + process: SigningHostCliProcess, +): Promise { + if (process.child.exitCode !== null || process.child.signalCode !== null) { + return; + } + process.child.kill("SIGTERM"); + const stopped = await Promise.race([ + process.completed.then(() => true), + new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), 5_000); + timer.unref(); + }), + ]); + if (!stopped && process.child.exitCode === null) { + process.child.kill("SIGKILL"); + await process.completed; + } +} + +export function formatSigningHostExit( + result: SigningHostExit, + output: string, +): string { + const status = + result.error ?? + (result.code !== null + ? `exit code ${result.code}` + : `signal ${result.signal ?? "unknown"}`); + const detail = sanitizeSigningHostOutput(output).trim(); + return detail.length > 0 + ? `signing-host stopped before login (${status}):\n${detail}` + : `signing-host stopped before login (${status})`; +} + +function pipeLines( + stream: Readable | null, + destination: Writable, + prefix: string, + append: (line: string) => void, +): void { + if (stream === null) { + return; + } + stream.setEncoding("utf8"); + let buffered = ""; + const emit = (line: string): void => { + const sanitized = sanitizeSigningHostOutput(line); + append(sanitized); + destination.write(`${prefix} ${sanitized}\n`); + }; + stream.on("data", (chunk: string) => { + buffered += chunk; + const lines = buffered.split(/\r?\n/); + buffered = lines.pop() ?? ""; + for (const line of lines) { + if (line.length > 0) { + emit(line); + } + } + }); + stream.on("end", () => { + if (buffered.length > 0) { + emit(buffered); + } + }); +} diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index aaf409d2..a2664097 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -786,7 +786,8 @@ export interface PreimageHost { * 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. + * implementations may treat `key` as opaque or decode it with + * `ProductStorageKey` when their physical storage is separated by product. */ export interface ProductStorage { /** diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml new file mode 100644 index 00000000..9c2bcc81 --- /dev/null +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "truapi-host-cli" +version = "0.1.0" +edition.workspace = true +description = "Headless TrUAPI hosts: a signing-host companion and a pairing host that pair over the real People-chain statement store, for end-to-end testing without an external signer service" +license = "MIT" + +[[bin]] +name = "truapi-host" +path = "src/main.rs" + +[lints.rust] +unsafe_code = "forbid" + +[dependencies] +truapi = { path = "../truapi" } +truapi-platform = { path = "../truapi-platform" } +truapi-server = { path = "../truapi-server" } +anyhow = "1" +arboard = { version = "3.6.1", default-features = false } +async-trait = "0.1" +bip39 = { version = "2", features = ["rand"] } +clap = { version = "4", features = ["derive", "env"] } +crossterm = { version = "0.29", features = ["event-stream"] } +futures = "0.3" +futures-util = "0.3" +fs2 = "0.4" +hex = "0.4" +parity-scale-codec = { version = "3", features = ["derive"] } +ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +rustls = { version = "0.23", default-features = false, features = ["ring"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +shlex = "1" +subxt-rpcs = { version = "0.50.1", default-features = false, features = ["jsonrpsee", "native"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "io-std", "net", "time", "signal", "process"] } +tokio-stream = { version = "0.1", features = ["sync"] } +tokio-tungstenite = { version = "0.24", features = ["connect", "rustls-tls-webpki-roots"] } +# TLS is compiled in for required People/Bulletin host traffic and optional +# `Chain/*` playground methods. Product live-chain routing is opt-in +# (`E2E_LIVE_CHAIN=1`); required host routes remain enabled. See src/chain.rs. +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +unicode-width = "0.2" + +[dev-dependencies] +tempfile = "3" diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md new file mode 100644 index 00000000..9db0de12 --- /dev/null +++ b/rust/crates/truapi-host-cli/README.md @@ -0,0 +1,387 @@ +# truapi-host-cli + +Headless TrUAPI hosts for local end-to-end testing, built on `truapi-server`. +They replace the external signing-bot service: two CLI processes take the two +host-spec §B roles and pair over the **real People-chain statement store** (the +same node an iOS/web client uses), so tests run against a real signer with no +Novasama-operated dependency. + +See [SPEC.md](SPEC.md) for the complete as-built v0.1 behavior and engineering +contract. + +Either host can be driven by a **product script** you write: a JS/TS file that +receives a global `truapi` (the `@parity/truapi` client, scoped to a product id) +and calls it like any product would. With `--script`, the CLI runs the script +and exits with its status. Without `--script`, both roles open a full-screen +terminal UI when stdin and stdout are TTYs. + +One binary, `truapi-host`: + +| Command | Role | +| --- | --- | +| `pairing-host` | Seedless host: serves product frames, emits pairing deeplinks, and can run product scripts. | +| `signing-host` | Wallet-local host: owns signer identity, can run product scripts, accepts pairing deeplinks, registers statement allowance on-chain, signs. | +| `identity-check` | Probe which derivation of a mnemonic carries a registered username. | +| `alloc-check` | Diagnose (or `--submit`) on-chain statement-store allowance: ring membership, chosen slot, and the `set_statement_store_account` extrinsic. | + +The repository's `make e2e-dotli` target builds this binary and runs the +dotli/playground Diagnosis suite with a non-interactive signing-host responder. +It verifies the initial pairing, remote signing, host sign-out, and +same-account reconnect without the external signer-bot service. + +## Quick start + +```bash +make headless install # build dependencies and install truapi-host once +truapi-host signing-host +``` + +The signing host opens an interactive terminal where you can paste a pairing +link, type `/pair `, run `/script`, or use `/help` to discover the +available commands. It uses `--mnemonic` / `HOST_CLI_SIGNER_MNEMONIC` if set. +Otherwise it auto-selects or creates a stored account under `--base-path` (default +`$XDG_STATE_HOME/truapi-host` or `~/.local/state/truapi-host`), attests it +through the identity backend, waits for ring readiness, and rotates when the +current account exhausts Statement Store slots. + +### Interactive terminal UI + +In a TTY, both hosts open the same scrollable transcript above a single command +bar. Host lifecycle events, tracing logs, every incoming SSO request, script +stdout/stderr, commands, and approval prompts all use that transcript, so +background output cannot overwrite input. On `signing-host`, `--deeplink URL` +opens the UI and starts the pairing response after initialization. + +Commands always start with `/`: + +| Command | Result | +| --- | --- | +| `/pair ` | Validate and answer a `polkadotapp://pair?...` deeplink (signing host). | +| `/script` | Reopen the session's last TypeScript scratch script (or create one), then run it. | +| `/script ` | Remember and run an existing JS/TS product script through the public frame endpoint. | +| `/login` | Start pairing for the selected product and copy its deeplink to the clipboard. | +| `/logout` | Disconnect the pairing host and discard its old pairing keypair. | +| `/log ` | Change tracing to `error`, `warn`, `info`, `debug`, or `trace`. | +| `/product` | Show the currently selected product. | +| `/product ` | Switch the product used by future scripts and frame connections. | +| `/session` | Show the current session name, path, and user id (signing host). | +| `/session ` | Switch to or create an isolated signing-host session. | +| `/session --list` | List user sessions for the current network. | +| `/help` | Show commands and keyboard shortcuts. | +| `/clear` | Clear the visible transcript. | +| `/copy` | Copy the retained transcript to the system clipboard. | +| `/quit` | Shut down cleanly. | + +Typing `/` opens autocomplete. Up/Down selects a completion; with the menu +closed it navigates process-local command history. Tab inserts a completion, +and `/script` completes filesystem paths. Ctrl-U/Ctrl-D scroll by half a +viewport, End restores auto-follow, Esc closes autocomplete, and Ctrl-C clears +input, cancels a running command, or exits when idle. Deeplinks are deliberately +not persisted in history across processes. + +On `pairing-host`, `/logout` cancels an in-flight pairing, disconnects the +current signing host, and removes the old pairing identity. The next product +login request or operator `/login` generates a new keypair and emits a fresh +link that can be answered by another signing host. `/login` uses the current +`/product` selection, copies the generated deeplink to the system clipboard, +and remains interactive while the TUI renders pairing progress. A clipboard +failure is reported without cancelling pairing. Logout does not clear product +storage, scripts, or the selected product. + +Both `pairing-host` and `signing-host` use the same interactive UI and command +bar. It uses a quiet, command-centered transcript: submitted +commands title full-width dividers, script stdout keeps the terminal's normal +foreground, stderr has a small error gutter, and lifecycle work updates +sentence-case status rows in place. A compact +`TrUAPI host · 👤 · 🌐 · 📦 ` status sits +below the writing bar. Long product names are ellipsized, while session and log +level stay out of that bar. A borderless, subtly backgrounded composer anchors +autocomplete and the `›` prompt while keeping the native cursor after the +input. When the input is empty, command guidance appears there as a placeholder +instead of occupying status space. Set `NO_COLOR=1` to remove semantic colors +and the surface fill without losing spacing, status symbols, or wording. + +Non-interactive `--script` and `exec` runs use the same sentence-case event +copy and status symbols without the full-screen chrome. This keeps captured +logs readable while pairing URLs remain directly extractable by automation. +`/copy` copies readable transcript text without UI chrome or complete pairing +links. Captured script output is plain text: Chalk sees piped stdout, and the +host strips terminal control sequences before adding child output to the +transcript. Raw ANSI styling such as bold is therefore not rendered in the +full-screen UI. + +Bare `/script` reopens the last script recorded for the active session, +including a path previously selected with `/script `. If that file is +missing or the session has no script yet, it creates a durable Bun TypeScript +file under the active host state's `scripts/` directory. Its starter imports +`chalk` to demonstrate that scripts can import npm packages directly and let +Bun install missing dependencies automatically, then calls +`truapi.account.getUserId()`. +The TUI temporarily yields the terminal to `$VISUAL`, then `$EDITOR`, or +`vi` when neither is set. After the editor exits successfully, the TUI is +restored and the saved script runs through the public frame endpoint. Editor +settings containing arguments, such as `EDITOR='code --wait'`, are supported. + +Managed sessions isolate signer accounts, product/core storage, and permissions. +Once a signer identity is known, its public session name is the Lite username +and its files live under +`//_signing_host`. Provisional and legacy named +sessions are promoted to that user-owned root, so an old name such as `pgtest` +does not remain the durable namespace. The selected username is remembered per +network but is not repeated in the status bar as a separate session field. +`default` remains only as a compatibility/bootstrap location until a username +is resolved. It is hidden from session completion and listing and cannot be +selected with `/session default`. User session names contain lowercase ASCII +letters, digits, `.`, `_`, or `-`; they cannot be paths. Switching prepares the +target while the old session remains active, then stops its pairing responder +and resets product WebSocket connections so clients reconnect against the new +runtime. +New auto-managed accounts use the session name as their Lite username prefix; +characters other than lowercase letters are omitted. For example, session +`pgtest` creates usernames beginning with `pgtest`. An explicit +`--lite-username-prefix` takes precedence, and `default` retains the historical +`headless` prefix. +The selected username and last script reference are cached in `session.json` +inside the displayed session path. Scratch scripts use a portable filename; +explicit scripts use an absolute path. On restart, an +already-provisioned local signer is activated from disk without an +identity-backend or ring-membership round trip, and bare `/script` restores that +session's editor context. A session with no signer yet reports +`` and the transcript prompts the user to run +`/session `. Inspecting with bare `/session` never starts network +onboarding; naming a different session creates and connects its user. + +Select or create a session at startup with: + +```bash +truapi-host signing-host --session alice +``` + +`--session` cannot be combined with `--account` or `--mnemonic`. A host +started with an explicit mnemonic reports an `ephemeral` session and does not +allow runtime switching. + +Only one operational command runs at once, but SSO traffic and approvals keep +flowing while it runs. Without a TTY, use one-shot `exec` mode (parent options +come first): + +```bash +truapi-host signing-host exec '/session' +truapi-host signing-host --auto-accept exec '/script ./js/scripts/ring-vrf-smoke.ts' +truapi-host signing-host exec '/pair polkadotapp://pair?handshake=...' +``` + +`exec` does not enable raw mode or emit terminal controls. Command results go +to stdout, diagnostics go to stderr, and the process exits when the command +finishes. Starting `signing-host` without `--script` or `exec` while either +stdin or stdout is not a TTY is an invocation error. The existing `--script` +one-shot mode remains supported. + +## Writing a product script + +A product script is top-level JavaScript or TypeScript (an ES module) run by +Bun. It can import npm dependencies directly; Bun installs missing packages +automatically. The runner injects three globals before running it: + +- **`truapi`** — the `@parity/truapi` client connected to the pairing host and + scoped to the host's `--product-id`. Call `truapi.account.requestLogin(...)`, + `truapi.signing.signRaw(...)`, `truapi.localStorage.write(...)`, etc. +- **`host`** — just `host.productId` and `host.productAccount(index?)`. That is + all it does: it keeps product accounts in sync with the host's `--product-id` + (hardcoding a mismatched id fails signing with `PermissionDenied`). Use + `console.log` and `throw` for everything else. +- **`assert`** — throw when its condition is false, using any following values + as the error message. + +Write it top-level and `throw` (or reject) to fail the run: + +```ts +const login = await truapi.account.requestLogin({ reason: undefined }); +if ( + !login.isOk() || + (login.value !== "Success" && login.value !== "AlreadyConnected") +) throw new Error("login failed"); + +const res = await truapi.signing.signRaw({ + account: host.productAccount(), + payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, +}); +res.match( + (v) => console.log("signature", v.signature), + (e) => { throw new Error(JSON.stringify(e)); }, +); +``` + +`--product-id` (a `.dot` name or `localhost` identifier; default +`headless-playground.dot`) sets the initial product. `/product ` changes it +for the lifetime of the process. Switching disconnects active product +WebSockets so clients reconnect with a new product context; the network, +pairing relationship, signing-host session, and wallet identity stay active. +Product-owned storage, permissions, and derived product accounts are scoped by +the selected id, so the newly selected product sees its own state. The next +`/script` also receives the new id through `host.productId`. + +Pairing-host state follows the same identity rule under +`//_pairing_host`. Before the first identity is +known it uses the small `/pairing-host` bootstrap; connecting moves +legacy bootstrap data to the first resolved user. After `/logout`, connecting +as a different user swaps to that user's KV/core namespace instead of carrying +the previous user's product data forward. + +Product-local KV is persisted independently under each identity root as +`storage/--.json`. Each document records its normalized +product id and raw product keys. On first use, the older combined +`product-storage.json` in that profile is split into those files and retained +as `product-storage.v1.json.migrated`. Product and core JSON writes use a +flushed temporary file and atomic rename. + +Five scripts ship under `js/scripts/`: + +- `battery.ts` — the generated full-surface gate. It discovers every method + from the same code-generated example manifest as the playground Diagnosis, + attempts all examples (including APIs the browser diagnosis classifies as + intentionally unsupported), prints test-reporter rows with timings and clean + failure details, writes the browser-shaped result matrix to + the role-specific report under `explorer/diagnosis-reports/`, and exits + nonzero if any example fails. A paired run writes `pairing-host-cli.md`; a + direct signing-host run writes `signing-host-cli.md`. Override the artifact + path with `TRUAPI_BATTERY_REPORT_PATH`. Run the complete generated Playground + surface directly against the signing host: + + ```bash + target/debug/truapi-host signing-host \ + --product-id truapi-playground.dot \ + --script rust/crates/truapi-host-cli/js/scripts/battery.ts \ + --auto-accept + ``` + + To exercise the paired topology, run the same script with `pairing-host`, + then answer its emitted link from a second terminal: + + ```bash + # Terminal 1 + target/debug/truapi-host pairing-host \ + --product-id truapi-playground.dot \ + --script rust/crates/truapi-host-cli/js/scripts/battery.ts \ + --auto-accept + + # Terminal 2 + target/debug/truapi-host signing-host \ + --deeplink '' \ + --auto-accept + ``` + +- `whoami.ts` — calls `getUserId` and prints `WHOAMI `; this + remains available as an explicit `/script ` example. +- `signing-smoke.ts` — a focused product-account signing check. +- `ring-vrf-smoke.ts` — calls `getAccountAlias` and `createAccountProof` + against the Paseo Next v2 LitePeople ring, then verifies both calls return + the same contextual alias. +- `preimage-smoke.ts` — a focused Bulletin preimage flow check. + +The generated examples are baked to the `truapi-playground.dot` product. With +live routing enabled, `Chain/stop_transaction` uses host-owned operation ids and +treats already-finished provider operations as stopped. `Preimage/*` also uses +the real Bulletin Next chain and asks the signing host to claim People-chain +long-term storage before returning the product-scoped Bulletin allowance key. +It needs the playground's deps (`cd playground && bun install`). Repeated live +runs can exhaust the signer's per-period Statement Store or Bulletin allocation +slots; the signing host rotates auto-managed signer accounts when Statement +Store slots are exhausted. + +## Confirmations + +Both hosts take `--auto-accept`. Without it, confirmations a web/iOS host would +show as a modal (sign requests, permission prompts, and cross-product Ring-VRF +requests) are rendered prominently in the signing-host transcript and answered +directly with `y` or `n` (typed `yes`/`no` plus Enter also works). Approval +cards summarize and redact signing payloads rather than dumping debug objects. +The current command draft is +restored afterward; Esc safely rejects. Concurrent approvals are serialized. +In non-interactive `exec` mode, a TTY gets a plain yes/no prompt and non-TTY +stdin safely rejects instead of hanging. Same-product Ring-VRF requests do not +prompt, matching the iOS signing host. Pass `--auto-accept` for unattended +runs; every auto-approved decision is still printed. + +## Logging + +Use the global `--log-level` option (`error`, `warn`, `info`, `debug`, or +`trace`) before or after the subcommand, or `/log ` in the terminal UI. +Every decoded inbound SSO request and every published response is visible +regardless of the selected level. Stable response entries include the request +name, statement and remote message ids, protocol outcome, and elapsed time; +encoded protocol errors include their reason. Response-publication failures +are shown separately. `debug` adds decoded request/response summaries and +`trace` adds complete payload and transport metadata. Undecodable requests are +warnings with the available identifiers so protocol-version mismatches can be +diagnosed. + +```bash +truapi-host signing-host --log-level trace --deeplink '' --auto-accept +``` + +Debug and trace output may contain product signing payloads. `RUST_LOG` takes +precedence at startup and remains available for module-specific filters, except +that the noisy `rustls` and `tungstenite::protocol` tracing targets are always +excluded from CLI log output. Without `RUST_LOG`, `--log-level` and `/log` +apply to TrUAPI targets while other third-party dependencies remain at `warn`. + +## Statement-store allowance + +The real statement store enforces per-account allowance. Before pairing, the +signing host grants it on-chain exactly as a real client does: it proves its +LitePeople ring membership with a bandersnatch ring-VRF and submits an unsigned +General (v5) `Resources.set_statement_store_account` extrinsic for each account +that submits statements — its own `//wallet//sso` account and the pairing host's +per-pairing device key. The shared native implementation lives in +`truapi-server/src/runtime/statement_allowance/` (metadata-driven +signed-extension encoding, ring fetch, slot scan, ring-VRF proof, extrinsic +assembly, submit). The signing account must be an attested LitePeople member, +and may sit in an old ring, so the signing host scans back from the current ring +index (slow, one-time per pairing). Auto-managed accounts are stored in +`accounts.json` under `--base-path`; mnemonics are plaintext local test secrets +and the file is written with `0600` permissions on Unix. `alloc-check` verifies +membership and can submit a test registration. + +## Manual use (two terminals) + +```bash +make headless install + +# Terminal 1 — pairing host runs a product script and prints its pairing link: +truapi-host pairing-host --product-id myapp.dot --script js/scripts/battery.ts --auto-accept + +# Terminal 2 — hand the deeplink to a signing host (registers allowance, signs). +# The wallet mnemonic comes from --mnemonic / $HOST_CLI_SIGNER_MNEMONIC when set; +# otherwise the CLI auto-selects or creates an attested account. +truapi-host signing-host --deeplink '' --auto-accept +HOST_CLI_SIGNER_MNEMONIC="spin battle …" truapi-host signing-host --deeplink '' --auto-accept + +# Inspect on-chain statement-store allowance for a mnemonic: +truapi-host alloc-check --mnemonic "spin battle …" --lookback 100 +``` + +Both hosts take `--network` (default `paseo-next-v2`). The network preset owns +the identity backend URL, People RPC, Bulletin RPC, and genesis hashes; there is +no public `--statement-store` flag. + +## Scope / gaps + +- **Chain methods** route to real `wss://` nodes from the selected `--network` + when `E2E_LIVE_CHAIN=1`; off by default. A rustls crypto provider is + installed at startup for the TLS connections. +- **Ring-VRF product-account aliases and proofs** are implemented by the + signing host via the `verifiable` crate (`get_account_alias` and + `create_account_proof`). +- **`get_user_id`** resolves the signing account's username from People-chain + `Resources.Consumers`. Auto-managed signing accounts register fresh lite + usernames via the identity backend (`src/attestation.rs`); first registration + is backend-async and can take minutes (ring onboarding). `truapi-host + identity-check --mnemonic ` probes which derivation carries a username. +- `set_statement_store_account` and Bulletin long-term-storage resource + allocation are implemented over SSO on native headless hosts. +- Everything else the browser host exercises passes: signing (raw, payload, + create-transaction, and their legacy variants), statement store, entropy, + aliases, preimage, storage, permissions, notifications, theme, system, chain + (with `E2E_LIVE_CHAIN=1`), and user id, subject to live chain availability + and allowance-slot capacity. diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md new file mode 100644 index 00000000..271139ba --- /dev/null +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -0,0 +1,1549 @@ +# TrUAPI Headless Host CLI v0.1 specification + +- Status: as-built behavior reference +- Binary: `truapi-host` +- Implementation: `rust/crates/truapi-host-cli/` +- Protocol implementation: `truapi` and `truapi-server` + +This document specifies the first complete version of the native headless +TrUAPI host CLI. It was derived from the Rust and TypeScript implementation, +the test suite, the checked-in compatibility reports, and observed runs of both +host roles. + +The crate [README](README.md) is the user guide. This document is the complete +behavioral and engineering reference. It describes what v0.1 does, including +its operational limits; it does not contain a roadmap or requirements for +unimplemented features. + +## 1. Purpose and scope + +`truapi-host` runs real TrUAPI host roles without a browser UI, desktop shell, +phone automation service, or external signing bot. It is intended for: + +- local product development; +- protocol and host diagnosis; +- direct signing-host tests; +- paired end-to-end tests; and +- generation of CLI host compatibility reports. + +It embeds the real `truapi-server` dispatcher and host logic. Product scripts +use the public `@parity/truapi` client and exchange the same SCALE protocol +messages as a product connected to another host. + +The CLI replaces the platform/operating-system seam with native implementations +for persistence, chain RPC, approvals, notifications, navigation, theme, and +terminal presentation. It also owns account onboarding, process orchestration, +the product-frame WebSocket bridge, and the Bun script runner. + +It is local test infrastructure, not: + +- a production wallet or secure custody product; +- a general-purpose mnemonic manager; +- a mock protocol server; +- a replacement for dot.li or Polkadot Desktop; +- a Chat, Coin Payment, or Payment backend; or +- an arbitrary-network RPC proxy. + +## 2. Roles and runtime topologies + +### 2.1 Pairing host + +The pairing host is seedless and product-facing: + +```text +product script or client + │ + │ TrUAPI SCALE frames over WebSocket + ▼ +pairing-host + │ + │ People-chain Statement Store / SSO + ▼ +signing-host + │ + └── wallet entropy, product accounts, signing, aliases, proofs +``` + +The pairing host: + +- serves product connections; +- starts SSO login and emits a `polkadotapp://pair?...` deeplink; +- persists the paired session and product state; +- receives a root entropy source from the signing host; +- delegates signing and other authority operations over SSO; and +- can run scripts before, during, or after pairing. + +It never stores the signing host's raw mnemonic or raw root entropy. + +### 2.2 Direct signing host + +The signing host can also be product-facing: + +```text +product script or client + │ + │ TrUAPI SCALE frames over WebSocket + ▼ +signing-host + │ + ├── local wallet entropy and product authority + ├── People-chain Statement Store + └── Bulletin and optional product-chain RPC +``` + +This path is used for focused diagnosis without SSO. It uses the same Rust +signing, account, entropy, Statement Store, Bulletin, and product runtime logic +as the paired path. + +### 2.3 Ownership boundaries + +`truapi-server` owns: + +- protocol dispatch and SCALE encoding; +- product and role semantics; +- product-account derivation; +- signing and transaction construction; +- product-scoped entropy derivation; +- Ring-VRF aliases and proofs; +- SSO request/response encoding and transport; +- Statement Store proof, submission, and allowance logic; +- Bulletin preimage submission and resource allocation; +- subscriptions and runtime errors; and +- typed unavailable behavior for unsupported services. + +`truapi-host-cli` owns: + +- argument and slash-command parsing; +- the single supported network preset; +- local signer selection and onboarding; +- local persistence and account-store locking; +- approvals and `--auto-accept`; +- the terminal UI and plain output; +- product-frame WebSocket listening; +- session and product switching; +- child editor and Bun processes; and +- CLI-specific diagnostics. + +Product scripts own: + +- the TrUAPI calls under test; +- assertions over typed results; +- test output; and +- success or failure through normal completion or a thrown error. + +## 3. Build, installation, and runtime dependencies + +### 3.1 Build + +From the repository root: + +```sh +make headless +``` + +This builds: + +- the Rust `truapi-host` binary; and +- the generated `@parity/truapi` TypeScript client used by the script runner. + +The direct Cargo equivalent for the binary is: + +```sh +cargo build -p truapi-host-cli +``` + +### 3.2 Installation + +```sh +make headless install +``` + +The `install` target depends on `headless` and runs: + +```sh +cargo install \ + --path rust/crates/truapi-host-cli \ + --bin truapi-host \ + --locked \ + --force +``` + +### 3.3 Runtime dependencies + +Host-only commands need the installed Rust binary. Product scripts additionally +need: + +- `bun` on `PATH`; +- `js/runner.ts`; and +- the repository's generated `@parity/truapi` TypeScript sources and their + dependencies. + +By default, the runner path is compiled from `CARGO_MANIFEST_DIR` and therefore +points into the source checkout. `TRUAPI_HOST_RUNNER` can select another +`runner.ts`. The v0.1 install is not a self-contained relocatable script +runtime: deleting or moving the checkout without supplying a replacement +runner breaks `/script` and `--script`. + +The binary has `--help` but no `--version` option. + +## 4. Top-level command line + +```text +truapi-host [--log-level ] +``` + +Commands: + +| Command | Purpose | +| --- | --- | +| `pairing-host` | Run the seedless product-facing host. | +| `signing-host` | Run the wallet-local signing host. | +| `identity-check` | Probe People-chain identity records for a mnemonic. | +| `alloc-check` | Inspect or submit Statement Store allowance registration. | + +### 4.1 Global logging option + +`--log-level` accepts: + +- `error` +- `warn` +- `info` +- `debug` +- `trace` + +The default is `info`. `TRUAPI_HOST_LOG` supplies an environment default. The +option is global and is accepted before or after a subcommand. + +If `RUST_LOG` contains a valid tracing filter, it takes precedence at startup. +The interactive `/log` command later replaces the active filter with the +selected CLI level. + +## 5. `pairing-host` + +```text +truapi-host pairing-host [options] +``` + +| Option | Default | Behavior | +| --- | --- | --- | +| `--script ` | none | Run one JS/TS product script and exit with its status. | +| `--product-id ` | `headless-playground.dot` | Initial product scope. | +| `--frame-listen ` | `127.0.0.1:9955` | Product WebSocket listener. Port `0` selects an available port. | +| `--base-path ` | section 12.1 | Root for network, identity, core, script, and product state. | +| `--network ` | `paseo-next-v2` | Select the complete endpoint/genesis preset. | +| `--auto-accept` | off | Approve platform confirmations automatically. | + +Without `--script`, both stdin and stdout must be terminals. The command enters +the full-screen terminal UI and remains active until `/quit`, idle Ctrl-C, or +terminal input ends. + +With `--script`, the command: + +1. builds the pairing runtime; +2. binds and reports the product-frame listener; +3. starts the frame accept loop; +4. starts Bun with inherited stdio; +5. keeps the host alive until Bun exits; +6. stops the accept loop; and +7. exits with the child status. + +The script must call `truapi.account.requestLogin()` or the operator must use +`/login` in interactive mode to initiate pairing. + +There is no pairing-host `exec` subcommand. + +## 6. `signing-host` + +```text +truapi-host signing-host [options] [exec ''] +``` + +| Option | Default | Behavior | +| --- | --- | --- | +| `--script ` | none | Run one direct product script and exit with its status. | +| `--product-id ` | `headless-playground.dot` | Initial product scope. | +| `--deeplink ` | none | Answer a pairing deeplink after initialization. | +| `--mnemonic ` | none | Use raw BIP-39 entropy as an ephemeral local signer. | +| `--account ` | none | Use one named account from the default account store. | +| `--session ` | remembered session | Restore or create a managed session. | +| `--lite-username-prefix ` | session-derived | Prefix for newly generated Lite username bases. | +| `--base-path ` | section 12.1 | Root for account, session, core, script, and product state. | +| `--network ` | `paseo-next-v2` | Select the complete endpoint/genesis preset. | +| `--frame-listen ` | `127.0.0.1:9956` | Direct product WebSocket listener. Port `0` is allowed. | +| `--auto-accept` | off | Approve platform confirmations automatically. | + +`HOST_CLI_SIGNER_MNEMONIC` supplies `--mnemonic` when the option is omitted. + +### 6.1 Argument conflicts + +The CLI rejects these combinations with invocation status `2` before runtime +startup: + +- `--script` with `exec`; +- `--mnemonic` with `--account`; +- `--mnemonic` with `--session`; +- `--mnemonic` with `--lite-username-prefix`; +- `--account` with `--session`; and +- `--account` with `--lite-username-prefix`. + +The same conflicts apply when the mnemonic came from +`HOST_CLI_SIGNER_MNEMONIC`. + +An explicit session name is validated before startup. Empty strings are treated +as absent after trimming. + +### 6.2 Interactive mode + +When neither `--script` nor `exec` is present, stdin and stdout must be +terminals. The signing host: + +1. resolves the selected session and any locally cached signer; +2. creates the signing runtime; +3. activates a cached signer without a network onboarding round trip; +4. binds and reports the product-frame listener; +5. starts `--deeplink`, when supplied, as an initial `/pair` operation; and +6. enters the command loop. + +Signer provisioning is otherwise lazy. Merely starting the UI, using `/help`, +using `/product`, or inspecting sessions does not create a new account. + +### 6.3 One-shot `--script` + +The host binds its product listener, optionally starts a background responder +for `--deeplink`, ensures and activates a signer, runs Bun with inherited stdio, +aborts the responder after the script, and exits with the child status. + +### 6.4 `signing-host exec` + +```sh +truapi-host signing-host [parent options] exec '' +``` + +`exec`: + +- parses exactly one slash command; +- starts the same signing runtime and product-frame listener; +- does not enter raw mode or the alternate screen; +- writes human output to normal stdout/stderr; +- optionally runs `--deeplink` in the background for the command lifetime; +- aborts that responder when the command completes; and +- exits after the command. + +Parent options must appear before `exec`. + +`exec '/script'` needs a TTY because it opens an editor. In non-TTY execution, +use `exec '/script '` instead. `/copy` is unavailable. `/clear` and +`/quit` are successful no-ops in one-shot mode. + +## 7. Product identifiers and switching + +Accepted product identifiers are: + +- a name ending in `.dot`; +- `localhost`; or +- a string beginning with `localhost:`. + +Identifiers are trimmed, Unicode-NFC normalized, and lowercased. For example, +`" Dotli.DOT "` becomes `dotli.dot`. + +Other identifiers, including an ordinary `example.com`, are rejected. + +The product id scopes: + +- product accounts and signatures; +- Ring-VRF context and cross-product policy; +- derived entropy; +- local product storage; +- permissions and authority checks; +- product-frame runtime context; and +- `host.productId` and `host.productAccount()` in scripts. + +`/product` prints the current normalized id. + +`/product ` changes only the process-local product selection. It does not +change: + +- the network; +- the active signer or paired user; +- the signing session; +- the SSO relationship; or +- another product's stored data. + +Changing the product invalidates all active product WebSockets. Clients must +reconnect; new connections receive the new product context. Selecting the +already-current normalized id does not reset connections. + +Product-scoped entropy is intentionally different for different product ids +even when the account and caller context bytes are identical. + +## 8. Slash commands + +Commands start with `/`. There are no `q`, `quit`, `exit`, or non-slash aliases. + +| Command | Pairing host | Signing host | Behavior | +| --- | :---: | :---: | --- | +| `/script` | yes | yes | Edit and run the remembered script, creating a scratch script when needed. | +| `/script ` | yes | yes | Remember and run an existing JS/TS script. | +| `/login` | yes | no | Start or join pairing for the current product and copy the new link. | +| `/logout` | yes | no | Disconnect and clear the old pairing identity/history. | +| `/pair ` | no | yes | Validate and answer a `polkadotapp://pair?...` link. | +| `/product` | yes | yes | Print the current product id. | +| `/product ` | yes | yes | Switch product and reset product connections. | +| `/session` | no | yes | Show current session, user, and path. | +| `/session ` | no | yes | Switch to or create and provision a session. | +| `/session --list` | no | yes | List network-scoped user sessions and mark the active one. | +| `/log ` | yes | yes | Replace the runtime log filter. | +| `/help` | yes | yes | Show role-specific commands and key bindings. | +| `/clear` | yes | yes | Clear the retained visible transcript. | +| `/copy` | yes | yes | Copy the retained, redacted transcript. TUI only. | +| `/quit` | yes | yes | Leave the command loop. | + +The shared parser recognizes every command, then the active role rejects +commands it cannot execute. `/pair` performs a fast prefix check; the Rust core +then fully decodes and validates the V2 handshake. + +Unknown commands, missing required arguments, invalid log levels, invalid +products, invalid session names, and arguments passed to no-argument commands +produce explicit errors. + +## 9. Terminal UI + +### 9.1 Layout + +Both roles use the same full-screen ratatui/crossterm surface: + +```text +scrollable transcript + +command completion list, when open +› command input or idle placeholder +TrUAPI host · 👤 · 🌐 · 📦 +``` + +The role label is omitted at narrow widths so user, network, and product remain +visible. Values are ellipsized to fit, with the product consuming the remaining +space after the user and network. Session and log level are not shown. Idle +command guidance appears as a dim placeholder inside the empty prompt instead +of consuming status-bar space. Operational hints temporarily use the right side +of the status line while a command, approval, completion menu, or scroll is +active. + +The composer: + +- has one column of horizontal padding when width permits; +- adds vertical padding on terminals at least seven rows high; +- blends a subtle surface color from `COLORFGBG`; +- uses true color when `COLORTERM` is `truecolor` or `24bit`; +- falls back to an ANSI-256 approximation; and +- becomes unstyled when `NO_COLOR` exists. + +### 9.2 Transcript + +The transcript contains: + +- host lifecycle events; +- submitted commands; +- script stdout and stderr; +- approvals; +- SSO request/response summaries; and +- tracing output allowed by the active log filter. + +Submitted commands become bold, full-width divider titles. `/pair` arguments +are rendered as `/pair `. + +Status symbols are: + +| Symbol | Meaning | +| --- | --- | +| `•` | informational | +| `◌` | running | +| `✓` | success | +| `!` | warning | +| `×` | failure | +| `–` | cancelled | + +Running activities are keyed and updated in place. For example, `Script +running` becomes `Script finished` or `Script failed`, and pairing progresses +from link generation through authentication to its final state. + +### 9.3 Input and completion + +- Typing `/` opens role-specific completion. +- Up/Down cycles completion while it is visible. +- Up/Down navigates process-local command history when completion is closed. +- Tab accepts the selected completion. +- Enter first accepts a differing selected completion; a later Enter submits. +- `/script` followed by a space completes filesystem entries. +- `/session` followed by a space completes known signing sessions and `--list`. +- Left/Right, Home/End, Backspace, and Delete edit by Unicode character. +- Long input scrolls horizontally and retains a native terminal cursor. +- Bracketed paste is enabled; pasted control characters are discarded. +- At most eight completion rows are visible. + +Command history is in memory only and disappears when the process exits. + +### 9.4 Scrolling and cancellation + +- Ctrl-U scrolls up by half the transcript viewport. +- Ctrl-D scrolls down by half the viewport. +- End moves the input cursor to the end and resumes latest-output view. +- Esc dismisses completion. +- Ctrl-C clears non-empty input. +- Idle Ctrl-C exits the command loop when input is empty. +- Busy Ctrl-C drops the active operation future. + +Only one operator command runs at once. Input may be prepared while a command +runs, but Enter reports that another command is active. Host events and +approval input continue to be processed during the operation. + +Captured script children use `kill_on_drop`, so cancelling an interactive +script terminates Bun. Pairing-host `/login` additionally calls the core's +pairing cancellation method. + +### 9.5 Approvals in the TUI + +An approval temporarily saves and clears the command draft. The operator can: + +- press `y` or `Y` with an empty input to approve; +- press `n`, `N`, or Esc to reject; or +- type `yes`/`no` and press Enter. + +Invalid typed answers show `Answer yes or no`. The saved draft is restored +after the decision. Approval requests are serialized by the platform prompt +lock. + +### 9.6 Clipboard and redaction + +`/copy` lazily opens the system clipboard and copies plain transcript text +without the full-screen UI. Complete pairing links are replaced by +``. + +Operator `/login` copies the first generated pairing link automatically. A +clipboard failure is reported as a warning and does not cancel pairing. +Product-driven `requestLogin()` does not automatically copy its link. + +### 9.7 Output safety and bounds + +Captured script and log text is sanitized before rendering: + +- CSI escape sequences are removed; +- OSC escape sequences are removed; +- other control characters are removed except newline and tab; and +- individual child-output lines are truncated at 16 KiB. + +Consequently Chalk color, bold, and other ANSI styling are not rendered inside +the TUI. One-shot `--script` inherits stdout and can render ANSI normally. + +The retained transcript is pruned from the oldest item when any limit is +exceeded: + +- 10,000 feed items; +- 10,000 logical lines; or +- 1 MiB of retained plain text. + +Adjacent output is chunked at 256 lines or 64 KiB. + +## 10. Product scripts + +### 10.1 Execution contract + +A product script is a JavaScript or TypeScript ES module executed by Bun. +Before importing it, the runner: + +1. reads its required environment; +2. opens the product-frame WebSocket, with a 15-second connection timeout; +3. creates the public `@parity/truapi` client; +4. injects the script globals; and +5. imports the absolute script URL. + +Top-level module code is awaited. If the module's default export is a function, +the runner calls and awaits it with the host context. + +The provider is disposed on success or failure. + +### 10.2 Injected globals + +```ts +declare const truapi: TrUApiClient; + +declare const host: { + productId: string; + productAccount(index?: number): ProductAccountId; +}; + +declare function assert( + condition: unknown, + ...message: unknown[] +): asserts condition; +``` + +`host.productAccount()` defaults to derivation index `0` and uses the exact +active product id. + +`assert` joins string arguments directly and formats other values with +`node:util.inspect` without color. A false condition throws either the joined +message or `assertion failed`. + +### 10.3 Internal child environment + +The Rust parent sets: + +| Variable | Meaning | +| --- | --- | +| `TRUAPI_FRAME_URL` | Bound product-frame WebSocket URL. | +| `TRUAPI_PRODUCT_ID` | Normalized active product id. | +| `TRUAPI_SCRIPT` | Canonical absolute script path. | +| `TRUAPI_CLI_HOST_ROLE` | `pairing-host` or `signing-host`. | + +These variables are runner internals, not CLI configuration inputs. + +### 10.4 Script status + +- Successful completion exits `0`. +- A thrown error or rejected promise is printed as `[script error] ...` and + exits `1`. +- Failure to open the product socket within 15 seconds exits `2`. +- Failure to locate the runner, canonicalize the script, or spawn Bun is a CLI + error. + +The CLI emits `Script running` before Bun starts and `Script finished` or +`Script failed` afterward. + +One-shot `--script` preserves the child's normal numeric status. Interactive +script failures are displayed and the TUI remains active. `exec '/script +'` reports the child code but returns the CLI's general error status when +the child failed. + +### 10.5 Remembered scripts and editor behavior + +`/script ` resolves a relative path against the CLI process's current +working directory, remembers the resulting absolute path in the current host +session, and runs it. + +A later bare `/script`: + +1. reuses the remembered file when it still exists; +2. otherwise creates a unique `script-kC`i0PV5d1`PSN2 za(W`px;M)-fKgM^*c{(T$^Svy$Y^8GS<1=D;c0Bb;uh37@R?KpZcTj1x{KwINHo*q3bq|1Ei2^52yKQ_4(^|RM?d`bx)#6XW z1U1Y?W>9mXDFV2$Bw_19{m+7}XD`(!3K?^C(K#Abe5#J z&az#rDqc$g#EUdO9+aY@b&iwu>x-37Nfc+`XZZBxkZ=5fYv^mzj! z)Wn24W`>#->xJb}*~H^G5&g;WV?21+`vlFp#d9evVC|UU8+bI2xTP1=4TXeDhTY2a zZ(xTK0y)3Csgsj+(Nup89IiVC9~0CqT&BOYS!%q_!awU7>?(7%{PItsI6K>aYmpdC z=t-|~?LD+FRj=*3yEzo3*L1P77(;FgoA3Y>r`fL$4IwriZY9fu#qD3J+H=DX1ciZ& zc|VYR#R-R=gP&xuOGN<#BOpatY3)YmHaD^|K&nnR}FvtgP=875OIo7sbLH_u+) zj_6F-aLV$ygkc~{GJRD~wBlXf+W|s~N)j%er@A;C&Tn2nTuJ+Rj)rYgc~SA}MpdAo z2({9(@+THO_%jCGmfW27_nC7~=qLCFEnWc^o#~6cmzSG`fd>4~vyx zW!~*A38>VVzt`R(y5EtyM6o>QmSUrBUyw}YO1qlzu|xPGx(y> zd!i&FH8mSASskkOjcBN0bereQ%(vS+p3iRI_lx9hjR`7FFxz!vXEu*KM9)H%Oi#qm!=K3;u}){6pK;h6fHKsyg;>;6aU`z&gI|CkJb zw-rRw^1-`hqNBO!&i{=+ivJuNS)l9Le1yj<$Ly&t*D}@87GesadbD?>5mI z9@V9JSZE{mYG{pvqZGIg>zFDJRdg~kJ9K