From ddf07864091f8d8e56c8e65ead25e1ed8bd5cbea Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 17 Jul 2026 19:07:23 +0200 Subject: [PATCH 01/35] 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 5c8b247ba..8b8485555 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 9305a03fe..1bdbccea2 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 bc068de5c..1d23a2767 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 a69dca866..cabe2e226 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 1dbae7924..69da527fe 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 886853b85..afe09e97f 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 000000000..cf2bb939c --- /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 b55d34d4a..a8adf8a9c 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/35] 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 82943a368..68649b21c 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 cfbd0fc4d..a61ea4c46 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 1d23a2767..63cffb693 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 730281114..4458bb67c 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 69da527fe..3d34372cb 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 12a1c5164..e687fafa1 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 000000000..3b6874e17 --- /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 000000000..4befc7fe8 --- /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 652a880c3..47176f2f2 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 2a660db77..f040841d6 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 b46d4fe8a..266266109 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 9d83d055a..6f989b067 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 4c09adb59..47af4ea0a 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 7195b5d6a..e9bdfec49 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 f7dd7021a..f5b4db20b 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 000000000..13040bc21 --- /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 9d2d8489d..b3da78bc0 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 ee990b2df..60b713809 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 3b106f816..23ba14eb0 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 36e7d9f41..c783c603e 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 dbd5c142b..c97cfc117 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 afe09e97f..95b699310 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 9da344b05..c8ba2611b 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 cf2bb939c..a5976955a 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 000000000..fd11583fd --- /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 000000000..e1c845d58 --- /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 000000000..42e1df77c --- /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 000000000..64bf61538 --- /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 000000000..a427e88ab --- /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 000000000..b9dc567ff --- /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 000000000..e70be9514 --- /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 000000000..34e046428 --- /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 000000000..a9a99b816 --- /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 a8adf8a9c..1f597ff1d 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 d54ac0c9a..3abbcf48c 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 3f322a28f..d6677218f 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 zcmeFa4`^i9c{hBH=I(md89U=dPUJ>z#(kBy+I-qu*_KmztN2~*YB$ms?XJA5wb$*M z(P-vMn(Sz1Ja=YS3NAR{fD0}-;6MT?B;Y^-2_%q03JD~Tf(tGrkU|12B+xPY=%J;qXn;qSI?)mea=RE(P=j;ak+WileRB1BoR_mQ!Fj4RA zcl({0#d^InX!lndk3XiAr#xNxv%&nIR33lqdH$&i6{S=W|EG=>RH-<-)vN6W)o?%T z2fNX??X6C6{8Fb~4|Lf*T&neJdR&d`@it$SwP~RARK3#<2fINpEbGdZVCx)*$K!6V zbF0}HemoNnwzit}X3*}Z+m{;{U|c7MQ>u(<9#i9`g>J3Y3i{Q}TC0Y^!}0!pHwcq$ zy~(rYot{u-UEb;i!Gw0tl&knkvm%5D&WDPOo2SG{bJKU*9>i7W9_v?{lqM z7<%5io>nD2(eCtiYprSRtt&m#=?1-8zu9TKCsVt%HbB+s?Mn+aU77|4+zy)CJNC*u=B)1bfZ3Z&mxv-C#zS1|OPJdKz2P*{X)kkGn@|MycboY8F#IuI7ph-ClFI z*>BzossiZp@?%eYD1%K{<5*EGlsSG6dyWw%w}W=jYt|4vd%sMvQlNYGJTj(Bo_T0U!iyxURkP zN>B7V-DW*}p`a(Y=yiZ}z1V06d;MxR=yY2_wTo{$Zii;w1lfbquT5N)qwlHfx|^g0 zr%%gd7Ho6$sutF2y;^;z)@-|PUee#H4|WHwL5M-*fOcIIOHwl3XjQQXHT2Xg`o*Dx zooRosT3+wf+F`BEL$FrcCrCZ@4gDqiC61|ki%H1qOegP9%U5qUyBC6>6@+2b$v5>^ zY$qY!R=3RSk-e1E(&b(gNOlFN4J7A;JQZmDt!BH?yyZa9kv3Hj9M>o_pZnCrVi*Q} zfM8eazMTYU03Kx;->+s(C;sJ+rsECwSL3)P`7WZp?jT~!h>q^ zDz-_W`)RHB?3bZ=mTCSTHFdqd+kJWv46w@2VyGdU+{4@I3w)jV_V?AyTBmih*68%n zho}BZ>m55BzREOzznVU0@LgK}?U;TgJ67haikiKMx!1M>JIB8pi#BlAycjXPKTwm` z`ps6ezwfDk7}M)^Y6J7k4y2}4<>H{#Z-&inPyKW(I#W29@-Q=@lb~{gM#ob>AJZ)d zT|CG%{Gb|N>vi@p`me@x*R}-n*xS!Eol%vQ29Bo8^}ojS>!~vVIY3@zK6^|}tO=F! z)O`j0YU(3HzA~RYt|l*bHm?j?*zE@kLj&!0HbLoH*`1zKU>x9jm$z2hH$C-mLBEk6 zOjshgVzYfSGvpJh{1J#ro;q33FQ-1+#^X%$KUU*cDUx~WiGqG5)js6&%m?$TvNYJ; z-Ct?~2cInH&!oO-@Kv?Z%;MvpU{M0S)h18)hYR}isV^-900L#ld`eA$j&%k-h*O?g z93B+p%e>1>_W!84b323f&G2Ha4V(r+I$zLVNsU{V_f<$LY>?^U40bTs-Gpf9sfz{u zTI$8%(`1L9dV2Vyx_OiNXh|*DkuG)m&8;S9`&>bPBX7iN+cwGca$c=p z1wq5mD{V+cwN~@vB9eLP`GS5c)mLcR0gI9iGd(V-MsAO0A8Q5ulOyz6&E3~UwI!p-dFkwC#7kL0es?wD+l#ZM2uvx!4r~`jO zPtQ5I7E9mi+|Wx-ww2FT@C;JmYm4m)1QYXVrQX?vcmmO=5}IDN2Cde8$soEv%e#MZ9`gaSJ;6+eJQwY)i6&_){H`?^enoK zv@-~TJByWeaJypP%Lw&eKv~hB`)Cv5RO8T2JnuSImFxi>bkPA6vE5qlCZRV0@fdQ$ z@c5a<9`m}F+m%lHl!$mx8rT6A)a^p1Z}>~sy1=DIusG=NK*aetyN2r52&zLs({uHm zpnkJ@3+Ed=yrRpSolZ*~$9mfUp>!gPtwlZgFI@>f24Nv+th8_8cs1fd?5Ly08qddw zd%OoUMr)ehD)C6E3H0osmD@W&v`W9X-)wJlcd_Qyex=#>y{@2dWvkPZO*VpW#heKL zMp}H&LwTrkMkSlpP9xLfb*g(CF&2yZhhqitHKY{U&}9f;P&_wADUZLCghn^M-nkh-^6u(s`DOaMM@=vGa2R)kezWeWzFvs`G%bp1E}=@)TAtd| zN#&x}GVM;Nsq=d~wE;{b4RtR>zD}<<=u+BO?@%+>+O=Dt9ktC?ph{}~N~hBYZ&_@$ zI=9jMK2=`tHNm1@)KmOndc0q$RuYscKtMwu2el9Oin=(Mr*cR5F6&ptZgX+*diCPv zrE9C_tLLv=xqPL1{?fV2OXruWS6BY@JWM*>WWPyx=;<>T2JJ?eoG-TOa=W$f0F14g zJsV)+7Xkg4)VM=I!LaBan4t9fDv}Kv?(xs_uBwSmVAdswb83aXsYc;>r zyajQ$(>}ZZr$Mhn%-&1FA#Yc8adwqPjVl3kSWH2w$xEHp+CC|xQZI@XrDyC4vk-A_ z0UfL0R6$}J_52x2pRV>nrn&X#W_hLUIN1_RSev4utF>optwFHZk6(T?*e8IOpx$Fu z1~Ce1#`Y-u#>4ADx7DnpcQ!w!=E)3e?VvLVFX1x)TG*p`B0~VqH{wR@1~#~$7r;yg zJHxu|{v0S=BYts0&B@0Xo8c~vQ9$#Bc%~vK1J`G{!l+3}&6v5Hso$s6OQjcw)SqGX zKPx*-Y+&FfC=zxL_$dk^MIr8p_x*LS)mzc~%9d?Fwp!T?z+Xe|9mq)|BGY~&+uN^=Ov1`8fBDZ2gIQ~W@!1kG4ZibfOzwAwzL1?P=bg>K*c>oGfCa7U*SEMAbWX!~= zLr{bO?X4)iH+jB|%^@&HNr)O^cn~<7_XN1z>C@JBhO@%OB?Uh#IvRmAEeeeN+pMaXxIVHL2T-T(Fg)sxCTyr92B{-Ip_nMK)EaD z_nKg&(Ee+!rJ&mhoBf$RD3`QNK-g-cX9%4GP+HipE3FQYydqp0G^|8BerGo$xfc77 zPSVn}&Yk<1x9a*bI!1sq=TBQ8UJU?@5*Cr^48ziWkOa7h>lT<6i?G7|3d{z5O54V} zFrvTQ$>BK25lwaqz=)TkGHoEt<(qwkRO()y^rx@2-5+-{;xX@MnGpw3PpL6o_dF*A z#z}wqikaJ$0Pc%klWL0iS$>oK(aQ~3Nukt}8&pFu)j{~Go*;J}K+-E-G(;>3p#)q= zAniOIIo@ja!hVFSR!TT5lUpD^LPz+GMz)P~60X+}Wwn2jWj! z?7b6!-{bvA0B+8epiRS^0i2%cz~AJT}$-I;P9?Y2^hz`8_{>1uDP2{XR1jygr z_vG}}mtOqU)SwQH8%v2A?vIzT9JrMIJHHh{$z*=4o zS`Cuf}8weyYbtY{fwIx1S8TwZ`kJ1P|NJQEqI%f-8VL0u zYV$`87&+j8fG!&_l0k5aN(B5(*oi|`xmU;2VL1~3T(ch{m;$E7uneK3K6lmx6j^B$ z>fQwi!n8zzSc{ZSb?>zUVl9?(&g*3C+kQRrHAk*(VvRl7?-_DUe@0)o-W%~uCtAKg z;Sl}Ykbg4vhbBH4@`ZkW$cG9O0Q{iN6`J@ia)Z91A+#q#9*h?>(K7aceo618npy{F zqV0UbvG*1IQmS?2*GsftQtI{6yoH5@oK>->6V0?&h6#V-jZ_awxaRfZB|Oxe_8X<8)c1sP?sqe5 zFlJfxgdcvXw4C}-V4nA7{=C0ZT1$OyHzBVBZ}Qn59Yi#zs;`Z*d<-9EU7WXP%f&_^ zX304F);kvmw5={Wr_fMm*L9IoX z%Y>e_Q@Xsx7%E-rH@m|RRq;8;D9kLVt$|rYy+G6h_kcI4zEjyi2ZlkUbOz`qG_^xH zsUvI(YA^ekxe*ySyO^#BkFZN@98Sl+S&9NUazdDLpfe4SFcu1X2GQ2UDt$|TXCam@ zjOT_nPV;M>l<}$4r`IIHE3}Rs+s|w0((*q#f=yWJKR1dv#0b>@QkIi!(Ys&BSs!Yv{ZV2CS=e= z77Tx!4jFj@p;@VKp@g}R5%Z<`KZ!t(59=W|69sw<50i}aI6wSV#1V$S0$cdbDDURf zmD+97-kY+NWu>8&E|=-y0O^{z+T4cjP77|_05sfKc*o#cjC|E_imC7AFq8?qX82Gg zIP6Vbftn?jEet@Jq02~BPbFV~OT1a0f#C~Lh1*r&T=CWuFoK%T65qpU3)c$doX|Q6 zY1}}dZKwB^p8CJ7mjrCMn5KIUE~n&~Mh7d?tH#p{oH>ic?mu~%RCGuO|bR33dE z6c8^T^VGj(UOtAGV|e+vr~V`J@^QQ@;N_gB{xkD(4lg~te8N*&XWl-6x5x1IL!LU8 zefuH2J&w13?5Pvkw||Vc_u%b3!pO33=kXTNx8FzfEdZ{5sD3yMriueo#Q_0P(o<2q ztEYZs{3>v-NBHyjW7w5;(7)a3-TX(rWZe&!%!8E%AXsdI#>V0I@LRf%>F)i|++p93 z<9G#oHfkHLL;E$vn#X1j`?hp5*#Boc_+zurV^ii00bgkY-C(c6Mu@)|KF5zRXd9L+ zj^?SK*#P!qYTCZQfCwXL1?}zrj>SKT)=w@Df!1^_i=Po&F}2njpi4vp;)7=67kc7s zr?HRVY+VK-_kXD?{2Ku7V`mmV(&}v1T2Y|3r+%WRB0$nv{3C>?m(@Qe{VzYs_+Ne^ z{+FMO+}lK#x-xZkpQ{U-h>S|E1GM1mKGgb~MLioi4B^I`b+Pm2A$?BC&k)w^0!eW= zCO$d{Yr8c7y>cFURxcRrqOtKpHXC+B?@=>nn|;zV1Q^g)8~usCvFJ0d!?OsBLQq2+ zPFnTPz~ONoT=g@6WJ3L-ToS7Bc!a?bpr^~%5QK)d2$yU(Z!+|4Sx-t(!QAsW-s5(^ zEozT3>LI8jw)9qjiBGKu^_?~h<1HN7nblys8Nz1AcgGYC2~6g2=-@=cEKWr%z@E=9 zHYnX7?AcJgqJk#}dfuS7&prHQW~wm`=<)GqI!$=2Ia?bVXg!*CdI0bRM4!`90CP*b zv+JIZBeXR7YfLRI_E#}xAI?gqTO8cHpq4KJ_An5&kJR2%>ff*d2sZ!``49X8FTk(= z#4o(UFAe%qI3_=j@#k^*d4fOhk)QYRCukAcKA=m>S)s#C$S7mm9Gr zGZ`kW!0ZHC5ykq>YQ&(rhnVJ^`%5$_MA~Y7{Yrpvji7-8-beT!L~f&HVa^Zh@|+pi z29sDtd=iL5>M`n5WJSQ3Y=S__dQJwwUJFWt#vsCQyR4_K;2-;_Sg$>z^}_HJ)FX#X zAo~TvbuhIWVjjosGA7|);=&N@XZu&`1Nw07&u+5BRFA6YIff^X9v{`EjZ}|`SmrcZ3iyiCt5~Z+y}bUW_*%B>FpPXcm#(JOvZ zuuu_~FCads4TBAm3qpi|5h=h-ELtEB>1uzsFTpLt@3dN5?Xdr?o0oTEvM%l}YA-52p5;I-gleh^8=hxL%oZ-w*pk{R}} zsC9A1%u5!9G=#l3g(i4(G5P%QWx50o3L<9=4w>q~{i~0biaMGI7Fnx7? zc*1ETMT~NsaH)I_Tow`&8&#VimP5e#v+elmuKgOt%8tU`_!{1~V0?&=+fapa5aVML0nSWU?JBl-1pE+e-?ACFuP%tr6qK zp`evxAFd-#8Y(a7d~mHzOMq2Na?KaOgO!7!V3z}eg_x>Aa1o;s5Qs+_V6+mj?r8vL z@F-IIE&>UTolsBnA5UEaJJ>XfL~g0Wyl}=nyA{-@V;~@;ML(y#t7It8gCn^#wCC=) zm!j!pKJMdrOla&mt!f9aGzrDxBr*gEJfispc)c$V`rQFSJ(dA+q-E9j>o6)*^FWer z0V)verMZYgx>xDN6nW@m(!K1VN!p}luQC1S9IW2dGW7)GVfy<`tygJx0r6luS9ezZ>FAPpcA6khF^GIm%cTV%_Jd1eT*JC zYG*GKHSA=HBcY$a)yH72M&E*6QIC=TdlZ`HS+kFOdj?>}a$eb>0a>td*bjauz1}mj zSL+X-k+TCb3s*b%n;>WATot{Ss7Sm)2u{h-@GX&3m4VqI8 zL7?NKAfU@_xZD!e1dvO$zq{FKB_Cnl^(N{yILqu*6FEF`94a>R>7Cx^kPwCtWHVwM zoJGiDS`wp1F#AYWG;Js;fToUwVA=beYZ`Y);Q$|{yF6oXBWO4laR9j-5lVp-wt{Ui z&SAos&!-;n9@W!}4MaIXA+NPgGK#;Ej5{}IZYSzE2!kqWf&(@j)Ws?KOAfoC?bZVb zqX+Qsd?KqyuHaqXFNulv+!@F@`sr3}s1yR&%;4gj6MDUYV4#@qIyjHUraPUSp5c+O z;5ZuW-(7rEV?j~;3eS~;Fi``?7f}u(Q1*iUzKv@!tBJx|?1+dT+27z{kM?UL0tpBk z5!Qr8Sk^ZZS;xKqh>kn`oI5p41hc61A@(9rJb+wlSUF+K5jTVYY*dVF?O&I{E+SXM zND7DY+6Ba3Rl(x(W9J`4pemVeq5%n6%@MoE7whsp?W_zb6kz$2>zx5pQAV?tJRYs5 zZ1`_h!hDbSpCj0}C0Qo0pGQQ3=z`^T6(M{=DV;J}#ZDHjvnzC)nr&f_8P5gWovM79&T%V_BZ=u->`3T-&U=pAtJ z>fsAe{X|a|^^YGfff+ree$V@n?F?rXyaCx+prRtikz0USIHV-wh$SgMn!^>7oH6n= zzR&yCs1c%Q?R3|(CK49>7&LwK+H%1>>3uJ=-9~AFWKl6)R;Jq>D*^8Z{geKzi9)=q z@g7bJh|vIP@nYJnhq2UmxSHShzLi;x4cM08@Cy4(X@H5i?K>Rn{WhLks7@I79EnMl zX&KX?uYu{w!wmy3(4aEVY5kzfz(D+?UP#-wK+yHQ=ivKRAH|Blw0QlL% zkqCxA97F#c9*HEPNS&e-mKcZ08H13JOF=1U)+y>~!Px3e80g+KrW)d`1dzffgf^dm z3G_m9FKAqo5^XB(BO;pz^^s_ANqy(~rhr?xQ$zK;q#ia9C5x(lc9J zM70ZRZa=x8MESW->e7uA7~`rvl-5gDar(r0{vV{lO#VAwkDyB{%Z1h;^2(zaLnsM14w=)y;*s~U&GX4HE2S> zh&-BDHbOGiGQk& z8>9n2J+H;CZAbO}3fo4V&D);fOC%IYaYim$Pg>!8IE?g|cf)`;nr-D{ILHvS*P`w@ z(MX!Sn?a6yUG^c0jayBh3FUMbH++_BFpxUR{@sH#sYEB_jBf9i5*?W&0|eWG>}B*- z=EGzjjsj89`!}kLRuwnn*%StAfr??*Lc|Fb#~MnIAl3%_JMedaYw+5!oJ@3*qNvvc zzBOT8O2mp`Ii7M%iE0BlKV^S?t zaAtjn1Eg()eIEf@x1~fLu&219*U@Sto;OnRCDX$pzyMwcBz~B_VSnpXny4q$gf*ci z#7hu>&K#`7CfrR*6+qN7dWOiscc{d`?FCVoirOnZ53gL*1b!jVujo&Khip>Ae(Nts zFR!3R#S!`|cze+&aeuwwl>Pefz5guON;Y`NmO0F_6^~^Ry0&OUPWc2xuAsp(1J9KA z9h`vWD;L*tizd`6p$S7mO#~ZZbogxlO6LU3CeX7#wb(~B*shqPCy_`2l_dBA0w56E zcKS4PMZ-Q~x6O0R@JlvQ4Vtkr++06*F*RG5mP~g(>bbW2NFy_*q?DeLG_R#zOxns4 zU)2JZh3S*@ zKRv!FY(^S|Ss*8E&6w)3z0wgOj}Kds35Te zUj<1jlWZ+vu3gje0o@fcu5k4>h)OTf!wgK5!(ZYFx`XM>)#iz&hJ24+*9}0bW;$jm zWU$^r-$!JcS;Q4oML{gAX^>vA6sMMz{umqJK2XPs&+8#TL=tQS5pZ$N4GK^L)?CoK zehu2swlNlN;15PTm>FykEXHa45)3X0paGVsjc9|;VFYOTB=annNc*NNX=ui?R-_d= zZ$pBo`K4hg=E-r0@<&>L0h%mB$~(wJM@|qx_>!Z)Qb`-2CKv*VUd11&YJz8ECHpKM zWopU5K7ZFM(sMS~CsWgHb+@)x-5fNwSMT(N63SQ#%=rwR)yU%@)eO|0Ew#;cax~B z#Y$%Y5~>HI4$FW*WB%If_?n2dF>SD6rXm}$(wK=T)X5G1jFqzu(L!0XeD;!y0ZT65 zx9SpJU>Z1MO#?1<<|%bA_yLQ^F+R%l6H(u!=wvwr>ke^0&7|X08i)ye>}VnCV!>Lk zxj@52M51*QGW5q#CFnUdR60#kBa+#gsbDcK{GJ*0VuqGV;Ek~ zm?D`+XOHigeEKfH;Z~M`OQcj?)GWk-Ahpa%?=sFB_2)nhpmY&r#3zGv2Sdt7Jg0DC z;8L8rU71FWuR7d{@$N*ksJKbcD1#`2Xk@gb=Dyl*DG8*!ny zczeJv5l~L}_KR+l zcyK|5Vs?#fMXBCKaye1mYpyA=Gv6Mg@zkQd<1s4tJU;MSTgf+U`E4tDL}0=?kUuN7 z3o4XgGuTewRq!PHMS#7X{m~i5yC$#wCJck52*XBL;VOwV+y6hu_$_jbc`GQ`UC%cl z44V4z7NpB;WTR){D{S$v_at-N2E^$H>w^4|k;-##9MMOR}r$sg>JQrMF}44|*Tg z&)SK{%N9AB78wDFuutKxTx+f~+h*6^{Z*On zY@vp#oB&fHcY_QYLe$1d-^zfJK=vEUfD_(#@?}5-(uz^bOf@M6x?PKTE|Y)}8wWF~ zJppOi=OLI7Q^;WXA#0)7PyK~O=3K=oktI~g<&zo38M4Bee$k>WT@@Lg361SMxsat!Oe<=&zU=LEMvj7|d*lG11ceu8bT+@RK&E z6)n~^q#tFWHtBsSXB<>XCD79=4-dEZe*xD9ri)s*v|eY!*QLrid;JHl-AI>_M%va& zR8qY-{MJgHUxclIr3`u|m|aXw@VAf44^QR3hO_L8qh^-vEK!#~`O-MaV%gV>sMMFW z&2N9guGPNKZ^V`BksXbkeBOG(G!j%z(rV*c8*3nugAIiWWh9l3sYdi}-9}VJFHxD_ z{l0+m0LZ*scYlM&L3jv|UJI2N+4_mw7*IXl$`!6L()JDgz*iTT@rTL+Q7I;gZic8J zdKpsq7tqKBVN%~lL4sx15!}F_imbnd!tv|p_v+AmaYNAFMWhM9s&Ew-hTyt{>u~eC zi00(|0r#Qa07I!6HKy)YtZxj(UB-B4bO(V@I9v%iZ>(|aoh30ss0Y*;4D_s+H|_G& zyVN`;%jtj|liI3xt6AJ7ah}%^uxy+fSGcL6)J zh@kUcHG2s;0Z~!>3576D(I*Vb;$s5lA+=`5ewIcGTA$_Wg5Og!7zB$n`1(HFAAy9v zIu5c~lbTex$zut*@9JSBFJj&p8Ik_#_tg|2ebapQeoUf+TxV3NqZ+I#1eoatIQ57u z-3ILn2>$>VUzlq&tcLx+)atc1bms;Roho$p>(QL#;=lQvGkW zYT9CgJjr&q{!lI1y@!xhzbPHzf-?10T^gbYQYEKMVg&iFW>ib!NH6f+QwVMO zI{n;#t<_&=`j|;H_e@{<`*RLOWwD-0uwJFs?J1LYQDAeb5z=t2A;z6LYavOs8dGym z58$yvf8x49S_NfaI-}vNna=`fJkp34Uo)?sa1mR{!8oW z%aRXkV(wO%&AbUbz*S&wy?RW~TfS@%2)@-aT}Q{p^y*S)fa7x>)E}l6g8-;8(pc{8 zb`c104k+6WTDah*Z3S5@fM$W9=c<96;Xuw)%L`83i&?&?*SfVj5^wFpd(w%sn` zCM)BCo`U(d+2s|rwju5VO*A}*D)pc$B|Qac1$FIVH+7>7%_h^)2{j|BJqAdipK~CH zb*#WV@YGx6YA`Iqj_D{XFP%Zrbw@N%{|H8m6z<9GS|wgBB60L-^tcalkUY82Y@uTm zQyYOJ_o-FeK`x49`Z*j=?pJdl24bU=8NfbC%TTtb+o%GOMB_bah!4{|M9{?55WIJ|^+ z;dY+EeiZ5eNr;-ncQp!3@Zcku%1{v81BZKU9>PAG17(QryEzx8UR@pZx+paWhPQAv zFpOteNIwb!;Z#q70oarFAnP;qk;nvuY@!lW`t5sg6B0Ab3=euQ|AI=8Mz-W8PQVm- zGcB9rYG%W8Z3(1@P}Ig405=26MvM7$RQ#(EEQcxQ?M+=0xc#ke-qw6fCXKOt1b(zIz@zcN#i=ZG44zgja59lbOeQ=a;F zt>-V&6eBIq@oJxnnvqkz23p9QDQ0%%;;ia$4&qTz>JhgL(-|)97Eiq+Q<=QRQDe>2 z88r>+{jr$FEiiA&K5xQcVLSZ;wPtXCc&YhF4-rJLDcxF4sRc;L5SPMQ%Wc6AbzGJ0 z10*RtVOFk$UT90O{Ulj3EM?irqp5nB68vu?U|Dn?uknN>jvD+|EtWe^S=0W|6bh-| z9Q9R>qBSkj8OnRGJ4$CPCp0veNfb=-?~^>tkPgDW`7r0BiR}D7DCe0{BraYHaAVHL zn*FNFadNcv|56nk`gL9+&5A5>xw;v9-sP1#h01r-1>L43XXkU%Xvj#=ssm}0o;6nl zc0^XlJz{^PCQ(zDb4MkLApp%LsJ0b}aB)1v7Mr@5oSdF9#wb>K4IjpZxAvgQo(cA$ zeu3eQkVJ6vxlzNaW^N8jjz)iAO#dXVe6onuk?*vkT5i2bulbkoG9E%VHs?f17gpceVQNh)*rsV%@=J<8KjL zCm*uxMhSfC@3fw|Fu)z|n{wf?Qhz(5o9Hu4A-U1&??!x(>Q)ykW)xl@>`EOfUOUL^ z$S^ve|Gn08W^%SzA|v_#enj`fEhpsKI#{x#DzdAx=Z4a_`n%Vw?V~_uq8cAc2Fg>^ zulxZ3BSre=S+sFd9zTkjnE#;d*?jav&tv80#p9^ZnLM+oh4KSTRVvIC@d_FukthX^=A^uXx{OaoS{ThmJP+7eh!Y54*@Mb8rfBQ{`YSz_%o&=_Am_B;=CLt>qyvU!JPNJtVtnyR}5^rSy@s_~h(K{aV2|MPKi7}tZ z9K13`D_ko&EATWfx*ixdWOXq!r{ovZb>yU<7*ih}bG34^-c|A{!XgOPaM7d(OXJI`pKiWzuT5l+rDzQU*GL_MTIXNC5j4+$HtgDDv@7<)!Hhr zQE7Kbw@0s$KcWL;**dI9{)mtNXIIG&aQY)yvc!5V#q98USosM+BSCgwElHApB;&v?XuWVTIv*JdPKfU2 z^*!9|zu7YI!Z6#J%b0Wq;L=Hx&VW31e@yL<_;g$VVL9rb4|CMN9_FZjKBAk5sR9!| z0j?Bt(NAQ)h-9ZktI-VdlW7L|$xL@gVU&L{mbt7XaYM>yvu_hur2JL(ZQ_oUFJ#{) zE=l=f_HE*llrLxBCN4?&YW8j7l9aD!-`Y!3{sKELx1{{#nEJ~Mw0=`2@TuRF35@9A zXiQ+l$N#gLz^8|qz^7770I!Fcz^4rp`1I)A{r>-DI zNz@=H&Dq$o3zBz{R@zkYr6m-8yj|-xqB;W-Wo@EB5!vilZv0sV2>~~r7guzbK1yB@ zAhBWT&UEoxd7Ta5!M2}8q2Gv}PxXxuHSrTQTnD^>0A3d!G%MRna=+zlL+19wwp0>b4pwiW?H@{pIq zU}cE^HeJnoo%v83n=PcsJyw0}i4ScsqJD?zSu)2(3~ zWCkK+pK;523JRyQb|^ zrt8*$a`@8b@493t7ytw++3zdU1KdZ?)a{zhATskg=}*}~n4$MO1pAB)Qc1=5df!>@ zpdvC#sJgUt24S-aA=b6krBnMpY18_xM|R?sXLkZL;C4mWAsJ-$YqNDQl`%<9%GF@+ z;?fZayL8V{6^S8aEk^EFav;j7!HmbmPr&sF9oHA|D=YoV2XWm;(AxhXP!tiYai6>=Im-I^KZQa$F(dd|M8XL6Zb|`K_N&+|s)pxS~Y3eQ!IEU~5m#Cf{Yw z^xUoqFLobIqHqsGjXkLkCNVEV-$?quJw5EwAM;*y0EzQl>CQ2DYlw*tXWlkq&uYlB zMc}204H!X!uCEW1AQ{Ix0CArPyD|%trm0y<(}K4yB!_o3Re_?r zzEI$^7hGT5M%f2e@wTfqR1-#hcNZjY5`&Eq+U%sgY0SfwV$lU-kaK=8R?fXO+Y#q5 zkFvtO%m{SDRUs$gimYC5(B(L|W+!5KaFR{OD429-6x8^0O( z>L13U0$8}cj<<%T3Lq(6IditwucIay&M=4O9vY3o)|0c*{5yIs<$r`;drixyL?WC_ zQ(psQ#FqC0&qORgD9jf8=lh@rpd)*1yVdL=vv>Kg}l$I1yzVUvzR ze{Ka8l;YVGJZ7ZPEr2w8_?~v%IjU9ix@AZ4Ohwp;<~Yrs4gW_}iVT+lyyrde8xCjS zG3)k|A>2rOL2wTY?LS5cvU_2o32^Drbym2;rjXjZ4Fg?VTG={}RDg?M?aqgN+-va9 z+~#Yh`33aExMzm2GZ6~TQv}WBuk{}9Wm1eS`$=xMhe}~ThAeEB!rPesY0NvXcXY9M z`6hxjaB`I@o$n#ub4NsCK7oq!)3NBnZgD@L0!ZrM(8a9BXApQTt_{3K$g?R14?M`! z@&h}E4+E_-Z>9*?Dy2Da6M|&f6dHTgltFagCGy>fKbyh!pzWV=W5eSmmt%l{j-JC5 zOFl?d)PEV%-F5fJ!r2z0?GWOGi#$Pya9bBF&v!as9LUA!o6KC1ooA*65eC9t2x*#y z2&3sc!F~L{ZTh2;f6V*b!7FoWgTLf<1L;2IIs~Nlp(+S}=i$U}b3Y2+=MSE>ofi(V zi;Q3E+#Ff7IUh-p-q32`Iu;46A1>snXWF=DV8lz$`z4RHeQT4KN1{5=$Cl>&qb?_T zc#hq_j1?D*d|7q9PDKds%_J~hm*Mf8*pd7w6z531!SNR9!?Hp3b2zYVpW&d<$x?&z z_*Go}3W7Gn_be@zOHRFSaOvi)lfQ(x?$#PnZ4LH7k-rC(rwiV~Kc&&fkPZjQwsX_e z8t|ua|1fhjaMu&Ae#L(L>sSZ6PbS|$_LGC8-#7mhL>#GaM+twpMZdj^V*9zn){j$Q{aXN(}!2dG8J@@Lit zJy#ff0+&wXuc57m=$ltvp7%BG?N?cz^xt8N0)ojb*YUT~Ymf|TyPM7JfrM`uwc}}f ziJ~snP}*iGalQ<%K~ym=hsBk&VYBWO6)9t^i~opKVP;Xm*$9;oSHOTbvJxVrT{?|6 zvb@*Q^MG>{U0}i&~;rK^CD7#2s8zjf&*+%9mYk6zRYzf@WtQLnHC%r-o6MvF-ZdXR?wx=t;MgeZgJC1LV5TE( zu-Y#R93}P}brncw$wVuEDz*nkiHQ-)N099lh9y)JyuN7$~5SBp{Xntqt3ZAL(~pQXa8%g zv<`qbZ{f;sTAPr7idVx*un^prX537W@!{o?Kjr-bLOEiO1&UyI1TPpleoRYUK+stz zNd_NK9iRn|pj}jW(qC}>OKOj>>pT)Ws_M7)-bxH!XR=v3|g$$z5Qd z^KMW>>L4QA$(Uo__Yz}TJRdP+hzmYpbMTj=jkHNQBUid$s;u^EJxBNvCCNhIc(Ov8 z4PO&v?+Ath^;$^WwKYMa4*a!f)I?>J5vx6XT+e$YIj$|WBI5(H3|k{;qaib zY!jL?6#4M+j(M*q$Kx3lPDZBSD)<5@eheR>#GXF9>*LhjBRCllvO2%d(704G(BGO`WCopgB zH>h^Ds?2S4wyJ}ajw0kFus*N8w`N z6u!&ABX^P~yhlKt%}#Q#cxVJ}c94foAts_HCKp(w8W98-As>NpXeuHbzbzYfuW*nA zD2x#4&bq5;Gji8>Ei4Xw3Q)JmU*4A4y`%ULAefom2g#KhakbNz#uiQn7s?Xg}H|1Ia^TtG221c z-f&1j`8SrJ{?o$b=3z1HPO;)$ECN+wDOfSgN82-v*)-b_HDaiVYRTFjtRQ0vyFE5I zPjKZ?SF>QET7&wrM(#Qp8w4B_7u>Y3wKt)dVE>|Vh7s%Cw1q_TiuA#O`OuGgvH&=s zr}6xr+?=8VV8o^aI(pD+H*k*@#bUL^zakjKf6Y@BWUq5T~xBLOQLDYes3fnGnT)MJIrj;#i$O+Q2!56R7`Zq zb83hO#YL(?G%sQ1$=I#?e51r#l>45>9-u}IaCy}webul zdeBjOm?Ejeu=vceXA#I7rntA?&+p!b3mtH9exD#daDb+{rpW)y@DR@iwVRiF*HCmA zN>C()<7he3FibZz0$HshcC4TmE<=KE1<{A<{cxzQLkeIcv5G)mMq~*|TW!P?VL;*F zQ~+s9!F7(!_ThT8F5GGMRyL#bSfM;;R9fpEcGcr(B!Xs(lozK+>*6@FL;E|Zic`?$ zC$wZ{<_OMFK|XOvZ}uygG0{_iugBrNBX3peJ}f4sTX%yF9KKn*`1G1V=bcUmb=>Nm z?KXlCC`f~Q&FbkG*I{&oDHvwnivgWc#%QJFf@B$}*XL=hDxj>opE(Z4Cwz4Z3HLzpR7O6-h?Xa@i9Hbh>_>OtsPY}GYv05m(NE zB!UZG>)zGgpYXnN*xr-oGKg@{J)G7)P(XGDf^-T)7Qo%kX0v$H;9l63ypgb*rB@CknVT?GGWn&|)QEso2h*J4>6w-*}*dY!0 z67Dt>7Z#)f1JQe^n5A(h(d#VX4T2?gn?bG!n?pB#mfeveH0we5P(ja=ZknZnFg#@l z^1-Vqdar?iBcwGHHGsoXnn8VYS+}(`__J36hSQ_S6RZ$W<EuyNMs|XCR6U$|xCQoBpfILaA++R;XBfQG^O^|UAuo3$p zVHS1+7*q)$NCbsjfvLPkshoZ=1*`Ie{PMJmpg$9^htKCoH`WW;>ZS<42;CoZTyfHBgLiR zfG|u&UW->P_ENZoIi~T0xR37lK7*49lm_I*U)$XZDP4-FQ2h%#zfJzOZk z*pTp2!=4Sl7XE9tZ)WxB8)x87H}_L78pa1<74(QW2nY8N!ACUft5JJ~VG5@txdZo% z?g`3s+aB1f_KxYeTU^FqI1o%IEU?@%n{0_H)b!+7v7KuvvQ@x|PvaMb%h4-In{i?Z z62k3|j7>+58NC7QOXxXmIwsVGlc~|h6HkzQ7J~0bz_x%M2ahcQ>j;H$uEs*~yrzI* zcOpLE+om^+Mc){36eCpZJzJF;uBzZP82ygUXgU|r>~pfs-c)1)v{nn_5T|&5@a$$l zm&F7ucabJ-DxUX}>qM*s22ZSq!B|9-*OVLXEoN%@*_%yUXs{mBWEjnkc`c(dMlBM< z`IRfUUXH<52O<^J;`6BZ-|3p`S}7Yl@H+3P0id{Q_WcDEh{nx~h{QKm=R;tG&7(xx z$iV0x@AVj$5t?r^(y%$OtKbY$Ucxy3Jg*bjUvxslfop-c`KrA%(5TI6Sp#c}#<9Iu z7@^v@JV5lx0fzu`RXkV64-Hfp-%Yt{>H_lw5AEq*Zx1%qXcjT79r2%Emm!jFp~3M4VhI+@vik0<~H_8+zP zvUi`B+jNfH|BNIKRKQxfgTNAOpZW2iVKp-Ax z9~Nx0W6y|$(Q~wwlx+k8ybG7cN3RffVwIv>rh}Mq!0P}RkhC6QTL$P6XBuV0)JZX4 zz1#b|{V=gxjvpCnX1Ob((PRXJ-eNf&1scRP2^mP8?s2(VUEl&HChy~gz+fsj^I>01 z|A}LKN25Fl&B;PPJ4)Tr2LBAK=vxRoHW3F{46JK#tl`lI3i?I!l8%R7BQMwGJ>Hin zmD+Sv^PTM~UXrtOA1cy4as*i}b#BWj|48N{-b0b$c;3x3bKaF&f_Q`wUCgSrX!tiA zFo-^I4-Ml*j4<*9Eutg zQr-&V#Vy1i`4kc)3$hGO8f`khT6b(mk8uLSc zntixng;XbF-hZ+aN!W*uDxFc00`aZ~r%apz?f8GTVfGsZ3TtiT*7i&e?8fu{9Q$6w zSOEj_=s1wyrwS+*z+X%Dw7{i|BJ2p-d`L&rmZnJ1H&C<6B@>8yO|UrF5Dpr+a#ha7 zwXV_79xdp;MgPk^gYpZh6c+_q7uEm-l0(KeXG%h4tZ9c!_fch&I}-&e7-!(&UF&In z?%V;Ap>QlYS`@g@7q_I@e}DS&)|R*(xwm9UhYxg|nx$PsC;B=z7sx86K1z~nB)kdJ z`0$?LF`|Q|SKmRrDv}`#oQMlIr8gF~xLeLs^j>wGM~n_fV>&3&YgkA|_Bi1+GL)Y) z9nD%s_jNCV^M<(y`-&OJJTW}Bx#1WlX~;Xgb$M?iL?ffw+Ew?HudleUzIpCYmCzO1 zXh}keF+8FBAU=jxmO|ibf46^)w}C|+5nmuwNWJsNAA4S|poSD;v{{MuN5BKK;B6m~ z4b$=XI}!Ho|E&sp?|gfNz4)9l~aExIx+!<_bwIW8bN_t+k4{?=9Z@bxcZKn(o0gG@? z=FOR{TKaDvt49kZl+sP`vJkRPU}m>4L@~W7`(UM^k)1Ya|MHNQ7QaE9$AlrUEY9Ns z$dq~_cc3iSoJt#9P5kJg1j=L%cEu)cMV}Z$V;$X-|ao$fY4pCAg~tA$Xw7 zk%wZWJ$H4{*m9Gu3`xPX^TJ*`>#m#E0pTr&uUXs@_P}z{Fk+gKB&3Bn8oS$RBZb8i zMgnIeeL%-XQiv3$Ia*M;6>T;ggfPv?eemFdQhDJNB_h|WSev!?hS}|$F@>nqz&4EB zd8b|q<3cqc#kz8x6VhhbIpOO_YRGj;NLc_UR}f^8Dct%cP`l)J=!YmA$q%t~g}$nF zddZ}y4W)t^G2uJGBI)(|45g@~fj^2g5ahHyz`eW&^^zUO>Po7?YQnLE>){}Z9Xzgh z+j7S>ckV*mJSG9$)^jm5wrfnv0a9F7Ch>5jKpIsFVORAaIEQ(b}@_}uT?&DBR^T|T# zdWz~d2D`ia)kbqSOOPuwD-9TYjQMj&yit@);^Vin6wwhemikjGuzA4WN~D32X*O*S zMsjAmva)1Jk2LdVS8zG2Gq&b@H0JHlAYx_{e{2%IVfbbkVbZRWlEw-ireh2xrr-n` zu4=e6uBSm5Io+mDxGGytSXWpk!}%zK$eD`ca?kP~A>OnGrQ zkPPF9ZfM=w>bOH&W+6gnqnnuF&QU{VK1F|sG6=krAO~xhz$wapbSRR1xKP?iA&FTm zZpG{8XuA`8F!(K^8XI&|at8d>!S#c6iA)hsnRVo3jfKklC5?sjbJ&mJQMF&kh&?Q+ z8n+Ot9Q5->#jUZh7)!{}(d=K^JEapHC1ya**mT_u@iZ!-8-gbr$@z!=yuk?5BRw$4 zt+xJ%Vef|?379eOheq!w$SulPbaCcBSiwO2X3b*%^ zAdPB3Q?GHvMO(AB3M?^b=cpX%(5Qa(@V+0|dE){u&~1Z!A_*$&M+zqxQ+YUDBMt~L z#TZIq@B(gz7|b6Op{%tT0LzmRXyUT!s!>!qJBf2b(v_HY%t?e+kMg#^jF%``1jE_UMHjXT2bY zg;*LBQTm7ufL!gWvp8LO&&@TlC}#o$D#2pm#PV5V%)Q4L*mI}snhHgv=(J9>q~kP* zn9ou1(`m`0kfewVdMGmOPJ15;i5Os0#*~;}aVxC_%iqaPNaAvEb`hHeT#&FSb4sQ} z!Kz9UgVUN*!pjNoEsGh6vtdj*(dgDlYU48*>pW*rbqN+G5#2{E@Lum97p3x@BQ;z{ z!+hn6q&fsj;jVvuwbR~SNBJE>9wsFlDTS+1B`hi?a6=286t)@k4j~N)!j0n7pTu>r zCe}RMX?Ekdb~udRQJyA)XpS)iRiHHFaTK{K=-DfHfMv+v zxE;_&apG&|tWl!5GJI5qhN1`qD)`clWVA#KitZ$YF>#p6bl`9MGo)%Xt87CT3Y*Ye zXJlrfb=4m_74Skhgs}Kv&k?HB251xufUn6+D;X0USJZW0e4B6^?7>~kDd9-O3nCFc zbRSj@N&6atW2t=QvEWHuqB4$uADzY3?DAty-LKTeLTN3vrMr^6l_Nk++q^D;7~COw zxn4gbkCO^5a`fXoFWW0f23THhp4XhADv_*^(?7+fqx3HY418RJ=aOnJ%Bj21=`97& z-#}duq7$=7{1J0o1fzY4tkiuPlQ4U988yKIN@99TB=$(x6!u2;Twow^Ue6}LNIC`C znBpNgJHU$f|I6~$0y0??x{ zpuZGn0kRS@B#VPUv|y)AK!s>-&+R?7lwOoj2w&sJ#Vqvqf(ylbfDDBuT~YHgi@Dl7d!r)5}&tkgeqxz)IWJtMtGIKj2jnxwxVJG{Cj^y4>CV%`#G;OvV^H8M;oq1#hgVk-tqO^97IwSt zpr48)PzuSD&8W(hh-1ZmC!?5;Gx|5UsI_z`K57WrpqEvQ8&#)M*AXDHkEQfhJ1E20 zU~yn}56LIz`!V%mw)y!zNG^ygmRo;Dl+4KMEDrErH`gt-f-vRBDyR)N8ZS8K^j5I# zoF0nR=PY@q*(S7dCLTdD@16ZDWieAP=M9}&YW^H@SDG`zp{qZpzINyo7TXOQjunpQ zLgdJ^Sw}HK*+tZZ1ShRJ|KfKHZlAQyJ_gT}}<_o!D79LBgrO@Kq@SeViC+@{4ty1m+`9rD0#M=a`u>Kg;?1q4PX zpkpmvV11ERqWb=|0s>Tn;2iG+Y1j856-Lk> z20rSMI7lby%sUmr^qTEBT80Gf2(ZjO*HOaM2sAA7s$GK|mQ^ytE{98?x|$6$#qmak zQYW{ciO%L>^x*;3aL7&M6U-4MHOP3MF@1b&gT|xQ{w!~AIPhDI2q(HJ?YF$($hAjpFR|Al+^!7)mG|2Y4XkI4 zH7><=#=MuzcSvuj?%nLh30dyFGDwn>n1rHdr`z9 zD(y620{};fY|FEH{;D-IUy@q)xYpn;gk<#xNTfFaqq(f+K4sUmtsn-^?C$LjEHAyo zb?fH~rB_pkM*ALQP3T(IrXcpIEV`Ys7h#($3FK{kpY|W96DUp&ix=e)QO8^w1w%sN zub`LKYB^}(uU)V2LXK>N5@vu{bs`Z$NohUCby+r1R00I>(%M}OqR6SNEDtvQjp0t# zZE6a%TXBZ7r>GAGlo;PSrzAB{QLBuJPe{u4=VUg|fS==(H4q;9x|L@dSE@OGDtN=>NvW&gK=Q%9<#LIEoMZ`x(5d@Er~slbT1k z+Sji#Bp~zsaqm@maq!3e%k4GjdXZg9HWwL5kXK-=g%kHp?jn%U#NBk-NGir!(#Rm% z6jE`_M}FgC6Xs_c2#mZ0T5hZ6AcEjq(aY1W7Z?C3A0=SZi@xx8qV^f(SDo-aAH6+| zNxBj_Mx%|aVZ^Jws39WEJ6o5b%ifF@jfLLpeLre(x*}~(b+%5S72qdc@2I;;8;uS0^#;@^lid3O*6GP))`TaT%BJ=jWB@OONzZo zhj8r79v)#i`Zz{AR#=Q4r?Cs>b5^?;iH72!!_hTWMGmKTwLBY;f7gnbz5;^sy) zEDObsBTM4)o#LIKWHL39kKbgcyLC6%k&syHNoeJUp!XjA~ z8^5@|O&9MdRZv8pCXWQydTS%v{0Juy+<=XbUtGP}?Be3A4SVaJuS$r@xq?&;h6lPy ziq#s&RQ<;aXfK2Ug4WK%u-^>}mBcuhpnGbhj13AN0=uXcYJEw3o6#ZqmEO?6Se_+@ z>R|((CLXX(uRszMg)#x_Xe59feFOmoo|Z7QnU`dbt(HG$Ct)U~#!5Gam%Al5FZ$|d zvLRS1*wH?u1GwoOXQS+aGNYAkW-+uG90Pd3W`JrXV$nSlLEZuwCL{pNS^d3}SPef( z6tf7;PQ)vb`lC`rB1qcbGa^H+1)-v`?`r$7 zI2zFelo`D&MKfM(+~R^@o=i>oM0U!FF<~h?!c=vcmzLy)a{iD2i4Tn_D`B=-s%XSx z$-O6G2Qnw*Fy_<5uiPO4uIA9g{|O6{TehODi0D_tUx*$W#QmSJnB%3@#Y!}9rzEJ0 zz$6lo_G&4mT?x4PF$y&3z-rzwQZR3Xr2i1m<1oZ zJemtE*E5)Z>M8GMS9dxCShOLf+WNoHYBuWpyo&irXB#{t)GdUNAb=e788pPobKvZx zKdvzncq3FKd$M6J(jzePQ2n-9@FvV8aTkG}&)ga_FSo|*7K*c5J!Y4L`$!bs9hb}l zP||rOz2dGIaBbeB;wfvXP-^x9cm&*-!WN+hBe688gdky1y-+|Aia9mlKl=rR|CCoO zm(`ySjdpz%#KICxOGsDRTOHnSKMQwsy$X6rzrm06s#F&{eVW7`welARk3WVh#ipbY zD_Vb|pqI{dZZG^HxC=sok=NVrJ*Ctq3SJ4134u=*a0>+fN6G&j8&iKVG#MnTH3B;e zTUpzzk#{*@MpK-E+^ROGdGF;iJiJQJA<`BJiFTqkh#t377fLC(TGgz44jaz?^bD>C zqxTLb8}nmcPg-Qc<#9U~d2I?WHG^1n$|#GbnXg_sXPA0)DG#*DK$yw~P!q`>8~!|S zsk(zI*0`ererm7_`FGZ4LVEuJ5mAVBOd_R$qv6j6n^fF(H{l)*kks4jv{}RyK|Ji# zr(YXNbpzKYmHAI%>SG>FG^THOo*fHjEB}W3tV-bmx>HqPhv^OXQYb&9M|Bew(r&+SUE9b?=mM)^4?E3^+it6gka4j^S#0_tlg7gCD$fdHww94}P%nEH!hW3Q8@o zAsvPYARsEzv;#KE+9`-HleaO-*_oMgqaW7%)btL~%BAGnq6#n&@qTE8{$UvGPF>1m zNq_d*SlN7R9W;2UY3VPS^CGxb#rbG6%`|h=?(yCjdDMhk@UCAY&iYF(z$vI&3_|^W zqS-J}I8m-?M?tU>UNt9@sxfpW@KZkAkboDi-r}bT(s9UYMR-=`Wtp7`)(68CZZ8Vg zBceL46(#}izm%D5=G5iR{ocxNf9}ugXBV$rTDkO*(+Eexo<&o|&jhfDgyt$|5<{2* zA$(fOA;&(fZ3Va(+?@UyLh(5r?A0jb8w@5)<+RG32i$w`GC>+#D#ndrJs;7bP>jkP zXeTt<^B#nL1@z;Ww0-P?1j0BZyVd5M^(}@>`i!%zY(z_Ns6k#2hFXK3bJ$jGu!6^a zHR___`p6Q-cT8Q=u!G2XvgZW_{Sj_7?a+k^TyOs*qt8MAWU|9Bjbw>XvYXxlyt8)Nr)|DS;Q0iiRDZL$!uiiTi>=n0H{ zsv+lK7nelP)Q$DBY@XnaJ;=V=Sj53IG!94$-3pEFa9PzZw=RT20WWa8~ZXdsc3o z3?V+~X$f_a`%vrq)KagHCVXJWyZEU+M6C z6y(x*S!@K+#DeFz;OK?MHN;kIH>;uq$rcjQm-iN=)npXqyi{M#pP{+$b2Vsh_jf2Z zmN3Fs3wnJRHdcgP-~=F5!OtZDL0^aC(@s)kT=msE19v%wb~yuV^CX6~{H?y8(i9MT zF?7FVSu};eE)=K3ZuPc7vV|+gxU#fqkqav=(;(nv%<2yOoEj3p9g=xS+c&^bz=(1fz#G^z$=Ab0>ZiVWEJ`cqMq?lq8MR}-vy}<3?1_?iA z-Vrb5MjA)KNsS$E6(JEz!B!1LBB_gZkaZit0rj6(2S`ALl*_KEI!FO*Ou*$~lj$Oo z;#9+YTcKT1KE~}h{hGgkZvPb3Z%_*A9BbP^Wq5)h2WO#xB%gj9??I8^Vq;{7z~2ji#D-Fd|=lv;WlF{S38-7%e>Z(|AK4lbpVmRWm|tw#&8H5b?196 zId_pHepQSimLvatq4c6u<{gCY&d12fZp|jMvR+FZO+_d}7cXK>a+*t(3#dv&d_%?z z?esw3JkuqJ#S+|M|Y>=y=;t5Wv<}dItfy!M5 z0`?G#_4bSiW6%#k^Nv5Q)j#0hu^$)Ij}Hgu@&JX{OgL&a49o>8i@X&wj)qq>dTnxz$; zBemAQ7xb&P9kg1MToLoj>C@K?vVEkp$i@(a{^_n3GBiu9WNxtAt?q`~sW=6_z^5kJ zU~?ZCu#R!f?ey>h))P5RKgDFvt|JcUVy(NdhMr{H+fsISbL+uBv(ek zbRC$RokSsA0<$Xe1bUYs#GpbO&KPKwNU+Bc8ncX}!8VYb%;B-`jnxP8<|U;YsW;XL zRb?pnoc5LxR!Wh$+qsntkO#vm_2b=iF;0nn#a+;Di{n`Uy%WYs#3m$d;`bpLe@N85 z&lA+SZzN2XEfqkbA{00wm*4pTSpjLzBdkT7{2z`W4zeryVI<+(cD3 zh%X63a}sF35Zu*w>fyVD+mV(76(|+n3GvJk?fDo;iL4Lx2+(cp1D?WvLIY2F>f}h& zTg`z5iYl4o;y@n_80odiQ>4%EF~VM0<16)IDnqlU4ev=zS63W*fR)ZVRthf&*vqMr z({4o5Mp-JDMq*=)Qqx*AbP8pu*(^Jym=zFfsaMzeYS87S6c+!ILBD!E^E$bJ8;Q4h zPuO}vmgsRpK%H$5%pM6aNYxy!a=DSy7HL?# z$=;Y24DDBXtrqUL>yUuzxBz||k}2GH;iH~jUgYz$<}uIFE=)p?dgT$!a4ywN?#D&F z%4J3nsNz=K{*FVnHE0SneO2k_2UXl>(M|hOJ>eB!k7VbFK+9xBtU?4Mm7I2e$xx<1 zNCku!To4UnH>anBkO=)zPdai4VJDJdoS-K5 z=?vQtr-_9#?ipH7pXHLk%Y%9mtR#C2;5Dy!n3^-Q)_KNG6*x7aQM1VK7!!?37GCYZ z-wm3O&q3w{2*@{sMYiKoyk|Etv6d}_Y16**W#Uyia*%yxU1@oFBmFLdU-%rH_XLz- zDFD`U+#Nvq`NNST*By z)a?aNucq-WT>5|5dmGS7&oXcPe$tans;Q=$YPy^5p04R`(mnH-Y193u+1Ph7lgv*3 zo6Mv$Nu6p-&Y7GiGv}QoC!UYaWd8~Z6$;zh^O2lPCtBA5v6JUK&;4=T*L{6|@O3x-VQ_ub#U%@(!C*jA zASh{SH1JVg;OrK9fRIG#<_kB7n7per6o7hZ0kbf_WF8e%%0hW`bA4>3{P`c#>Z;-k zRbz%k0MWi&dz4U(4eSS7(|fd z4Wj5KpvF3Cuv47owNd&Du|7gTMR1zbg?4)IL@>olc_$ZKF;M^!BXCVcxj&zoFl0JEkswn+vvEK1{J1(C_AL zt^CE7G5sQvjGo+>Jqz}8U}e*cn2cBTA!KV2t?U)b@?;m_TRVD1>mR{}Q*UU@54D8I zGi0FF{sElzN@p;&)*J86(uW`n&jfQVV0P|9#Aod3BmD125lz&qa5GKZjhCO5Cf4MF ziNupq?Hz<|ScC|g>0la{hl~?3M~^-67ta0du^-$g+7IsU4n4TP@8#eyvIu9N%DGP+ zaE9mY3{x1}`}rLxD*YV~53Bfl{`Vhp4*!z{;XMEGaJa@PHnE##bonCWziluhNa1E3 zz~y31ki>~>tS!yKJvW1^paSo5TzSL4YR>O)750Mbm8)t^rn$>k^EK`D<6PA($&5W*9t7j_LTpv0Sso~t3J9T*3v)er^pV7Z? z7L+JwT+MH@k^;p6L>|P8tkmZi%)#05CU`-3{h3&vp~M3t|NIMZvd}*cj|xMy8Pu%m z0ineFGmU~#5h1lEieK0Q5`I26blNfu zDEol-?d-Klt2xwIL)g>MZuMqC8tJS+I-;3sd978pcgR7(QnckDEG;Fd>HI9d|8mk9FS#M%vb>!J+?7YexIk0gwOzOIcGSPLYaVMAP% z8XSX2vTG4@9&SaOP26W-H+F#avg=`(KLc+eSU)OANK_Iuh+SIh}PaiB1J4 z5oj}UYOS@2G>Tz!QECW$< zWA;Zd3g^{erTl{+nBfmgae+UG=f28?e;JXe3<-eG?Q2lNF6lp=`&zdd<5z;AAru0D zd1rW~Qb$CF8=Bu{R~jrF4E!rO_pQz_IKcMt9ozbMQ(ON|w}ligX$%lq)Zl{sk!&^i zLEyItc))rg@MwRp6G+^5xrOiK+&>?A3*Wab{2;Z3@8?Fcfmx$)2qQXdw845I_2C`B zFOSytQaXK*rlwD}F8z~gYid6|_X95V`#JZoM_%fWY^krNminV3FZI>F*0sLc*LAV4 zavU2^V&*+G#Di#h4NZ>Na&sjik%iw z)aXhOEnx#imVB_e5U-*Uo3wL)%z@g05lCZ#T6n(?=hPn&GwB0Sz@4r}Fxbj?B6UI{ zK&$~mUnC$!tH&xw`LfO6S4N9X64H`nWtWij7rF<6wVpX>@v;+Esp^Dnx+G7C|l*4#vG0f?Fq zXRkMQw%@fAAuXgF`n65@fACfw3a*I+v_1?}BNyr%kpe>)EDDPO&mL)M*Bgke!w=XuBySoI1$8`)!IW@#YVHO%*%Gt{ zcS*sEf~Sq_;7Qcj48zcUyAu@=LWE_C(ChD@(vZ;o9|)=>)kWqLFc-yKCo$IAd56zZ zq)h>EHb0-g3O^jqgj*@2yh3fK0u90}%$eH-dJ9Xs%BZFrk+?GMaV5z6xd3G}O((K> zU_j3)VBOmJkw18Ptq8`6K`ac?6I+m*BOE7_8S)8I(Z{skU=WkPdDLHy_T;;kr3hilBQBvCO@?zyhnv3bf|lp%PK9HkA4!Ph;uwC}~Apiq8tTB1HMrR7_I+X5>Q2m6{K*_js241k$TOA{Ba|)X-}< zlVFX13q7cT3AnOoND117A2Abnyn1D#E8y&P*pN{9Q1nOCsjcl!h6{Sa5H8?FmM&L^ zoiXESHz>Z_?WgKdEMSKyUmH_|fL&C7L)EBT`&Ugd83qF@Vz5n*LU^A zs<4}bnChTP`Rb*HbQr{iyII-~-T&>3n6Vg8+Qw@L@nz&Rb}XV4F#?1Mvm+64{9Snn z9MqnNQ4nMo8P~82HCq4~bl_rd8vDOz@PVu}7AcUd-!50iEX+U}1N;#$4eAdmX%GaA zEz5cr3GWR|CgdhW07?^7awMT(2TO`i2PooJVTLQ_CrvY|Nekiz2i{y zA#=cMxuKNXYib7};}zT1l*Cb^YBBdXm~=@C+|<0$FjjL2pyP}{QG+^@ZKXD{#Getn z38Q@a1FFZDN;h-qsJA{hZWBHJ7<;cJ-T&^1e|c;w=l;!pd2H%=m<2F2!ggQnbN|t< zLs^r$j<;3XmtU|%0IbXXXZt`IYr5CQre>Z2F9%kfQ4#_p#b@nFOjS^|0g3Wx zZvsjuOjzzxxV9{}0#xo2hOqd&I*jGtGa%WuAsGbZ<2QUfI)tdk^>PhXxa|-gx;pym zA*vtb#4n{Ucgy~XnX3@qphjg@aDI^$v{j=*0vjXzVaxvcGra2QPqg5cXGcjT{rF=D zBl2}PjRJbOB_*Za$P4(S7ir&(3RI+Z`I}+{6agOBfoTEZbFvNq(IDq-(lAM*LK(tf z#Y*T6&fpfb$g!9Q79fpWL2huD{BURieKWvjlfE?$aCOkqit8l#?tzl$rSTmJoGIAtT8N1#+4q5S`qe9nGaw4>@* z){6KncTU}WcGi+N_@$bfo<7Z}WB2B+m0bNGsmI(vnOa5O&xii7& z=*3-*>yVTus2J{$QSFUNiTL(_IS09g26lznMs#Dxf{y4&@k1ZC39c*aP$(fnZ4O$8$ezKCiW zejJRF3bZ~PDV3p1W?vjrI@9{OxHb3FXgOx&e>7-W1F~l^s+ZT(pG;Tk8wtnqHV`1V zvV=H+q>#K6KO2`=DlpOb$ta^~n)UnpgTg$ac3J*Jczb7PW?l-0hL<7xg2IfRt5yFd z0ubE90@1&^50*sZ{R90t{=x4dlD+eIdOF5&hLh82fkrSoBAJ2^l+x#hh^aB7IcW&l zo*wECrfp=~6$?YqH}P&Hl7mPgO;+LDL!EciI^EEy z3Mv^0jh#_j5W(|s=W*s*7MG%Q>Foz2aN|jGQw=kq;;a{Aa1P+)LTt}R+Yl>L(CKr2 ztH?o=NKtoB{5FnpXRt*GO??Yd@ev;A5DwH$c3yj4!^2A3sYSP)CMa}F`u(B9bJX=R zxRaeHn--j1!R2OTz+l@v)8-4i3=wemT5zf#&6T^)5)4yZIR&)Id~`b zzwZf06Cf{@8SVIo z^XfJtSdE>zM<9!lE8t6f2UCQ8Z#+dF(V{7Js%W=`*^YJogp`wQHex;$x{}Cs#+Ag> z$jA>A{Gh1M7kwZI1=h159F6Fr)?Jcu#X6IaSW|6D_e1y1b7CFDw!GmDTpMDHSbk{* zD+);Mio$46h+$iw72{>4Y&I!`(L#!lZfd5G$D8+-W}1XdBu!#WU?ZYC9b2&x_D{hM zt%5(Uf`Si$!dtNJUjTWN1uY>!f?xtxE)_>Euh=ozDR~0`zl6wq{j=7q#5OCR>u9uQ$N;FevJM7dGgX*N3QdL%j(b|$XDv`+N{wN96H%#wCD18b+9}1@ zf}1F~-TfwZkrvVyG;1;omjCNW7qnJ+0fAvi-HI>iSC4W>L-#qNMwe??JVzg)UAxeI z*33yo1x&F(Ns>$qsFAv1tSSTS_}#cea(;%rN~b)|cDzIS3JD&=Z5b#R)o{_#k(w?I zSbrf9JQA?LD9{r4Di=sSgmhPKOE{H;90Bz~9%CXWBri1x$OkUSCll9*%*@7nMftD@ zBC)}%So~eNi(j<6C^)4Az2^&w6i@bGqxT8It#o(pawGeuehg`|rdD#qX|k3qiJN^Y zw6}%ZDSac>7Bka`n{uIz9a}V5-x#5Ln z*cHAcZA-YPhPU;01#wT+{sZ$zt7~6|1hFhCMu(`_dGICFHDHk#%%B5R5S#*ND=f!z zE-qM%O$1)vSQjf7{)y!{89Z|my`+2_sB z@=j`m!l$a#iOWP3Y=fGsZIqAdJ6{(B&72`n7rzgg6_(CMoQ}`mu7Gx3IQw9`C-je^dh-6~WvLzNSTxQxgXpgD6 zbhec&fb^I#?Uh9cCbFYE%LJ+zSzKg2mf2YYTU0iMv2Ind!v)*KMyzumL^g6$rND+# zm`IWh6bqtzcl@AF2RDdB0;!vMO4_X;o200l;E*5pcOQwK%p#HBSZ`S5-~J7v(GPn= zBfm0_M>7a|t<bo8k#vnlpfiK`M?$818%Q%;hI?R*8gu=!hOG6_sh0Y;BdO0Y;5@5Xp_i!t_$F+3}G`{WZ!5 z{7VmyGBkYRJ@Ro?iCN6p9Ib=}So(bw1X8>98lzHj(l0U$XjV*qQAW-s=-0N{@Fk3) z!}`zFnVVU!4rSB2Pn*CeJL_50TMt;wsm^vC=HucW@+u&v%WUN@y`hl4MRkcu)vng} zLWu@A5HT3!azunUF}E08GLJVPmR=;oT4-tj_upxPec`__2qHQJ%vhO*2d6=pC$H!I zM4%d1Eb4Veo8R52He-Y zQJW2#PyCp=NE%>7Ph+Bk3Ksql1(z*=_sIY_ElUCkOzvlFq=|2w?w!DdPm;!NTqw#1 ztgH(Q1^%gai57dqy)n)s1F8j!3B$kSWyt?2F{qJ$AKQmSCn+N`5eHbqSHd`m?LZ_S z)yiZ?#Ph-PFRwrj)YvE?O=ck^V7-kkAtx4f9l9$W5jk}C_o|w-k+f9AW%ya(jc&)2 zq+|&xiWWZIhF~5EJYpr=NMRQsYg=u@GtzMgbvAkGoA}ht;-)lGQ4a>u)aoHGYV`6p zMSJ|_bFC`7h${tHH(A(GG)w;FzqB9(iE6)D;TuG)Wc@J>DqZD)5!)mZPV8mhO;BB>^tyyR%?pfC><(G(e#Dv#!uPk`mro|q~cIv#-ySJ4@T~Zo7HTj=+TU)YU4z%&V-o%)Wy2P87zuNg4GqcuTOwh=fm!7;Gsc=5= z3Sm^yLsiDSGaUFc;J}J09XYUxdEuw=8uj6hFL}koTw200{fo*zWBHGy%g>i$x`!}o zn000H)E9SzO|RS*oA&GXR|{=R(~SC+cAj{bQO6pcoMR_8?V;M8QI9DSgrjOjmJHG^ zE(A$;=qe$z8I*6ZW5pVGA1STQN*>&MXvS(7sORv zY*eAAZir-wg;r|lFNbu=juJfO-?Si+2*_NLTJ$@rqO>9a4w^vJ?ps$1C6XaQg)olr zWB8(*8fmY%Ig?V)$tSRww5V6Z5NU7T{)rDw{0x>d#2|M$Wuel{J!Qy5a<-*wsIC{f zJ4?5Z4!{F*Y<62gxCTf;#%}{5x@<9WcSBC-AVd0nL}S5POa(kTY7q(-ErOnrlEMt9^#-A0G2!*Cw))E_KlB6t9? ztzFs9p)p@R=C$hx!&5J%K&|Fx#94}jNP3fmB*(+YmfverCv;lzwvut#CuB2YN*a)F4!$6T6y zIw<-u&^VG8)~P?ww>({qYG+VpKmTZcg2A34YS`i>jql_isDcBF7VQ+QboFsBy=as@ z45~<;i39m0@+fp)@8M3<^4gS|HX1F%x$X2NdS?q!9jIah7dn1uL9D!ZzG29P{Dl>w z-;jh5YD}wo#dT2Kg$;|O8KrCb)G`BNEbL{_leAlUZ1A`$?7Xh!dt8Zd^xD6ThYHr6%aP?G~|JKG$cVyWRrF?Pv==P2{t{26*=}y^hQDbdU-oy#Ts) z8RQI!L-5^P5S8hPocjga6T^Dm(~K4?zZ^S2TqRv^` z_Q^7EK*Qw8>sLH0_3}6OB~Ch#%4r6xqwJW3zNtrkJ@9CZE_n!I5D}Ykz1|_|BhM6V z8Nw8!Y0FL#SrJF7ECmL7GrqOV%4<~g;P&XRS-p+w9%`vE5C_N-N97SY*LEUbVnt$Z zO`uwgt5d-@mfc)pRrT?=5JKaK7D7sn2&=ZBbhPJ#V7`U=WuoW^Ml)VFbUl%A3FnHp zr6vl0iLhh;OX>~~5Z-KKiv3czmKQEHF0nIz|szrPu6$ML-f znxSH_biH=(oL&l0dg|_kJ8H&B@x<3Y>upDg4?fd(PFAM&b8l{NJ2ioWk!<7P^ zkSK^mL82?V`1IG(Q8ES%~x~rS-nNqYsxcnw=FvK;=Ey!8DBccP3C@J%m7EsDwlJ zNS#WgPGSpcAx#=cnxc*w%8rBr^5Xa)ZA(C-dy{H7N5jPu&$xu8} z%>bW`O9?81|E1t<_6tRI4IqsxGcaEY*{WO?8X4#UW>Ei8Lf{!nxf|Zgc@FatDYBDm zwuYQSvWK0-=N+%nr%nJv4`Kx=Dqu-gLx_49T+o4%%LJs6>P@Mc=4%@K;qVQDJW3{-lVU=D~>jq$nK^*_K|&b47pzU?=CC>>0QXvQrcdmbuh5*Opkc*%C%4MFC_(wrDW4#bpnQu%16T=v-1r!TW30vFrC zkV46m_cBEHu!}6X3mxwU6fKb-z%-ED%@z_Q--hp|9Wv`XqsGbQ$GKOwe!{tEoKcGg ziCNmHLoIHbskzWH6^o$_(p!PVgBDYlVjXF(tlz@TnO14n789jCjs-mq-r*j(#kXozMU5zx>vly!VRxfcFp<`n-Dr*cl2kDLu?K_$pceS9x8n7AaYxjY=Ac~ffbI3 z!#Owzh}1*wRwn{0@`PPG%Yz_|n#ePgNrIU_jw9_BHpIk?qfqH7B_J9ab^GV`N7K(A zO10tqL{A^F@bUkR80XjPpc5#ax+O)ydFu!oBc7<`4(7Oq2OY_^TpUv?(f}3dsE`a& zz~=_iqUaG+?srQ;OjDWL($%zSL{JaLXuW3}=~;P;HCk*M)<6`W6#|Hh`rvJ`T~B;s zW*&a9+rj@1bAb`5oOk<}_s0{@-hRj;h%67SHM-5n%$*L`I?Vs5Hv*x&%CHz&-0hk- zcLi(|$4(o$w$fkie(y{T`~yu1hcrRxP*^yB8a=kWTEV*jNwD|w_`*>=Avm@o`R|b; z`PWgbtiY2-;H8j*R$1Y0ZG4C=q;9bu?M31{MZVskHTKh3$cA*CEA40AQsGSN?JoxT ze5&MrqGi)PbU?lR7X0&7s;M#lET2zT+i$P6pIJ<%;=aUdnbCoTYHqzqtV{qe`}Py3TR zT_gTH(UJrU#8Lc`A`qe%BBJW7_>}9*{fr;+NK+0tX(EYYU0Hj4S!4L(ejDM~`tA)h zG2C_cWs9YpO1$79oD2FZMsb;1ld1sMY zT>#7Sdn=7ysDvndy}h&x%?tgUU`OL4>BFt731<>@>wPTgd?NR8Jjqm@OVSa%7cpF3 z-!1vBmuG#^&cUH);Xje~Gytw71xcar26oiJR3at6(Nu*_DZb*la(+?E9udA~=UNFM zZo=Nmn!s{OqO6Klql*ZwF#~AXzI8H*2hfWrtQe~CFSeqaFri^;o!_U)qiy9?Jja#t zi>}LgOVuIhI(!l;Y`bDEHzT4}o+(4kGYKr7-7ZT2LdOyD#@j`|G?(v~2iv7!2lX{g`qUj?0Ev zO~Ab+_Y#MQuZ!&kG!88XXns=TqPkZ?28xb}=sLW`B)lz2? z+ZiX4=!;Ox0izf+SM7##--|)Uweiq+5(POW+VHR@U1w~C3+I-05h9Go!;ei;7Tof0KMsf8vof&WQx--j@4eGJkTXm9k)Ba0|sAB~vj_!kM!G*Hx6Z zK8L~=C?fy%+>8E+02udI4^6Fx5t|H?0LeKmmEA~s+MYi-iaFG2$9AqMGa|bz_tuKGzfYQ-$VKQ`Tn8ROoi|b&W6_X`Y_8~bJ?~8E_W(O z+6c%hNrovk8P`>kWW5TASwgN!6^m_c3W`0fcCmkGA-&oS^rVW?6?;*r-vvWfu6Hn) zkmK4wNC%3AE}W^}qLe5Y_1AcWD%Qgs;3GjWey(yWDzBenP9mC!?(Q50&j^OUYowJtCrin*rC= zUAy7ah($r8bL?#iT1lpLkS~}Xun#RQ^)B5sC{^Fwzv$evsM_AQ-tV68Vdi17D#4fW z1yI`!z`41N*dL0iY{dqjO6OK=Zu(Sk*<#ncj+Oox8aN5hBrXu@BxBRkD-mjJ@<2Hq z)B6CqePqJ21aRmK6bup}t{PcJ*L zvekDa!%(D14aFhU(ntc1-BZ7XxQQyy&j%X0M~NPb->Gb>)0ZifnBSf?Q#Z7eY=#H#8J zR22A0c@ngsm%tess4qr7hmg7qy4Xm11TDY#-l6$oZ#r#Pig&J$5F zhbE(N$D$?dk4K8P^_Bqg5XPWFZ8y2N%I-=a@D_&vihW-fzc1_!x)>`nSM0E~jd)Bf zUn;6?V6WhBS47*yd27MuW-6JSE))c(e3iWgta9g4*< z5K|sjgNSW3bT5;WnC7lxg-2X3@OFUhdBWC4-`q#@^WAbT80LRQgDEqJXyfL-u{d)v zejjZ>!Gbhug~i$cP0rEsJC+ew=+Gm!h2cixep{7&{}mG^RM}BAZUuEoHL^qw%7a+F z(K5e}IJr?__ZtsudJchHa$;gB=#LDz`fJ;U?CB_pGl z4O{f0EV+10l#^|$vCqFGXEafo1V}V5lDmHhc889HS++HCb&{y(4IU=C%Lx?E>P_C< zKZ6s3gXG6+T#=A5{Fih(wx1BwG9(LBBW5X6$O(KQ0&oY}nG4A-mAlaoL zg_N?cC{k(k_x)XEiQv40G`0I=ZY{vEuxYe%R8R+uqcM7TPd=(Qgy);bpusg zpl|jMF9!2v+zVkS{(!}?MOYIOo}p*pj#|eF;MEb9L_Kvy1tH!#IB_n)FQe7|b{M6; zO{8D$K-xR+eqgwSd$7YQIOIL1&glZ7>%)}Y? z5%EZQ68}=upeK=@5`sRW?r2eoJ@YkwQr+Ba?y*f2CPp^IN!A}Hzce}kEJq#UZCrbQ z`x%8sFjBztlp%9xQll%qh> zP5rgtQ@laagi|(RRz@8@gz+V0c7vN3OiATgCKXsg%+@P8IJSa)nw){6?jCXwiMLDW zDUiN5aTQ8%EgS(pN|7xUkM#at-CqZNrbmfzFWTXl6UTNGKaF?fg&dP~J#S8X6@J25 zV+C2j3WVcBizl(Hmzo?nkj=Jnz-h`pRJJCSFdoy1EkT69#uK?&&Uihr<;F7bL284d zgDx1 zX}gD*N~Iy*UFMx|*;bZ*3O~}vQ6<>sREW8qbhUvXv2kY}-?&I+<=0)QQ_90_>!3cD9 z8+~x2T4SvmLv|U`s2Q;D&3zVKSD`rYLuhVzF{U;CScJrcn3$Xt7WAD?1HCEtxnv;a z(24lFnH_3+K^YxRa2bZ7H|KtN0l4EiirqOyG0@-kx^*_3QSY#_F@bN-<3zR)UYk@WfIwR(N_zI*Nfh+QQviL8Lf=ZoJ$I zVT_USI~d?S?#Fx&&=GWu6T;xXc~5|Sa0S9){k60cq0$7QAT0=N!8}3b(etk~uqc7{ zVCSpQ)V0&^@IQ2(bRX!){|3vu=$5mw7gf=U1nqL`QFQ~Vaig*m6)vmw=&xb(YwtS>6tV(aXr)FpMAI>%VhZpL58!&nzM!4Rr zZ*Z)!dhM|%o;dYn0e;L4`}e5-f_=K@>|rv_KDY3peuw{I3&S7jcOU7O7UOVHF=KPA zy0t~m?%YNF?`1}@z$${b-ADU_F?i$AMS9!ekD+NSsDvM{!_QjToPhs*1FGKcwR-Ya z4sA5WB;o#6Cj3PIjQd3Y(3otc7Qw-=zt^aa143*2dy(>-^Tu+f!E!#;KQv?JOw)Q<@-kt_TglNF zloInBZUg$$@55B(9t!3uDuQ(+!aRP)0#E^+z3t-h;4QCEoa0S`U5gy6#Bgz?z2Ub- z&h#!?N_F`N0N-pN2%&+FLtF>($`2X3Ad@1(J5{dK_tZS5sdFL~;YP*l_tKPxz=Z1@ zK{k@afTvb0UVQ9Cv`re{$i6U*wMpcMG{gZWeo=J%{K7ToiRuF0R{$gckZ+oMQ`O-FV?{(H)Dc;GPf zCCA$xU6B5B?C({8^~_%e?G=k~eIKUqojmFS0=1gztbaP#L5pg(ep0-~G^}}GwraLe$7OAiR zA$qCfl{(^>K8L}1J>;&YX0g5yI50mvA`j*jf{;%i4DdZKt!mxI^zpWEQwf5Ajl9wT zLf|&}#vVL2zY&==%rzDnT_F1)BKrHG1UvluBRjO3=Fs|wltb$u$grCI?jQRH&u17Q z`FDNzGX9^uX$oxSFQ%RQys(*n>>qYt=sf)bE8&?|$I-s{>&VnD$z?KG%npp(XFQgfb`T~xL^XU zf9R@9KO5QFE?ujCt3O!v1KX0$@ws!~IA|NKiy0iecmn_&zuZJ+D4q>^OuKI#G(wkI zX{-ky_9Dl3h&o^Eci$D9cp!K>aR1!-kWSx>N;hB!Q_R3gp~!COM)tG@)!90e_*%bz z&=TjK=82s}#A@Xl18JQb3Kk%^afc9-KwcT>;T<+Jw70fDK28002h~?v-giHMD&7@M z+C-$0H9s+M2}d4nHt7yVgvU}~KnJM+W505C1*R7J*+=U{e7u?$3@lKsE7i)9-ezVP zO8Q*X0Ssdk7d6#2T($jWm(@4&^-Hqv9v8)=O{g^#SH!lS zmVvy8rIvJIcDxhXT!uueviojI0&@0wzJ+@QS8wloD4G@3gE&4b^=I1&-!p>zb%@e8 zAuqFzA!bgj?|bNOkhP>(6BI>f|@CX(f?25^XylTFTX;n?AcfHQ}#(d8sA zRjVkF_%r+pgDb={vyWOnBmHH?7W57M_8`Ne-qM zhS+j=IpIiZxQvNo+J2aTc*g8{O1{bv<0Ou^<5vp8atO0n6%Eez=OsF}g`HB$V~A<1 zIZ;Dt^PpTUgliO|ovB=dl=mDyi_rxnk(Q&Ym1{kiIer9-Kq7k>Hz@$lwS9^Y&3djN z$z~TMHah@?gTgNqXP9zGeGlfVsP?VCVBr~!_lwHQRfx92a+Y^W`&Xk4s9GqVd;@Wm~^M)=B zLWLg?{6RY14+f*knz}5Hbqo!-^!!)1A&ZP$Vr|~R0KAWTt4IIdpV}JES{}M!u+%z# zpL4JF=SFqC6c;h;;P3??AC@+J8G%SS_bt2xdFQ8@_g?F7d+#+!F?f&v$qfz;x_|E< z90K1=V4N&uXz-cGbM8O-gQ*uDXVX?&;5}~endzLnZvfAyQ_pjF{zT6GD8Tb4QqM8d zfAkM=lED`q&&R_Aqr(@_Xcs#eooDcOJ?9qtg2D<6@fCf5bY09p8tW-?7#cnfAi8gd@dL6kNFbgvALZsT_IwT-TmBNDHDvRQK(dWwXZ)=Fx+k;~_{_!3+ z<^O$-kk7d_|2Oa&S}APi)bX*GaZp5+uH1k_yt+^-t5*EZ*8JjaUI}aQSNjZ0l#j@ zU4d8uQY;sL9Y)JNZVGU?&yCAu{L{U~AuXeZt1EN7)lFF(>Dld4WycM>8Na>-*wJuY z_q)@WNNdsB_T1at3>M4fKG zK@*DHJKT(-PYj)Rx~U5Y4P2?zgcK7@+o|Xsrv(0z$DnD zAe;L`Hv>YlyLr7@GuOwMgD$^-_$>F3o8Y=JGxAv6AGvWr`i6e?$5=!<2`W#3=`euh z!?JZVw+jgW#33IDax7%@@|DdUe~;ef&MVNQsy7zG%$b~<2uAg>8+VB5tdShMN8GYL z#CpzRpLRHZ>K5#%!3H*O$UyGT9IoWhvalgWq|po{=F|ECnr|?m%GA9Bi8d7scv3Tw zTxzPeBcgi#&_dg!>U94=@Zl5@ARmY?KTm8q+67Z6>{QE580B~Iq_;0dc1r)dTJuwl zz-*iPON_1do9iDq>Ym&6$9o5$p9TXU$U_AvB`AM(Z9|Cd^nt|o1AT!J-s7aR_Yd58 zI(z?-f%bqQ%0nqcd7uqkha=2`M@E?5zUUoc9z62i+Y#m=BFqB=?qU2Fd=piwIXSc=X)>k0BoU z$B+f)JHh5UT?P$ucEn$vYwk+i+$AutT6yq1OdwKqSRJKXAhyA)eN&)a{s-93hJAs( z(O|gAOqxK;_H~h1N4GgsH*tF$%8$%gUmOTtPSMN>KVz~K2-BEuQfR6FwGNx-;*n8n z$xv%0g<4C3TCetvx)q`p{(Sn#7RW0`2e!o+9&g%0RP9~Jjv?Bs=JkFXV(fap4!Wi_RIal3-f#t_C@;DFdj z)Cq?!8$S9d;?p5;A`oj|vcfIvJ3XUV88P}!DGAw!frys#{zh)7`sPByN?LW>`oySA6YSH-DCBei@a zne|u=yyOu-ip_wsq|iy@b=-OgRj_Za#F-U)yneL&a5$%#|ux#QEa;;gNw7SSST~#MjjzeB+=e!f^6= z^aNr60Vb6#Fexd>v}3T1{2dBr3k|SR+YRTgLleJt zonVCGrovn{T^}pQe!8(A&{=tW67dIhrOWS zmI0ccMZnW9E+%md=p<3}zr-vtwcyM1{4~93?BepgrZD;mte1552!Dxz4?RT>DbA5O zLZJ3hxUKE2l{nnxx|^lDT=#zf*Zp26t~)_$Widi=!3?BfQ@*Kd>%imU#$zg_MM!`# zrQllw!B;b4IhBIBHC%93F@p~f0ksxmdlsS#l!ahor*vyW<8V9<>~#8aBw6ky8k#kck=EMNt#w20&inU*D-;V}_e$ z%-U8^!2sq~I6Ih*PJtR}fly^(-KV6*QmBadl2}GKgCTPzi$^auN}b@UBDX+`plcIt zx1yarr~+Q1MS(NzYJ&CQe{q%Kd3cxMEiB!VT2mH=O(|I=b_=XX{E517iCYEP#H@Y; z1@a&^V)~#yfltq`B3>aiif{ph8{Gz}jDmAB$v&lIelM8jIWDL4K6`06n82Z$X6SOG zOTi+8AQ&+V?|2Cc4S>a34oq)NsZW3wRtf0tpkt)yN+N#ZGnIJKU(VTFR1+xwyB~w` zwdoQVQJ>}6&yKb%N!gb2vKJL0eu(dgD)elc0GwWwEE%T|ml@hbk2E;y@29?z0*rmS z4Gj-DyxGO+zLQxH2_kNmdRSHfm!qu`{BLPM=K{3np%XSW$5-wN&YR+%!1pQFKk_Uc z>HuuLv?YN+@%)Ggjp9FKE9CKs{ER4a_-iZ~-qKEYs@MQ@Rj5K=_509LO|hN}up(#? z^$t}1lZtGUw|@jr5|(YdjSoX$BR5=@%NuIzQk9FYTG7xDY)vsWyrodBH?P*^v^?@| zh#a|}%NRfHvm5CBFE;X`&n zy*0qLPE8meUk{{C;DTR@%X}Sl!)<-Y zVM4RdYoM^5z)8Si&8Iyu!KMhr+XtU?3+M%WjHb85Rgm@=D58m^&-b75x^UqaFx@5x zkn9XJ1d-L@2fdl6i{Lr(+c$enDgW4zl|BgD;CqFiEYm*a2Jh;uz{9&bE7*uv3rz#Q ztFsqDH$s(A*mpqtQL#a;x?X+sJoB=o$e$v@>I=`Z_Kz8H zAw@u_&3tI`BxQ}b2BHd*4CgajF;fp{r%{s^EliO0z$|K&I_jF-H3d@{`yXlwexoU+ zi)j#^KUVM7F_5m)UR!3`6Ky=XU_6eN_gQCe8vZ3s43HV1E#e;} zg4edIC<<$7EmDCI0dnv<7@oJ#^7OfFmoK91KADf|}VgZ1R zid3v{u3>Jr-w`VO<`<96Gx&1anAPSP6a$JkuX)x5Rr`3T`4d6sz=Qk*%#q0VkdpPR z=m}vJ=3vGr)Qbpp>LR|*2U#ZGU4E7D1&u*CP<;LI?TW9Z#J@TrwKc^sWE)6IqQOAd z$>mBTGHy)mW(9#Ekc$05(3tgQz(T!hD1i#Q3FQ&7Rl+vRyk9^I)?1wkqzCRl3ih;i zu=J_Kuiut0Hu1xXR~Ccb0bi__{u|W=dPp;lJaJue?P-LKcW^`xY{n5O?pT`1ESf~9 z)9q=q-e15Wc2?DKncc?o{s``n-_;*t9RtI+qvhzJjfwL{T&%n;iXo|oJ@{Su9bM~v^QbPpzy@?_E zJy0B{Hkm_XatadP6#Ki!F7_A%IW)Q+NOlhVi+Z0R67B6Nw~#U+d+=65kn=4dAhc~q zTMdSt_%E#Y67Oy7Ck)*eaZh4D+x+f6lQcJB5(2}agAb$$;dZD_T(4|eD_xo2+!oO- zWH0#%#36-J#^89mICdIghIq~N$0q367+ zD@X!WIK#gWSYrnhZ`bgQ@vO>gHYhJ`RLbvZ4ZEG^2`sS%#Cdro-ogQ6p5H_`W_3s0 zkJVikvu<<`=M4_@A>8+E)Xwp7Zw6RnWH>;PRD_$6!t+ei0)YOYMQ{+kjOr`MoA&F! z&h={r1RIu2gIf$yCA6r|ioJ+mrn`GK)DrBLXCMOZ1w-e4ZXvL;Qkx=sNJ8o%#fguf z0*znDtnkdD2L%src6OQjzodd1?ug%-M?iq2JroRgH3g&Jc+?!^DbRrIHc~7z*~}4z zw%=aZT&Epfk`PMU%!CO=lQ^QbU9|(5-O5E-@FR5jpVb#~Gd*~xsJk#7?^m)*%Osp5T zQGGbL+C3A)zqS=FbIi9MK~W_0;nHfJK3$>0*JZj?|RzpN@WHwz;{u;8`VP@V;oX5egpWnR0XEA77E{QORCQ6c_ z${ywDgc_3212G|H;QmPSM!#*rEy^EPqt7PL^d=3_i(ANZ=t{vFaGVIw5Co=0ll5fe z42x8_PMJxPJ$NfTapu`8)S-LBZh_wzRtJ$?L$p+nP!zPVd7NXJl9FE-hby+T*dk-2 zU?TvQTCdUn%jkZ_>2Vg|0ve_@HFcM3R_v50dz+Mqj#dt+8-<2u(O&q$yvFSdUTYUO zyi%8xcWi<@DPKf1(FD>%9)ACVMo{Zj|Nm^0vrLvKPt0#`qev=~-of_(e(hSGhC4CV5SIh`HtI7>{FNACnF%N*N13it$m9PKtxug3FKX!-y>r%6CPq%c%gcB8r#RCob1reDb^+TopPm=S5zu)?sJcSF1D!}V ztBWy0pvFKR9pY9cPYsuyn_zi3AqZxjr>y14neS>WU3bc)J>bNUt3}~b3Md4Uiz!~Fy(MBwaQo?vp zxf#s1q~M^+iRvp}14cA13-SUOps!>o;bgD|z$8QAx%3T5Hjt_w8>iD1H8ZoAmyYqi zLC%tl#bHImmhxLRrg*$0*kcMWR%a1&Y+i{ZvsI!^_Qzi;vh z7K}E@x^-5u-mf;jLD}oeigOAs$|d+)6uJ_KmLwi|4f~g}W>)O0={V$Os6v{b5YjJ0 z`#}M2PlW_^5m5Sc@ZR}L7nUyk)hq#R5)={7cz1jZ6Bua{2n_{I@jW>JzZBUJY!8*) zXBkIbsl8ifXWfYq*Bxv;1wmhR)lO9cTvCThd4ugR0d_D_L`GLpKqY!5UJgLd>yG0^ z>irHaA}rVEjV~Fcm-%W`4B9X}*9(Tfb@2J%l6>D484^e&Iyc)4(coh-qy!mkE8LYw zoksPOg!5t8j`Ezk`0ees4McS}JjlpU(R+x*ov}4uV8>-(X|2mZV06o+yEBE&_O%qj z)HA*y7Q~`lA(e~ZYZ82DAXsHh0{lLg`uQo8Hjv^?|G?i-oS^M@t5Wc#0KcP3LEG;r zSMZ^@!pab*m+Whlk8mFu2(F+cDXIm<65!lf0F}wqA7TFXM+e+T<8KS8*KCa7+tcqg z2gp2mOM5{m=hiN%Awpg-S&#*2+Dk`WL9v7gMSHy3XLTFeU+r_#Qj`oMO9igsfc5lh z`><0Lf-(7t#U4O7^mi1*%KLY_WPSu=R+8**Vd%1WcqwePZ1~KBnD+Jw*-LC#ZQDuO zp1R%_OlJlH+*kcLY0wOLJ8j!=lEmIVPQXG`K$|fhIWpi;1S&5B!x}mi6x5z7ty_N` z1QVK>z^<(>xMB^IE&pYs)YxEvev$_h43il4_XW!fRTQL;7B!?_kpuOm%I9l4duW_+ z7TOcaW?qCo!(eTQ2-pQ>?eS3JqEpucW zc3#o}fGepYxAx+oJD+;-?6x#9916zKV+ScSNJ;mzw5%qnmTNEE>k4PleygV7L8&V= zh^Qk?!D?xt#BX1)wnPmdRo%1a_-0qQB-I9_TvN_{0)|w*na z!Re%O+PTU$ra=JYbRM!-?0JiuMv*6pix>1B3WjMF=rZWd(r%*- zQO6{Y2wI|5(0i*xM|L@o$QJCWMU3p7haamc&1eTf8`2NE(|!(5Hx3!RSAg8_ruBTS z#@Apljb#bJK~dzbYUlIzx47fR-sYy#mLZhApkThz7fd9mfU<|_Fv8a0`L}abC<0Nw z+^p@PSTrh}%^`|LaPT|~TQEQ5K=6ZcQUxLA?g58}6b&?&pi+FR_1AZ}3Aq7UbnKn{ z3(liMD4dl*X+NJ)UvI6Tb-5pOr!V_N5aWSJ_ad!rrPinqd21sEu!10=NPpnYvyz;p zl-Nk9tTuiIY1FdVb~Z64W!O-mqRa0Oxv52|1*exk=epDWKk`G_UGG}c@Q>Z9j_D~e z3M8>`rt46EFz>XsrdB;0%#E|7*=SP?HXHiN*9* zw8r32Ft!^tZdPkIqP}4Kg8q~2zL3%!yHo8p0`jL?bI%Zdq(nKU^+vKBi4zl5)in&`WuC8mv)lbACZ3ljhYVB!6YTrR; z>eVrw*nMiiR|}okVU<)tt`2N}5aA)Puc91DI~7A+Fq%qp?(YVI8U8EHDjc46n4;~y z;h}jc+&Y5)T-+KA1}kM0K^qgCmAYu)D1Sc?%yE1zT~^s<^Pj+_Otd*?LQ|icl5xy* zJJkDdBQW0XHf)%CocsGut8ZV$U|tu6Vx-O$keeC*894V3ZDj$oTFyTnpym7{wVcln zxX-tZ(UZ25s>s+~(Mi1&xUM=*>aC-yH}?(nuHJkBVyCG$J!m$mMP)?OkAOh?&kxmS z(u0WXmHAreF_hc8TFh~3F(2&a~;1Cfo4%AjPC@$R4(Qm^#w=^t?LKI%8)|WWO&Mku$v$ zN(e6X0ZK^r*Ity6^f8+LkvW0irs|PeTdUTQ`l(If=tzyNL$;;K_@jMb=_hOBZJbNm5bD8rdMP>>Z($tzEf=?^I11VBK4K&5Sh=8 zQ+>!x^g1<%j#mvz!xu7217>Vh8a|>*!yNqXkLl<)8ouj~c_aH{z)TKMg|ff)q6)PVU|*8H-CFtEREk(PE`M$e${cG17-J+B3O2fN3_tCp{eYoqpgKYG7urNRL0I^!VC^qIM0yKaP*jvWt45(zKR~Th+y>X9fH1K#zJ1+E2U{svqIwA z?@Ex6ApfO@z>s^<(Y^P`_WMxoyK7)b`j`)i3uznYa6IV}7ScWv&82A+ybbZOZBQQ; zlHOt)CLa_P(l+AZ8$K*3r1Q!7ppe!HI}4BB3GQG}2;5=^2#?ucdl4SfhiE||Y54y( zWyjRgT4l%7Pi?Z}-*%$Kt+L~*$o4XtHqm?>IolGKjvS!lk3a8>Qg0-CXXh0jL3Vtl zr||Qibd5gmL{r-+L8pL_)K!ZELel8mIUXeSmBoXkzv&tdlKM=Indz^(NsXzGEE**J z)o})cr002^u^=6<*t^}>YH6Qw@Hq>?Pxd;MN;0xqEhCvQ@$}+?0uxxRR?9(QL-OUi9~}YA&JZ(fmJGMT7`h&p&5-6DZ2Ht3kIoTH!ZS}3uqZpj|hgpjKn4}4uNAfm-h|(O5 z`l-I)#nk%wTOrT?ztG%yrc|q7GA#6Q=l*?QcopSmU{t;)1;RAHxmvSM$}=S-Y0({Kzxc3uD<>iLfFqErO)?ny`;cKJ8`5<5H$86=e`9YC(hE1V`LGd z{vVw>7>{To5Ukh19uh!mEY!mE4YW)~*AwOc-iCJ4F^B)mj<9x_7y|a zn^AtE5+V~F9dVecUez`!PpKgTLrc6%}!O&tc@J+o%=!7G1bT z7STCv-}UD{%mD=Lux=^O0cfGnAS*8Mm2l>)e0?b@?QWvexEshl5g;dGyDSi|MJM&S zq?;$>lp_=b#25{X&h$S4EB{EZ71)Hb-_vW=>I!O1$?dMY6S+6f0n!qbzRn#E3b#C}fxe+iL0G`J9{%o4inVzwJ7p=>*;AHMcN0-~EyS7Y z5XD(DN1ySg*dvora+>{iCW{K#s z@5&@UXBV-Bbd_?rFu#opUfz#08Ov@chsNF*W!tYEy9Wjl(kO()$`4pV)x}HQ>Y{B!e!)oGcyQx__FU(>xOfu0uH2D z;!ApXG()39tB@ub*Iv<3s+r)X_#+A@$lNi*O4M-o1HK$P*@orBYpqAS?@Uy)MqJ!; zoL!zmlAS1Ogyc9kF!DK~i+fw{m-9d`U$o?gZ#ak&Bh?yk7AeG1%DY@@lu{$%!YI0p zEbXq>Blq^4)@sq*!_-Rc4%C~-j|cYp+kq8=s*tjPNlZqeGQ=2;`xMoc-`p1 zJlW~`uC&?nO!0x-FYH%6yaFpIM70;NaH;!)BQn~Sp~6Bxz?g1jhqi`*CJ*Mm9xv6SDM&@L z0d9g)30kyXY6WTfW5`rk79U#LMJ?Bc`-9xaGs-%RMhY|%(N3rrn*<}X^P=pj|2&t!1i+_+NrMRNV65Fr4U?i&5 zsn}Uf5RE#0(lXqAKC9E&)5eX%D4#s z@h`g*Zdz`6eDZ^CjdEjDUaR^nY*nwh53*b<-4r?YP42uONLduCAhX!8d zz~d|^mL1KXQA3o2MgfD(8}hZAaPC(FL#X16WaeglL$3;zwNd@26c%DcPvGuM^`!qE z7+kekOrh97s(UjUKU1yVU}c3UH*Hv`Qf=`4J~tYST#cYc4hQ3_rE8JVpCKqs*(aJ| zkbQPqIV(Ihz0 zmS_^3Z~&YOPITV$L^6MeO{0_uD`9<~)rPcAf&v7p@h)now?B9Nm@YT?{k~y$ZlBj{B<2O-FG7RY|3oVoGIj&P!ewa@yF);LJD~a_fHnKnqBq7#>t!s^0dlXwUcw zFRkHs5rk2D6asG<)rO#txCW|b>XD#Q<_dT)hb~^OT-%Mx#qbFh)y$rp71Y#kwD6!F z1%(EC)!=RbM!8;4&=;WEO8HQN&IXZ#RHc-aZeWJNABT>{6Rb37=LzrS`sBvgi1KfY zVhWxPYEpgY#c7dGJ%4M(spUQ47wz2Y9lBrjriz?^l_?KixlROIG}*~c9J*hgff{{% zr&POv&nN-iLg@$@0UBCOBpwu&y*Qz}_;a)A=&B~eJHsg+IQaUe%yr|aAJMpkD=d7} zp_Mzk)0&ZbLv$LO3gHV~1e;9NY!D7iAK)B5@7? zI##^_FG%U*7pf~txnV>5Craok3xz-Aj0UE1`Drswta@?)BU&K3t^bH}yf;PI#l!Sm zabitLJ--Gr9*)A_+k~W$%YBhc@k3#Yf_&P(daK!h4Kl%v@I2&x*ay*20k>7f3fR#e zY4E0Wo!{W{gl{)mO(f!IP4h#ce z7(TVk^i0k4%WUItxw&~gO-v$@5~DJzPQ%8+gJk2h z`kSaGO>G1_u>#y+42{}8FEHymHKSEpbj$9<(9a1%y|h^5o;()#lg8`rq);aAT})iW zK*R>`4*`1k3P(jP99_%V;FcD^Mg@Jpc#5oX?iXle$4=%de>iE2T1I(ANHic>3+QRD zw;lGtQ^7noj03_HAi?pEd6Q?s*~Dw*#52Wb`u z8wPOe%{bZ2GDU0QXpOMfufNAJLC@ZH3!%h4C$rGg) zpt1=`!nydjfi{g2?MEnRyB;iU`Ay(NYE_^wMppBhu9^%pwPqSrXiOBdBCnWdduo`^ zPY~mN4hzP16Gqki)qR)|gjW~0SV(?Dt9j4P>QT(joB~%W`>|AD+)9RJ2eVUw+*~fAtd;4(!t($jY;hcyehMRx97fTJBvh=~wdNkA4yoNM z0A3G@XNY7FP9dDgk1!~ZG-M{g#M@N>T1;6S7AA+QxN=yWNiQsqtcSHzDn}~;FCJeM zk;k)KTN~fgC1btPoG2M2SsJ=faAkT7Safl@eO=*nsg6E8kYm86UtsYuVbV_r&|pse z1(XL)^)T+=nd+?r?_V(eW4nI>MBkDCgh2)bK=!odr7ecp>ebxk44t{CWx;h0#Fv+3s_*2^n*aPNdz^Fh=p!3xNvc8 zah9lnD$YXnc->h7U#OjtU9&@5D?1&RcL?KBi;NP#xklM5&>Lnw;sOUSYbk@5o@TvieFtx7Bdx}*?)!^h{@%`!x(3;S5LFyfb?xiDKnM+{(8?DTDjLi4cWLUsG z+l)5kHXY?A<)#mltX@IoNj6uZUkK~+2#Hs}&THbC4|tgG(bRdA+qr>bm932k&ISOACcB;h1%{z)rI)t{ zo_iojL(x7}dBAwM3VYlX*PuU9#IBReGgKBnt%A@PVMBpLy6&$Vx_Dh+n&=EWRIV0jarKQCY>M4@Dpl!CDy{EsVzxCo;gLV=Wx;AMZT zVv+RgvQhn?iV3YFQ|jD=8rS)hLbm{gF2=a!>b1%yS8bn(#I_ET3~r%MGi6tv1Z7tnRz+-Wh1517MQ%SS=gWLUKykzh_$fC`jNnU#g;>@y z8kPB#DXPLyP{3Z-0gAJL$FtbwW%L;np_u@JkqN#bGO`!MA_Tz}-P38d1u7T^%u8!I zIedUM0_BA2!P|hYj)p7~Fr(IFMd7MHUx*wV{GIN`J1l#g7`YwU5#)Kp)-N%el|zf^Qt>I^wBPD?r3hk; zw_rNd_5W!u_}XzU!q1eN|4nY;qG$(~63sv7`ul?)9p^&YrcABqJ~t|XB!I`0{l|Mx zbe`1QZ*k-D>=HOTs;BoKFJ{J5rB*QP7S6&Zz_v4&5sxX{IOm=}=JRNqGPR-y+}LGU z?_sjYxli>6UpY>^u!&N$zr&3q0%cEpAUXGgV?L31s?-XQF11?SzELVy8~8crnghXi zjuQr{$x^HMWAsMQP#IaouLgo|9p@sFxl(J$yQvlRFxhe3eOn1 z+mmw-o;Y5Nw24x)PdEetp#K6)nmPB#iQ}c%ex}s?lWzDjjNdtT@CL&i&}b@p2@cCpGuu zZfvD`^-^;i{OGk~#&w%0HT$%iz+qIkE^aOD-pILsKXJTNCz&g?h9}(cU%~5=bH6@j zeCI5wdH)|bav3r9nE0W=!4U^nxF$4g=SOsV<*5~nWszAQ~- zo*g{qQ;+9Lt>Gy*4v7>|lW@r9TxIZhC9!y_)C&HWn?8HJxqAcA$-9WVK_|54;PL7R zndwq1ISm>R(Zvf$d}Q!=`JGLan*FRBJ6lD+_G`EaA3f&TZKBld3l0IYviA$<$i9Ur z;PGm%Efc0zv*=cZBcItuhQvzcngyhRFAN5+9VbidG;3;IR~;mgp*ahM*)j2? zEf!{EwUeh7$+{A=gIL>ZXcG*pS5*I0zywxAv(C=WYc|zV`6&J=H}!O|w6hbH(Lj@3 zh`o6Sb}d=mNu7%pPBCq1KV#&z!S)3|DGY7!T_HOoZVAk{=-|H{udfpmX4@Xkmgzm( z`3ZRg_9<9%Y1z}50!Es^{f&k0m1@$J7{3KU%Put7%Y5p55#eSC7?NmBjumeR`(m(a z_%VwJN(BUoSuwF>hVJViOE^ke9?_KzVHMj91%dIABYKF!99fgsT77j71{WvZm;dv8 z94qraZ|lV{*I>O&xi`eSfmj+er&dF%IH;w-O^7%L_3n=ZnH#i@cC59h7!TjOgQ#QUwBVoCgG-3vhv^M58!=)r z;U@;7fu7Ma7Rwa}0O7mJCSs)!Ylx6qG5s^S5Ptgq(!p>52#qUY2*gYisKppc`IGrm zMS0YYd?B#%(MQb&9WN{S6o-zuEQEfDNzgkK6ep3(?7xsW47|%gmc^iwx^iX_7i8h> z2_JnlWAT@+?@S6$Z2kUr;&SS>Jo)XSZIbq6!TF*FF6_c-!OY;wHL%76Y3N=<H-pTPqGY-JiCylM2uVCncD5!yUJc0h$Z8c;7bNHKKMj!OWr?Pm=03|FLkgHfT9ZcbkWTo;)uiLD4e zVpaHGr!&Db(nkF1Lu)L=ksr!_o(?!o*KhGo{3Tr>aPP#GjZ zF5iqYR%JE`W0oKchMJMaAY4Q6fPq^cu?s&2ni26N z#GJlC4imdU^TibtR~DspS2iMLLI(TCsEfqEJP+YDdHKooL6oWgZ9HAn0-Q9vP+ zpTLaU)oVNrd^8#U7yP&HzBrpd1^g%>T2DY$$T#I|PT|fozs??qsUt%Y1k!uLDO)6C zUBm5@67p=oF)5`;NEE{rx==y(p^ePiTA2M0ISY=pLLMR#!~K!U1$U_Aa2)`Buz!u& zeGuo@DBS>QgU?ZfpzX?5L~8+Cx=z3KOF!ynXyVi z2r`3FUlj}!feK*}5cefF90ameh$=`RstH2mWWNAm;{tHcV@8JXYmL9oOH~F+hm1hb z@RKy5U>aK{7RiWxJ|_0qAeed_U|UoSW!zgkpYo+bUe59rk}9sys7K7qGK;(`w^;egl7~{=%Pj6N~_!PiNw}~+oI3eEWW!G(C0IHr6{8Uz1Ufu zqM~CO(_Tss(npCN6i)3`Z$wDN6O-l8>!TH9g>9-tZu*z2m_o$ww%bgEVL&u9(KCb~ zzS5y9LGVp%*m0z-zx*PvdP9-V-V*)OKcrNv@wqU`*b?w{b?Z6)64Rd4lz~i{!R$!# z$ZwWddBN)remkWdG4nECUsI)FE`0E4mOth?W8nA#`BiC=hu3B5Yw<@E^6{3#el9XT zwXWh5^$x#R{L{=Nvf9vnd=YWTiK&%m4YLevJ4wL&9jdi3c_KHeZUEH!3BZq9TPDAz zEt5~47%G^1tB$yocGuQ0ALn{^7C6+`WDzx39FVowfMJ=4!$I;lP@#~v4o~p#TgMEm zB9lL0-`DJ_HGaE+JS>)9MCP^HFJ4!EOE7YW*LX*BU%q^qz=F9G9Q*Lhm)mjNjj>8E zP9ql#?M63W%LS{kGjbV4o0@xC5~mMka@&s*^$BY9mt~HW4W8tnwGL!F91L2Rp*RU= zm;8)Bcr5LU4Ce#x7v$56h`FvP%sP|&vy3{It^Nk zyhV9=gNYSSoQP}RGaZwIvk3H2ypC>sC0Ov~AqW{ot^c3B_W`Z*zVH2?$2yMXRB4sX z*_6$>_oQ=}oS*bWNuB*1*9lQAJJzlLD3;u~H!(W0bZnhQ($RFJ#M-Ze40@r13^K?d zgAOw2AcGD%=%9lRI_O}74LWF{7dGfF9dyt^2Q74v3%}R<^Z97RqBr5 zL?m$J#C_rlI-I}|deⓈ#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 000000000..35a031725 --- /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 000000000..84ddb18b7 --- /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 000000000..1011f7c7c --- /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 000000000..5fbf94ebf --- /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 000000000..fac873fc5 --- /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 000000000..16d5de736 --- /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 000000000..5d8a40d05 --- /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 000000000..65c6034df --- /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 000000000..97ab3e209 --- /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 000000000..b9bb82494 --- /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 000000000..96d2229da --- /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 000000000..ae0f777c9 --- /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 000000000..d4badeaf3 --- /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 6de95ba5f..7846d55fb 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 e69733515..3aba26ca5 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 d1e8ccc54..2e6849513 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 c012751da..3420b837e 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 65964ebd0..e265eebd0 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/35] chore(cli): allow Boost license --- deny.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/deny.toml b/deny.toml index 7acc3441b..3c039293e 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/35] 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 6de95ba5f..2fc9b388f 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 e69733515..9af2ab61a 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 3b6874e17..000000000 --- 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 f0ef86ee6..195882fbf 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 49eb386d5..318d75dee 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 7db47cdfe..443666b3e 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 26d5ddd94..d44731313 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 1918a26f9..46770a679 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 8431f241d..efd163f9f 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 4ae778ddb..7a644b61d 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 7fc6e68fa..66552c0ca 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 a98814b93..2a8996e6d 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 80cb4f554..3fa962aca 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 bd8bf4fac..749691453 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 2ed3ca6cc..46c2131f2 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 7d214d6f2..24667e236 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 0774f2705..4f70d0006 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 637e7897a..9deccdc46 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 5c700d81a..c6e03dd39 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 b9dc567ff..7b91287e3 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 1a37ae5a5..aabd53fa5 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 bd51be99c..5aae13b17 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 2bdd35a93..3ba81bf6b 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 7e2bca0fe..33ef267e8 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 3abbcf48c..5a1d2755e 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/35] 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 bb360b083..aef6089f1 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 e5b62cd41..fd0947e00 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 9db0de123..0820de7f9 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 271139ba8..f6f51f534 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 000000000..63ffea772 --- /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/35] build: make `make headless` work without a prior JS install --- Makefile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Makefile b/Makefile index b6c65785c..86c8a0a9d 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/35] 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 3c039293e..43a7c2651 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/35] 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 84ddb18b7..b533ebb91 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/35] 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 5d8a40d05..c1f909202 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/35] 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 4c5c778f6..74ea5998b 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/35] 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 2fc9b388f..1c0789bca 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/35] 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 195882fbf..7ecb008d0 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/35] 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 46770a679..0e8c3bb38 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 dc2291b0f..be0f1cfc5 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 327d8c2ce..3f312f538 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/35] 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 b533ebb91..624cc6ec5 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/35] 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 65c6034df..e3a9b08d4 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/35] 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 fac873fc5..8cab7ed85 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/35] 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 e3a9b08d4..09d3a1188 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/35] 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 09d3a1188..3005cdaa8 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/35] 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 03f9f8912..fc64490d3 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 aef8b144f..d61693341 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 9c2bcc810..72ef36136 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 0820de7f9..87bf209d6 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 f6f51f534..d3584ffbb 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 1ea1bb532..57fede855 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 000000000..ca21de3f9 --- /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 7cd7a5e49..be6b88a5e 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 8cab7ed85..fb8186284 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 16d5de736..66d615a39 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 97ab3e209..2fabc8324 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 96d2229da..3650f58bc 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 d4badeaf3..bca4d6c68 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)); } From 0fcee1085ad726c3395519dc933bf951de4f9880 Mon Sep 17 00:00:00 2001 From: pg Date: Thu, 30 Jul 2026 10:58:02 +0200 Subject: [PATCH 35/35] feat(host-cli): add chat and Coinage follow-up --- Cargo.lock | 18 + .../kotlin/io/parity/truapi/TrUAPIHost.kt | 11 +- .../diagnosis-reports/pairing-host-cli.md | 2 +- explorer/diagnosis-reports/web.md | 17 +- hosts/dotli | 2 +- .../Sources/TrUAPIHost/TrUAPIHost.swift | 8 +- rust/crates/truapi-host-cli/Cargo.toml | 7 + rust/crates/truapi-host-cli/README.md | 44 + rust/crates/truapi-host-cli/SPEC.md | 81 +- .../scripts/smart-contract-allowance-smoke.ts | 11 + .../crates/truapi-host-cli/src/attestation.rs | 62 +- rust/crates/truapi-host-cli/src/chain.rs | 32 +- .../truapi-host-cli/src/chat_requests.rs | 485 +++++ .../truapi-host-cli/src/coinage_balance.rs | 1676 +++++++++++++++++ .../truapi-host-cli/src/coinage_top_up.rs | 616 ++++++ rust/crates/truapi-host-cli/src/main.rs | 209 +- rust/crates/truapi-host-cli/src/network.rs | 6 +- .../truapi-host-cli/src/signing_shell.rs | 148 ++ .../crates/truapi-host-cli/src/terminal_ui.rs | 142 +- .../truapi-host-cli/tests/signing_host_cli.rs | 1 + rust/crates/truapi-platform/src/lib.rs | 4 + rust/crates/truapi-server/src/host_core.rs | 3 +- rust/crates/truapi-server/src/host_logic.rs | 2 + .../truapi-server/src/host_logic/chat.rs | 715 +++++++ .../truapi-server/src/host_logic/coinage.rs | 169 ++ .../truapi-server/src/host_logic/identity.rs | 4 + .../src/host_logic/product_account.rs | 2 +- rust/crates/truapi-server/src/native.rs | 30 + rust/crates/truapi-server/src/runtime.rs | 3 + .../src/runtime/pgas_allowance.rs | 398 ++++ .../truapi-server/src/runtime/signing_host.rs | 69 +- .../src/runtime/signing_host/sso_responder.rs | 112 +- .../truapi-server/src/runtime/sso_pairing.rs | 16 +- .../runtime/statement_allowance/extension.rs | 191 +- .../runtime/statement_allowance/extrinsic.rs | 30 +- .../src/runtime/statement_store.rs | 2 +- .../truapi/src/api/resource_allocation.rs | 14 +- rust/crates/truapi/src/api/statement_store.rs | 16 +- 38 files changed, 5259 insertions(+), 99 deletions(-) create mode 100644 rust/crates/truapi-host-cli/js/scripts/smart-contract-allowance-smoke.ts create mode 100644 rust/crates/truapi-host-cli/src/chat_requests.rs create mode 100644 rust/crates/truapi-host-cli/src/coinage_balance.rs create mode 100644 rust/crates/truapi-host-cli/src/coinage_top_up.rs create mode 100644 rust/crates/truapi-server/src/host_logic/chat.rs create mode 100644 rust/crates/truapi-server/src/host_logic/coinage.rs create mode 100644 rust/crates/truapi-server/src/runtime/pgas_allowance.rs diff --git a/Cargo.lock b/Cargo.lock index 8b3fabbe1..9cb946de7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -834,6 +834,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "cipher" version = "0.4.4" @@ -4440,6 +4449,7 @@ dependencies = [ "futures", "hex", "impl-serde", + "jsonrpsee", "keccak-hash", "parity-scale-codec", "primitive-types", @@ -4462,6 +4472,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "wasm-bindgen-futures", "web-time", ] @@ -5041,6 +5052,7 @@ dependencies = [ "arboard", "async-trait", "bip39", + "chrono", "clap", "crossterm", "fs2", @@ -5048,13 +5060,19 @@ dependencies = [ "futures-util", "hex", "parity-scale-codec", + "rand 0.8.7", "ratatui", + "rayon", "reqwest", "rustls", + "scale-decode", + "schnorrkel", "serde", "serde_json", "sha2 0.10.9", "shlex 1.3.0", + "sp-crypto-hashing", + "subxt", "subxt-rpcs", "tempfile", "tokio", diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt index f7842a2f6..18d7fed48 100644 --- a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt @@ -63,9 +63,10 @@ enum class PairingDeeplinkScheme { * * [hostName], [hostIcon], [hostVersion], [platformType], and [platformVersion] * describe the host to the wallet during SSO pairing. - * [peopleChainGenesisHash] and [bulletinChainGenesisHash] must each be exactly - * 32 bytes. [localSessionSecret] optionally activates a local signing session - * from host-held BIP-39 entropy (no SSO pairing needed). + * [peopleChainGenesisHash], [bulletinChainGenesisHash], and + * [assetHubChainGenesisHash] must each be exactly 32 bytes. + * [localSessionSecret] optionally activates a local signing session from + * host-held BIP-39 entropy (no SSO pairing needed). */ data class RuntimeConfig( val productId: String, @@ -76,6 +77,7 @@ data class RuntimeConfig( val platformVersion: String? = null, val peopleChainGenesisHash: ByteArray, val bulletinChainGenesisHash: ByteArray, + val assetHubChainGenesisHash: ByteArray, val localSessionSecret: ByteArray? = null, val localSessionLiteUsername: String? = null, val pairingDeeplinkScheme: PairingDeeplinkScheme = PairingDeeplinkScheme.POLKADOT_APP, @@ -90,6 +92,7 @@ data class RuntimeConfig( platformVersion = platformVersion, peopleChainGenesisHash = peopleChainGenesisHash, bulletinChainGenesisHash = bulletinChainGenesisHash, + assetHubChainGenesisHash = assetHubChainGenesisHash, localSessionSecret = localSessionSecret, localSessionLiteUsername = localSessionLiteUsername, pairingDeeplinkScheme = pairingDeeplinkScheme.toNative(), @@ -106,6 +109,7 @@ data class RuntimeConfig( platformVersion == other.platformVersion && peopleChainGenesisHash.contentEquals(other.peopleChainGenesisHash) && bulletinChainGenesisHash.contentEquals(other.bulletinChainGenesisHash) && + assetHubChainGenesisHash.contentEquals(other.assetHubChainGenesisHash) && // Compare nullability explicitly so null (no session) is distinct // from an empty secret (invalid input) — matching hashCode, which // hashes null to 0 and an empty array to 1. @@ -123,6 +127,7 @@ data class RuntimeConfig( result = 31 * result + (platformVersion?.hashCode() ?: 0) result = 31 * result + peopleChainGenesisHash.contentHashCode() result = 31 * result + bulletinChainGenesisHash.contentHashCode() + result = 31 * result + assetHubChainGenesisHash.contentHashCode() result = 31 * result + (localSessionSecret?.contentHashCode() ?: 0) result = 31 * result + (localSessionLiteUsername?.hashCode() ?: 0) result = 31 * result + pairingDeeplinkScheme.hashCode() diff --git a/explorer/diagnosis-reports/pairing-host-cli.md b/explorer/diagnosis-reports/pairing-host-cli.md index c32ca3e55..2f32fa6fa 100644 --- a/explorer/diagnosis-reports/pairing-host-cli.md +++ b/explorer/diagnosis-reports/pairing-host-cli.md @@ -59,7 +59,7 @@ | `Signing/sign_raw` | ✅ | | | `Signing/sign_payload` | ✅ | | | `Statement Store/subscribe` | ✅ | | -| `Statement Store/create_proof` | ✅ | | +| `Statement Store/create_proof` | ❌ | StatementStoreProductSign confirmation failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } } | | `Statement Store/submit` | ✅ | | | `Statement Store/create_proof_authorized` | ✅ | | | `System/handshake` | ✅ | | diff --git a/explorer/diagnosis-reports/web.md b/explorer/diagnosis-reports/web.md index ee13f0e13..6794dd2de 100644 --- a/explorer/diagnosis-reports/web.md +++ b/explorer/diagnosis-reports/web.md @@ -28,6 +28,15 @@ | `Chat/post_message` | ❌ | postMessage failed: { "error": { "tag": "Unknown", "value": { "reason": "Not implemented" } } } | | `Chat/action_subscribe` | ❌ | no elements in sequence | | `Chat/custom_message_render_subscribe` | ❌ | timed out after 10s | +| `Coin Payment/create_purse` | ❌ | Coin Payment service not yet wired up by hosts | +| `Coin Payment/query_purse` | ❌ | Coin Payment service not yet wired up by hosts | +| `Coin Payment/rebalance_purse` | ❌ | Coin Payment service not yet wired up by hosts | +| `Coin Payment/delete_purse` | ❌ | Coin Payment service not yet wired up by hosts | +| `Coin Payment/create_receivable` | ❌ | Coin Payment service not yet wired up by hosts | +| `Coin Payment/create_cheque` | ❌ | Coin Payment service not yet wired up by hosts | +| `Coin Payment/deposit` | ❌ | Coin Payment service not yet wired up by hosts | +| `Coin Payment/refund` | ❌ | Coin Payment service not yet wired up by hosts | +| `Coin Payment/listen_for_payment` | ❌ | Coin Payment service not yet wired up by hosts | | `Entropy/derive` | ✅ | | | `Local Storage/read` | ✅ | | | `Local Storage/write` | ✅ | | @@ -42,17 +51,17 @@ | `Permissions/request_remote_permission` | ✅ | | | `Preimage/lookup_subscribe` | ✅ | | | `Preimage/submit` | ✅ | | -| `Resource Allocation/request` | ✅ | | +| `Resource Allocation/request` | ✅ | resource allocation outcomes: [ "Allocated", "Allocated", "Allocated", "NotAvailable" ] | | `Signing/create_transaction` | ✅ | | | `Signing/create_transaction_with_legacy_account` | ❌ | createTransactionWithLegacyAccount failed: { "error": { "tag": "Unknown", "value": { "reason": "Account can't be derived from product account id" } } } | -| `Signing/sign_raw_with_legacy_account` | ❌ | signRawWithLegacyAccount failed: { "error": { "tag": "Unknown", "value": { "reason": "Account can't be derived from product account id" } } } | +| `Signing/sign_raw_with_legacy_account` | ✅ | | | `Signing/sign_payload_with_legacy_account` | ❌ | signPayloadWithLegacyAccount failed: { "error": { "tag": "Unknown", "value": { "reason": "Account can't be derived from product account id" } } } | | `Signing/sign_raw` | ✅ | | | `Signing/sign_payload` | ✅ | | | `Statement Store/subscribe` | ✅ | | -| `Statement Store/create_proof` | ✅ | | +| `Statement Store/create_proof` | ❌ | StatementStoreProductSign confirmation failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } } | | `Statement Store/submit` | ✅ | | -| `Statement Store/create_proof_authorized` | ❌ | createProof failed: { "error": { "tag": "Unknown", "value": { "reason": "Not implemented" } } } | +| `Statement Store/create_proof_authorized` | ✅ | | | `System/handshake` | ✅ | | | `System/feature_supported` | ✅ | | | `System/navigate_to` | ✅ | | diff --git a/hosts/dotli b/hosts/dotli index 7921ce413..7fb26bcb1 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit 7921ce413a4a1661b36c3f5032d91287a8f160bf +Subproject commit 7fb26bcb1a64726f8e465fae2ad29e0ffeb80b9c diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index f25295d03..9b2e10e3f 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -47,8 +47,8 @@ public enum PairingDeeplinkScheme: Sendable { /// /// `hostName`, `hostIcon`, `hostVersion`, `platformType`, and /// `platformVersion` describe the host to the wallet during SSO pairing. -/// `peopleChainGenesisHash` and `bulletinChainGenesisHash` must each be -/// exactly 32 bytes. +/// `peopleChainGenesisHash`, `bulletinChainGenesisHash`, and +/// `assetHubChainGenesisHash` must each be exactly 32 bytes. public struct RuntimeConfig: Sendable { public let productId: String public let hostName: String @@ -58,6 +58,7 @@ public struct RuntimeConfig: Sendable { public let platformVersion: String? public let peopleChainGenesisHash: Data public let bulletinChainGenesisHash: Data + public let assetHubChainGenesisHash: Data public let localSessionSecret: Data? public let localSessionLiteUsername: String? public let pairingDeeplinkScheme: PairingDeeplinkScheme @@ -71,6 +72,7 @@ public struct RuntimeConfig: Sendable { platformVersion: String? = nil, peopleChainGenesisHash: Data, bulletinChainGenesisHash: Data, + assetHubChainGenesisHash: Data, localSessionSecret: Data? = nil, localSessionLiteUsername: String? = nil, pairingDeeplinkScheme: PairingDeeplinkScheme = .polkadotApp @@ -83,6 +85,7 @@ public struct RuntimeConfig: Sendable { self.platformVersion = platformVersion self.peopleChainGenesisHash = peopleChainGenesisHash self.bulletinChainGenesisHash = bulletinChainGenesisHash + self.assetHubChainGenesisHash = assetHubChainGenesisHash self.localSessionSecret = localSessionSecret self.localSessionLiteUsername = localSessionLiteUsername self.pairingDeeplinkScheme = pairingDeeplinkScheme @@ -98,6 +101,7 @@ public struct RuntimeConfig: Sendable { platformVersion: platformVersion, peopleChainGenesisHash: peopleChainGenesisHash, bulletinChainGenesisHash: bulletinChainGenesisHash, + assetHubChainGenesisHash: assetHubChainGenesisHash, localSessionSecret: localSessionSecret, localSessionLiteUsername: localSessionLiteUsername, pairingDeeplinkScheme: pairingDeeplinkScheme.native diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml index 72ef36136..4775a4929 100644 --- a/rust/crates/truapi-host-cli/Cargo.toml +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -21,19 +21,26 @@ arboard = { version = "3.6.1", default-features = false } async-trait = "0.1" bip39 = { version = "2", features = ["rand"] } clap = { version = "4", features = ["derive", "env"] } +chrono = { version = "0.4.45", default-features = false, features = ["std"] } 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"] } +rand = "0.8" +rayon = "1" 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"] } +scale-decode = { version = "0.16", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" shlex = "1" +schnorrkel = "0.11.5" +sp-crypto-hashing = { version = "0.1", default-features = false } +subxt = { version = "0.50.2", default-features = false, features = ["jsonrpsee", "native"] } 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"] } diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index 87bf209d6..e784d97d5 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -62,6 +62,10 @@ Commands always start with `/`: | Command | Result | | --- | --- | | `/pair ` | Validate and answer a `polkadotapp://pair?...` deeplink (signing host). | +| `/balance` | Show the active signing wallet's CASH balance and any amount on hold. | +| `/balance --verbose` | Add finalized per-coin and per-voucher derivation, value, lifecycle, privacy, age, and recycler-ring details. | +| `/balance --recover` | Scan the next unexplored private Coinage ranges, persist discoveries, and show the restored balance. | +| `/top-up` | Add 5.00 CASH through the iOS-compatible testnet faucet flow (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. | @@ -77,6 +81,46 @@ Commands always start with `/`: | `/copy` | Copy the retained transcript to the system clipboard. | | `/quit` | Shut down cleanly. | +`/balance` is signing-host only and does not use a product permission. It +derives the same Coinage coin and voucher keys as the iOS Cash modality, reads +them at one finalized People-chain block, and prints the total with two decimal +places. The `on hold` line is omitted when it is zero. Persistent sessions keep +signer-scoped Coinage inventories in `coinage-balance-scan.json`, including +the highest discovered derivation indices and known vouchers. No mnemonic or +entropy is cached. Each balance read reconciles that inventory against chain +state; a locally known voucher is retained when the chain cannot rediscover +it, while an observed unload marker excludes it from the total. +`/balance --verbose` lists the full local inventory plus its latest +chain-verifiable lifecycle and recycler-ring state. Vouchers allocated by this +host also retain their allocation and randomized `ready at` timestamps; +historical timestamps cannot be recovered for vouchers first found on chain. +`/balance --recover` is the headless equivalent of iOS's manual **Update** +action. The first balance scan records how far coin and voucher recovery +looked. Each recovery invocation starts after those horizons, continues until +four consecutive empty 500-index batches, persists the new horizons even when +nothing is found, and then shows the reconciled balance. Run it repeatedly +until the balance stops changing. Add `--verbose` in either order to inspect +the recovered inventory. + +`/top-up` is signing-host only and is intended for the configured test network. +Like the iOS Cash top-up, it sends 5.00 of the underlying test asset from the +built-in faucet to a fresh temporary account, then converts that asset into +wallet-owned Coinage vouchers. The temporary secret exists only for the +operation and is never printed or persisted. Successfully allocated vouchers +are added to the active profile's local inventory before the command reports +success. Protected-asset transfers require the same 32-byte Ed25519 +authorization seed injected into iOS builds: set +`HOST_CLI_VALUE_TRANSFER_AUTH_KEY` (or `W3S_AUTH_KEY`) to its hex value. + +While a signer is active, the signing host also watches the same account-scoped +contact-request topic as the iOS app. A valid request is decrypted, checked +against the sender's People identity and inner sr25519 proof, and accepted +automatically with an iOS-compatible `chatAccepted` message. The terminal shows +the detected username and whether acceptance succeeded. Accepted request ids +are retained in bounded per-account state so restarting the CLI does not send +duplicate acknowledgements. This wallet-level handshake does not implement the +product-facing TrUAPI Chat service or store subsequent chat messages. + 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 diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index d3584ffbb..57df78181 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -307,13 +307,38 @@ terminals. The signing host: 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` +### 6.3 Incoming iOS contact requests + +Every active signer runs an account-scoped contact-request monitor against the +People-chain Statement Store. It mirrors the iOS wallet flow: + +1. resolve either a CLI `//wallet//sso` identity or an existing iOS + `//wallet` identity by matching its People-chain identifier key; +2. subscribe to + `blake2_256(SCALE(Data("chat-request"), Data(identity account)))`; +3. decode and decrypt the ephemeral-P256 request envelope; +4. verify the inner sr25519 proof over the request and recipient account; +5. resolve the sender's `Resources.Consumers` record and validate the V2 + device-to-identity proof when present; +6. reuse or allocate the signer's Statement Store allowance; and +7. submit an encrypted identity-lane MessageExchange `chatAccepted` message. + +Acceptance is automatic and independent of the product approval policy. The +TUI reports detection, success, or submission failure. Invalid, undecryptable, +or unregistered requests are ignored with a debug log. The subscription +reconnects after transport failure. + +This is wallet contact-handshake compatibility only. The CLI does not persist a +contact database, render welcome messages, exchange subsequent chat messages, +or implement the generated product-facing TrUAPI Chat methods. + +### 6.4 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` +### 6.5 `signing-host exec` ```sh truapi-host signing-host [parent options] exec '' @@ -387,6 +412,10 @@ Commands start with `/`. There are no `q`, `quit`, `exit`, or non-slash aliases. | `/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. | +| `/balance` | no | yes | Read the active wallet's finalized Coinage total and amount on hold. | +| `/balance --verbose` | no | yes | Include finalized per-item derivation, denomination, value, lifecycle, privacy, age, and recycler-ring details. | +| `/balance --recover` | no | yes | Advance the private Coinage recovery horizons, persist newly found items, and render the reconciled balance. | +| `/top-up` | no | yes | Add 5.00 CASH using the configured testnet faucet and Coinage voucher loader. | | `/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. | @@ -402,6 +431,47 @@ 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. +`/balance` is wallet-scoped rather than product-scoped and requires no Payment +permission. It derives `//pps//coin//` and +`//pps//ring-vrf//`, queries Coinage and Members against one finalized +People-chain block, and formats the underlying asset at two decimal places. +Available coins and non-unloaded vouchers contribute to the total. Coins at or +past age 14 plus onboarding or suspended vouchers contribute to `on hold`. +After the known index range, discovery stops after four consecutive empty +500-index batches. A persistent session atomically caches signer-scoped +high-water marks and voucher inventories. Chain observations update that +inventory but do not discard locally known vouchers that are temporarily +undiscoverable. A retained voucher remains in the total using its last known +state; a directly observed `RecyclersUnloaded` marker excludes it. Version 1 +high-water-only caches migrate to the first signer that opens the profile. +Ephemeral mnemonic sessions do not write this cache. +The `--verbose` form renders every locally known item and its latest +chain-verifiable state. Vouchers allocated by `/top-up` include the same +wallet-local allocation and randomized readiness metadata used by mobile +wallets. Chain-recovered vouchers have no historical timestamps. +The `--recover` form mirrors the iOS balance notification's manual **Update** +action. Coin and voucher scan horizons are stored independently per signer. +Recovery begins after the prior horizon and continues until four consecutive +empty 500-index batches; discoveries reset the empty-batch counter. The last +queried indices are persisted even when no items are found, so each repeated +invocation explores a later range. `--recover` and `--verbose` may be combined +in either order. + +`/top-up` mirrors the iOS testnet Cash flow. It transfers 5.00 of +`Coinage.UnderlyingAssetId` from the built-in faucet into a fresh temporary +sr25519 account, decomposes the amount into runtime-supported denominations, +and calls `Coinage.load_recycler_with_external_asset_unpaid_batch`. Voucher +ownership proofs use consecutive wallet `//pps//ring-vrf//` keys. The +load is signed by the temporary holder with +`AsCoinage::InfallibleUnpaidSigned`; the temporary secret is memory-only. +After both transactions finalize, the allocated voucher indices, +denominations, allocation times, and randomized readiness times are persisted +in the active signer inventory before success is reported. +The protected-asset transfer also carries `AuthorizeValueTransfer(Some(...))`, +signed with the 32-byte Ed25519 seed from +`HOST_CLI_VALUE_TRANSFER_AUTH_KEY` or the iOS-compatible `W3S_AUTH_KEY`. +The command fails before discovery or submission when neither variable is set. + Unknown commands, missing required arguments, invalid log levels, invalid products, invalid session names, and arguments passed to no-argument commands produce explicit errors. @@ -904,6 +974,7 @@ The layout may contain compatibility paths as well as identity-owned paths: signing-host/ current-session session.json # default/bootstrap metadata, when used + accepted-chat-requests-.json core-storage.json # default/bootstrap core state scripts/ storage/ @@ -923,6 +994,7 @@ The layout may contain compatibility paths as well as identity-owned paths: accounts.json accounts.json.lock session.json + accepted-chat-requests-.json core-storage.json scripts/ storage/ @@ -1158,7 +1230,7 @@ surface. | Statement Store | Real subscribe, proof, authorized proof, and submit over People. | | System | Handshake, feature query, and no-op navigation. | | Theme | One `Dark` subscription value. | -| Chat | Typed unavailable/empty-subscription behavior. | +| Chat | Product methods remain typed unavailable/empty; the separate wallet contact-request handshake is active. | | Coin Payment | Typed unavailable/interrupted-subscription behavior. | | Payment | Typed unsupported/interrupted-subscription behavior. | @@ -1357,6 +1429,7 @@ The CLI exposes events for: - product connection reset after session/profile replacement; - LitePeople ring discovery; - wallet and device allowance preparation/results; +- incoming wallet chat-request detection/acceptance/failure; - notification scheduling/delivery/cancellation; - pairing link/authentication/connection/disconnection/failure; - script start/exit; @@ -1490,6 +1563,8 @@ ended. This preserves the child status but bypasses later Rust destructors. | `RUST_LOG` | Full startup tracing filter. | | `TRUAPI_HOST_BASE_PATH` | Default `--base-path`. | | `HOST_CLI_SIGNER_MNEMONIC` | Signing, identity, and allowance mnemonic input. | +| `HOST_CLI_VALUE_TRANSFER_AUTH_KEY` | Hex-encoded 32-byte Ed25519 seed authorizing testnet protected-asset transfers. | +| `W3S_AUTH_KEY` | iOS-compatible fallback for `HOST_CLI_VALUE_TRANSFER_AUTH_KEY`. | | `XDG_STATE_HOME` | Preferred default state parent. | | `HOME` | Fallback default state parent. | | `VISUAL` | Preferred script editor. | diff --git a/rust/crates/truapi-host-cli/js/scripts/smart-contract-allowance-smoke.ts b/rust/crates/truapi-host-cli/js/scripts/smart-contract-allowance-smoke.ts new file mode 100644 index 000000000..ef2436793 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/smart-contract-allowance-smoke.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env bun +/// + +const result = await truapi.resourceAllocation.request({ + resources: [ + { tag: "SmartContractAllowance", value: 0 }, + ], +}) + +assert(result.isOk(), "Resource allocation request failed") +console.log(result.value) diff --git a/rust/crates/truapi-host-cli/src/attestation.rs b/rust/crates/truapi-host-cli/src/attestation.rs index 1011f7c7c..dfdceb800 100644 --- a/rust/crates/truapi-host-cli/src/attestation.rs +++ b/rust/crates/truapi-host-cli/src/attestation.rs @@ -13,8 +13,9 @@ 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::chat::{ChatIdentity, derive_chat_identities}; use truapi_server::host_logic::identity::{ - decode_people_identity, resources_consumers_storage_key, + PeopleIdentity, 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, @@ -102,20 +103,61 @@ pub async fn attest(config: &AttestConfig) -> Result { /// 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}"))?; + registered_chat_identity(people_ws, entropy) + .await? + .people + .lite_username + .context("registered People-chain identity has no Lite username") +} + +/// Active signer identity whose statement and encryption keys match its +/// People-chain consumer record. +pub struct RegisteredChatIdentity { + pub chat: ChatIdentity, + pub people: PeopleIdentity, +} + +/// Resolve either a CLI `//wallet//sso` identity or an existing iOS +/// `//wallet` identity, rejecting records whose identifier key does not match +/// the locally-derived chat key. +pub async fn registered_chat_identity( + people_ws: &str, + entropy: &[u8], +) -> Result { + for chat in derive_chat_identities(entropy).map_err(anyhow::Error::msg)? { + let storage_key = format!( + "0x{}", + hex::encode(resources_consumers_storage_key(&chat.statement_account_id)) + ); + let Some(value) = query_storage(people_ws, &storage_key).await? else { + continue; + }; + let people = decode_identity_hex(&value)?; + if people.identifier_key != chat.encryption_public_key { + debug!( + account = %product_public_key_to_address(chat.statement_account_id), + "People identity chat key does not match local derivation" + ); + continue; + } + return Ok(RegisteredChatIdentity { chat, people }); + } + bail!("signer has no matching Resources.Consumers chat identity") +} + +/// Resolve one People identity by its raw account id. +pub async fn people_identity( + people_ws: &str, + account_id: [u8; 32], +) -> Result { let storage_key = format!( "0x{}", - hex::encode(resources_consumers_storage_key( - &wallet_sso.public.to_bytes() - )) + hex::encode(resources_consumers_storage_key(&account_id)) ); 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") + .context("chat requester has no Resources.Consumers record")?; + decode_identity_hex(&value) } /// Probe the People chain for which derivation of `entropy` (bare root, diff --git a/rust/crates/truapi-host-cli/src/chain.rs b/rust/crates/truapi-host-cli/src/chain.rs index 5fbf94ebf..c866e00c6 100644 --- a/rust/crates/truapi-host-cli/src/chain.rs +++ b/rust/crates/truapi-host-cli/src/chain.rs @@ -204,7 +204,7 @@ mod tests { } #[test] - fn required_bulletin_route_is_enabled_without_optional_live_chains() { + fn required_host_routes_are_enabled_without_optional_live_chains() { let network = Network::PaseoNextV2.config(); let provider = WsChainProvider::with_live_chain_routing( network.people_ws, @@ -216,25 +216,29 @@ mod tests { provider.url_for(&network.bulletin_genesis), network.bulletin_ws ); + let asset_hub = network + .live_chain_endpoints + .iter() + .find(|endpoint| endpoint.genesis == network.asset_hub_genesis) + .expect("Asset Hub endpoint"); + assert_eq!(provider.url_for(&network.asset_hub_genesis), asset_hub.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, - ); + let optional = ChainEndpoint { + genesis: [0xdd; 32], + ws: "wss://optional.invalid", + required_for_host: false, + }; + let disabled = + WsChainProvider::with_live_chain_routing(network.people_ws, &[optional], false); + assert_eq!(disabled.url_for(&optional.genesis), network.people_ws); - assert_eq!( - provider.url_for(&network.live_chain_endpoints[0].genesis), - network.live_chain_endpoints[0].ws - ); + let provider = + WsChainProvider::with_live_chain_routing(network.people_ws, &[optional], true); + assert_eq!(provider.url_for(&optional.genesis), optional.ws); } } diff --git a/rust/crates/truapi-host-cli/src/chat_requests.rs b/rust/crates/truapi-host-cli/src/chat_requests.rs new file mode 100644 index 000000000..6cfc0b266 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/chat_requests.rs @@ -0,0 +1,485 @@ +//! Automatic iOS-compatible contact-request acceptance for signing hosts. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use subxt_rpcs::client::{RpcClient, rpc_params}; +use tracing::{debug, warn}; +use truapi_server::host_logic::chat::{ + build_chat_acceptance_statement, chat_request_discovery_topic, chat_statement_priority, + decode_incoming_chat_request, validate_peer_identity, +}; +use truapi_server::host_logic::product_account::product_public_key_to_address; +use truapi_server::host_logic::statement_store::{ + SUBMIT_STATEMENT_METHOD, SUBSCRIBE_STATEMENT_METHOD, UNSUBSCRIBE_STATEMENT_METHOD, + decode_statement_data, parse_new_statements_result, +}; +use truapi_server::statement_allowance as alloc; + +use crate::attestation; +use crate::network::NetworkConfig; +use crate::terminal_ui::{self, SystemEvent}; + +const RECONNECT_DELAY: Duration = Duration::from_secs(3); +const ALLOWANCE_PROPAGATION_ATTEMPTS: usize = 5; +const ALLOWANCE_PROPAGATION_DELAY: Duration = Duration::from_secs(1); +const ACCEPTED_REQUEST_LIMIT: usize = 256; +const STATE_VERSION: u32 = 2; + +/// Run until cancelled, reconnecting the statement subscription as needed. +pub async fn monitor(network: NetworkConfig, entropy: Vec, state_directory: Option) { + let identity = match attestation::registered_chat_identity(network.people_ws, &entropy).await { + Ok(registered) => registered.chat, + Err(error) => { + warn!(%error, "chat-request monitor could not resolve a registered identity"); + return; + } + }; + let topic = chat_request_discovery_topic(identity.statement_account_id); + let state_path = state_directory.map(|directory| { + directory.join(format!( + "accepted-chat-requests-{}.json", + hex::encode(identity.statement_account_id) + )) + }); + let mut accepted = AcceptedRequestStore::load(state_path); + + loop { + if let Err(error) = + monitor_connection(network, &entropy, &identity, topic, &mut accepted).await + { + debug!(%error, "chat-request subscription disconnected"); + } + tokio::time::sleep(RECONNECT_DELAY).await; + } +} + +async fn monitor_connection( + network: NetworkConfig, + entropy: &[u8], + identity: &truapi_server::host_logic::chat::ChatIdentity, + topic: [u8; 32], + accepted: &mut AcceptedRequestStore, +) -> Result<()> { + let rpc = RpcClient::from_insecure_url(network.people_ws) + .await + .with_context(|| format!("connect {}", network.people_ws))?; + let filter = json!({ + "matchAll": [format!("0x{}", hex::encode(topic))] + }); + let mut subscription = rpc + .subscribe::( + SUBSCRIBE_STATEMENT_METHOD, + rpc_params![filter], + UNSUBSCRIBE_STATEMENT_METHOD, + ) + .await + .context("subscribe to chat-request discovery topic")?; + debug!( + account = %hex::encode(identity.statement_account_id), + "chat-request monitor subscribed" + ); + + while let Some(item) = subscription.next().await { + let result = item.context("chat-request subscription item")?; + let page = parse_new_statements_result("chat-requests".to_string(), &result) + .map_err(anyhow::Error::msg)?; + for statement in page.statements { + if let Err(error) = + handle_statement(network, entropy, identity, &rpc, accepted, &statement).await + { + debug!(%error, "ignored invalid or unaccepted chat request"); + } + } + } + bail!("chat-request subscription ended") +} + +async fn handle_statement( + network: NetworkConfig, + entropy: &[u8], + own: &truapi_server::host_logic::chat::ChatIdentity, + rpc: &RpcClient, + accepted: &mut AcceptedRequestStore, + statement: &[u8], +) -> Result<()> { + let data = decode_statement_data(statement).map_err(anyhow::Error::msg)?; + let request = decode_incoming_chat_request(&data, own).map_err(anyhow::Error::msg)?; + if accepted.contains(&request.request_id) { + return Ok(()); + } + if accepted.predates_activation(request.timestamp_ms) { + debug!( + request_id = %request.request_id, + timestamp_ms = request.timestamp_ms, + "ignoring historical chat request from before CLI monitoring was activated" + ); + accepted.remember_seen(request.request_id); + return Ok(()); + } + + let peer = attestation::people_identity(network.people_ws, request.peer_account_id).await?; + validate_peer_identity(&request, own, peer.identifier_key).map_err(anyhow::Error::msg)?; + let username = peer + .full_username + .or(peer.lite_username) + .unwrap_or_else(|| product_public_key_to_address(request.peer_account_id)); + terminal_ui::output_event(SystemEvent::ChatRequestDetected { + username: username.clone(), + }); + + let result = async { + ensure_statement_allowance(network.people_ws, entropy, own.statement_account_id).await?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock before Unix epoch")?; + let priority = accepted.next_priority( + request.peer_account_id, + chat_statement_priority(now.as_secs()), + ); + let statement = build_chat_acceptance_statement( + own, + &request, + peer.identifier_key, + random_uuid(), + random_uuid(), + now.as_millis() + .try_into() + .context("chat timestamp overflow")?, + priority, + ) + .map_err(anyhow::Error::msg)?; + submit_statement(rpc, statement).await?; + Ok::(priority) + } + .await; + + match result { + Ok(priority) => { + accepted.remember(request.request_id, request.peer_account_id, priority); + terminal_ui::output_event(SystemEvent::ChatRequestAccepted { username }); + Ok(()) + } + Err(error) => { + terminal_ui::output_event(SystemEvent::ChatRequestFailed { + username, + reason: error.to_string(), + }); + Err(error) + } + } +} + +async fn ensure_statement_allowance( + people_ws: &str, + entropy: &[u8], + account: [u8; 32], +) -> Result<()> { + let rpc = alloc::rpc::RpcClient::connect(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)?; + let bandersnatch = alloc::bandersnatch_entropy(entropy); + 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)? + .context("signing account is not a LitePeople ring member")?; + let period = alloc::slot::current_period( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock before Unix epoch")? + .as_secs(), + ); + alloc::register_statement_account( + &rpc, + &metadata, + &chain_state, + bandersnatch, + alloc::RegistrationParams { + target: &account, + period, + ring: &ring, + reuse_existing: true, + }, + ) + .await + .map_err(|error| anyhow::anyhow!("chat statement allowance failed: {error}"))?; + Ok(()) +} + +async fn submit_statement(rpc: &RpcClient, statement: Vec) -> Result<()> { + let encoded = format!("0x{}", hex::encode(statement)); + for attempt in 1..=ALLOWANCE_PROPAGATION_ATTEMPTS { + let result = rpc + .request::(SUBMIT_STATEMENT_METHOD, rpc_params![encoded.clone()]) + .await + .context("submit chat acceptance statement")?; + match result.get("status").and_then(Value::as_str) { + Some("new") | Some("known") => return Ok(()), + _ if result.get("reason").and_then(Value::as_str) == Some("noAllowance") + && attempt < ALLOWANCE_PROPAGATION_ATTEMPTS => + { + tokio::time::sleep(ALLOWANCE_PROPAGATION_DELAY).await; + } + _ => bail!("chat acceptance statement rejected: {result}"), + } + } + unreachable!("bounded chat statement submission always returns") +} + +fn random_uuid() -> String { + let mut bytes = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + let hex = hex::encode(bytes); + format!( + "{}-{}-{}-{}-{}", + &hex[..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..] + ) +} + +#[derive(Default, Serialize, Deserialize)] +struct AcceptedRequestState { + version: u32, + /// First activation of CLI chat monitoring for this identity. + /// + /// Requests older than this belong to the user's pre-existing iOS contact + /// history. The CLI has no access to the phone's local contact database, + /// so replaying those statements as new requests would be misleading. + #[serde(default)] + activated_at_ms: u64, + request_ids: VecDeque, + #[serde(default)] + last_priority_by_peer: HashMap, +} + +struct AcceptedRequestStore { + path: Option, + state: AcceptedRequestState, + ids: HashSet, +} + +impl AcceptedRequestStore { + fn load(path: Option) -> Self { + let state = match path.as_deref().and_then(read_state) { + Some(state) if state.version == STATE_VERSION => state, + Some(state) if state.version == 1 => AcceptedRequestState { + // Version 1 recorded submissions carrying a double-wrapped + // ciphertext that iOS could not decrypt. Retry those requests + // after upgrading instead of preserving false success. + version: STATE_VERSION, + request_ids: VecDeque::new(), + activated_at_ms: 0, + last_priority_by_peer: state.last_priority_by_peer, + }, + _ => AcceptedRequestState { + version: STATE_VERSION, + activated_at_ms: current_unix_millis(), + request_ids: VecDeque::new(), + last_priority_by_peer: HashMap::new(), + }, + }; + let ids = state.request_ids.iter().cloned().collect(); + Self { path, state, ids } + } + + fn contains(&self, request_id: &str) -> bool { + self.ids.contains(request_id) + } + + fn predates_activation(&self, request_timestamp_ms: u64) -> bool { + request_timestamp_ms < self.state.activated_at_ms + } + + fn next_priority(&self, peer_account_id: [u8; 32], timestamp_priority: u64) -> u64 { + self.state + .last_priority_by_peer + .get(&hex::encode(peer_account_id)) + .map_or(timestamp_priority, |last| { + timestamp_priority.max(last.saturating_add(1)) + }) + } + + fn remember(&mut self, request_id: String, peer_account_id: [u8; 32], priority: u64) { + if !self.remember_id(request_id) { + return; + } + let peer_key = hex::encode(peer_account_id); + self.state + .last_priority_by_peer + .insert(peer_key.clone(), priority); + if self.state.last_priority_by_peer.len() > ACCEPTED_REQUEST_LIMIT + && let Some(oldest) = self + .state + .last_priority_by_peer + .keys() + .find(|key| key.as_str() != peer_key) + .cloned() + { + self.state.last_priority_by_peer.remove(&oldest); + } + if let Err(error) = self.save() { + warn!(%error, "could not persist accepted chat request"); + } + } + + fn remember_seen(&mut self, request_id: String) { + if self.remember_id(request_id) + && let Err(error) = self.save() + { + warn!(%error, "could not persist historical chat request"); + } + } + + fn remember_id(&mut self, request_id: String) -> bool { + if !self.ids.insert(request_id.clone()) { + return false; + } + self.state.request_ids.push_back(request_id); + while self.state.request_ids.len() > ACCEPTED_REQUEST_LIMIT { + if let Some(removed) = self.state.request_ids.pop_front() { + self.ids.remove(&removed); + } + } + true + } + + fn save(&self) -> Result<()> { + let Some(path) = &self.path else { + return Ok(()); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let bytes = serde_json::to_vec_pretty(&self.state)?; + let temporary = path.with_extension("json.tmp"); + fs::write(&temporary, bytes).with_context(|| format!("write {}", temporary.display()))?; + fs::rename(&temporary, path).with_context(|| format!("replace {}", path.display())) + } +} + +fn current_unix_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + +fn read_state(path: &Path) -> Option { + match fs::read(path) { + Ok(bytes) => match serde_json::from_slice(&bytes) { + Ok(state) => Some(state), + Err(error) => { + warn!(%error, path = %path.display(), "invalid chat-request state"); + None + } + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + warn!(%error, path = %path.display(), "could not read chat-request state"); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepted_request_store_is_bounded_and_round_trips() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("chat-requests.json"); + let mut store = AcceptedRequestStore::load(Some(path.clone())); + for index in 0..(ACCEPTED_REQUEST_LIMIT + 4) { + store.remember(format!("request-{index}"), [index as u8; 32], index as u64); + } + + let restored = AcceptedRequestStore::load(Some(path)); + + assert_eq!(restored.state.request_ids.len(), ACCEPTED_REQUEST_LIMIT); + assert!(!restored.contains("request-0")); + assert!(restored.contains(&format!("request-{}", ACCEPTED_REQUEST_LIMIT + 3))); + } + + #[test] + fn acceptance_priority_increments_within_one_peer_session() { + let mut store = AcceptedRequestStore::load(None); + let timestamp_priority = chat_statement_priority(1_763_164_800 + 42); + store.remember("first".to_string(), [7; 32], timestamp_priority); + + assert_eq!( + store.next_priority([7; 32], timestamp_priority), + timestamp_priority + 1 + ); + assert_eq!( + store.next_priority([8; 32], timestamp_priority), + timestamp_priority + ); + } + + #[test] + fn historical_request_is_remembered_without_consuming_peer_priority() { + let mut store = AcceptedRequestStore::load(None); + store.state.activated_at_ms = 100; + + assert!(store.predates_activation(99)); + assert!(!store.predates_activation(100)); + store.remember_seen("historical".to_string()); + + assert!(store.contains("historical")); + assert!(store.state.last_priority_by_peer.is_empty()); + } + + #[test] + fn version_one_state_retries_double_wrapped_acceptances() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("chat-requests.json"); + fs::write( + &path, + br#"{ + "version": 1, + "request_ids": ["failed-acceptance"], + "last_priority_by_peer": {"0707070707070707070707070707070707070707070707070707070707070707": 42} + }"#, + ) + .unwrap(); + + let store = AcceptedRequestStore::load(Some(path)); + + assert_eq!(store.state.version, STATE_VERSION); + assert_eq!(store.state.activated_at_ms, 0); + assert!(!store.contains("failed-acceptance")); + assert_eq!(store.next_priority([7; 32], 1), 43); + } + + #[test] + fn generated_message_ids_are_uuid_v4_shaped() { + let id = random_uuid(); + + assert_eq!(id.len(), 36); + assert_eq!(&id[14..15], "4"); + assert!(matches!(&id[19..20], "8" | "9" | "a" | "b")); + } +} diff --git a/rust/crates/truapi-host-cli/src/coinage_balance.rs b/rust/crates/truapi-host-cli/src/coinage_balance.rs new file mode 100644 index 000000000..761d27184 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/coinage_balance.rs @@ -0,0 +1,1676 @@ +//! Wallet-local Coinage inventory and chain reconciliation for `/balance`. + +use std::collections::{BTreeMap, HashMap}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use chrono::{DateTime, SecondsFormat, Utc}; +use futures_util::future::try_join_all; +use parity_scale_codec::Decode; +use rayon::prelude::*; +use scale_decode::DecodeAsType; +use serde::{Deserialize, Serialize}; +use sp_crypto_hashing::{blake2_128, twox_128}; +use subxt::client::{OnlineClient, OnlineClientAtBlock}; +use subxt::config::substrate::SubstrateConfig; +use subxt::dynamic; +use subxt_rpcs::client::{RpcClient, rpc_params}; +use truapi_server::host_logic::coinage::{derive_coin_public_key, derive_voucher_keys}; + +const SCAN_STATE_FILE: &str = "coinage-balance-scan.json"; +const SCAN_STATE_VERSION: u32 = 2; +const BATCH_SIZE: u32 = 500; +const EMPTY_BATCH_LIMIT: usize = 4; +const RECYCLE_AT_AGE: u16 = 14; +const MINIMUM_FULL_PRIVACY_RING_SIZE: u32 = 10; + +/// Human-readable values rendered by `/balance`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CashBalance { + pub total: String, + pub on_hold: Option, +} + +/// Balance plus the first voucher derivation index not known locally or +/// observed on chain. +pub struct CashDiscovery { + pub balance: CashBalance, + pub next_voucher_index: u32, + details: CashDetails, +} + +impl CashDiscovery { + /// Render local inventory and latest chain detail for `/balance --verbose`. + pub fn verbose_lines(&self) -> Vec { + let mut lines = vec![format!("Coins ({})", self.details.coins.len())]; + if self.details.coins.is_empty() { + lines.push(" none".to_string()); + } else { + lines.extend( + self.details + .coins + .iter() + .map(|detail| format!(" {detail}")), + ); + } + lines.push(format!("Vouchers ({})", self.details.vouchers.len())); + if self.details.vouchers.is_empty() { + lines.push(" none".to_string()); + } else { + lines.extend( + self.details + .vouchers + .iter() + .map(|detail| format!(" {detail}")), + ); + } + lines.push( + "Allocation and ready-at times are shown for locally allocated vouchers; chain-recovered vouchers have no historical timestamps." + .to_string(), + ); + lines + } +} + +/// Reconcile and read the active wallet's Coinage balance at one finalized +/// chain snapshot. +pub async fn read( + people_ws: &str, + root_entropy: &[u8], + session_path: Option<&Path>, +) -> Result { + Ok( + discover_with_mode(people_ws, root_entropy, session_path, false, false) + .await? + .balance, + ) +} + +/// Reconcile the active wallet's balance and next safe voucher derivation index +/// at one finalized chain snapshot. +pub async fn discover( + people_ws: &str, + root_entropy: &[u8], + session_path: Option<&Path>, +) -> Result { + discover_with_mode(people_ws, root_entropy, session_path, false, false).await +} + +/// Reconcile the active wallet's balance and enrich every included voucher +/// with its finalized recycler status for verbose rendering. +pub async fn inspect( + people_ws: &str, + root_entropy: &[u8], + session_path: Option<&Path>, +) -> Result { + discover_with_mode(people_ws, root_entropy, session_path, true, false).await +} + +/// Scan the next unexplored private-balance range, persist newly recovered +/// items, and return the reconciled balance. Repeated calls advance the +/// recovery horizon like the mobile wallet's manual Update action. +pub async fn recover( + people_ws: &str, + root_entropy: &[u8], + session_path: Option<&Path>, + verbose: bool, +) -> Result { + discover_with_mode(people_ws, root_entropy, session_path, verbose, true).await +} + +async fn discover_with_mode( + people_ws: &str, + root_entropy: &[u8], + session_path: Option<&Path>, + verbose: bool, + recover: bool, +) -> Result { + let query = ChainSnapshot::connect(people_ws).await?; + let state_path = session_path.map(|path| path.join(SCAN_STATE_FILE)); + let mut state = state_path + .as_deref() + .map(load_scan_state) + .transpose()? + .unwrap_or_default(); + let wallet_id = wallet_id(root_entropy)?; + let discovery = read_with_query_mode( + &query, + root_entropy, + state.wallet_mut(&wallet_id), + verbose, + recover, + ) + .await?; + if let Some(path) = state_path { + save_scan_state(&path, &state)?; + } + Ok(discovery) +} + +/// Add successfully allocated vouchers to the profile's wallet-local +/// inventory. Chain reconciliation on later balance reads updates their remote +/// state without dropping records that are temporarily undiscoverable. +pub fn record_allocated_vouchers( + session_path: Option<&Path>, + root_entropy: &[u8], + vouchers: &[(u32, i8, u64, u64)], +) -> Result<()> { + let Some(session_path) = session_path else { + return Ok(()); + }; + if vouchers.is_empty() { + return Ok(()); + } + + let path = session_path.join(SCAN_STATE_FILE); + let mut state = load_scan_state(&path)?; + let wallet_id = wallet_id(root_entropy)?; + let wallet = state.wallet_mut(&wallet_id); + for &(index, exponent, allocated_at_unix_ms, ready_at_unix_ms) in vouchers { + wallet.vouchers.insert( + index, + StoredVoucher::allocated(exponent, allocated_at_unix_ms, ready_at_unix_ms), + ); + wallet.highest_voucher_index = Some( + wallet + .highest_voucher_index + .map_or(index, |highest| highest.max(index)), + ); + } + save_scan_state(&path, &state) +} + +fn wallet_id(root_entropy: &[u8]) -> Result { + let first = derive_voucher_keys(root_entropy, 0).context("derive Coinage wallet id")?; + Ok(hex::encode(first.member)) +} + +#[derive(Debug, Clone, Copy)] +struct Denomination { + unit: u128, + precision: u8, + minimum_exponent: i8, + maximum_exponent: i8, +} + +impl Denomination { + fn value(self, exponent: i8) -> Result { + if exponent < self.minimum_exponent || exponent > self.maximum_exponent { + bail!( + "Coinage denomination exponent {exponent} is outside runtime range {}..={}", + self.minimum_exponent, + self.maximum_exponent + ); + } + let shift = u32::from(exponent.unsigned_abs()); + if exponent >= 0 { + self.unit + .checked_shl(shift) + .with_context(|| format!("Coinage denomination 2^{exponent} overflows")) + } else { + Ok(self.unit.checked_shr(shift).unwrap_or(0)) + } + } + + fn format(self, planks: u128) -> Result { + let minor_units = if self.precision >= 2 { + let divisor = checked_pow10(u32::from(self.precision - 2))?; + let mut rounded = planks / divisor; + let remainder = planks % divisor; + if remainder >= divisor.div_ceil(2) { + rounded = rounded + .checked_add(1) + .context("rounded CASH balance overflows")?; + } + rounded + } else { + planks + .checked_mul(checked_pow10(u32::from(2 - self.precision))?) + .context("scaled CASH balance overflows")? + }; + Ok(format!("{}.{:02}", minor_units / 100, minor_units % 100)) + } +} + +fn checked_pow10(exponent: u32) -> Result { + 10u128 + .checked_pow(exponent) + .with_context(|| format!("asset precision 10^{exponent} overflows")) +} + +#[derive(Debug, Clone, Copy)] +struct CoinState { + exponent: i8, + age: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +struct VoucherState { + exponent: i8, + on_hold: bool, + counted: bool, + location: VoucherLocation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum VoucherLocation { + Unlocated, + Onboarding, + Included { + ring_index: u32, + ring_position: u32, + ring_total: Option, + ring_included: Option, + }, + Suspended, + Unloaded { + ring_index: u32, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +struct StoredVoucher { + #[serde(flatten)] + state: VoucherState, + #[serde(default, skip_serializing_if = "Option::is_none")] + allocated_at_unix_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + ready_at_unix_ms: Option, +} + +impl StoredVoucher { + fn recovered(state: VoucherState) -> Self { + Self { + state, + allocated_at_unix_ms: None, + ready_at_unix_ms: None, + } + } + + fn allocated(exponent: i8, allocated_at_unix_ms: u64, ready_at_unix_ms: u64) -> Self { + Self { + state: VoucherState { + exponent, + on_hold: true, + counted: true, + location: VoucherLocation::Unlocated, + }, + allocated_at_unix_ms: Some(allocated_at_unix_ms), + ready_at_unix_ms: Some(ready_at_unix_ms), + } + } + + fn observe(&mut self, state: VoucherState) { + self.state = state; + } +} + +#[derive(Debug, Default)] +struct CashDetails { + coins: Vec, + vouchers: Vec, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ScanState { + #[serde(default = "scan_state_version")] + version: u32, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + wallets: BTreeMap, + // Version 1 stored one unscoped high-water mark. These fields are read only + // for migration and are bound to the first signer that opens the profile. + #[serde(default, skip_serializing_if = "Option::is_none")] + highest_coin_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + highest_voucher_index: Option, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct WalletScanState { + #[serde(default, skip_serializing_if = "Option::is_none")] + highest_coin_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + highest_voucher_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + coin_scan_horizon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + voucher_scan_horizon: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + vouchers: BTreeMap, +} + +const fn scan_state_version() -> u32 { + SCAN_STATE_VERSION +} + +impl ScanState { + fn wallet_mut(&mut self, wallet_id: &str) -> &mut WalletScanState { + if self.version == 1 && self.wallets.is_empty() { + self.wallets.insert( + wallet_id.to_string(), + WalletScanState { + highest_coin_index: self.highest_coin_index.take(), + highest_voucher_index: self.highest_voucher_index.take(), + coin_scan_horizon: None, + voucher_scan_horizon: None, + vouchers: BTreeMap::new(), + }, + ); + } + self.version = SCAN_STATE_VERSION; + self.wallets.entry(wallet_id.to_string()).or_default() + } +} + +#[async_trait] +trait CoinageQuery: Send + Sync { + fn denomination(&self) -> Denomination; + + async fn coins(&self, root_entropy: &[u8], indices: &[u32]) -> Result>>; + + async fn vouchers( + &self, + root_entropy: &[u8], + indices: &[u32], + known: &BTreeMap, + ) -> Result>>; + + async fn populate_voucher_details(&self, _vouchers: &mut [(u32, VoucherState)]) -> Result<()> { + Ok(()) + } +} + +struct Scanned { + items: Vec<(u32, T)>, + highest_found: Option, + horizon: Option, +} + +async fn read_with_query_mode( + query: &Q, + root_entropy: &[u8], + state: &mut WalletScanState, + verbose: bool, + recover: bool, +) -> Result { + let (coins, scanned_vouchers) = tokio::try_join!( + scan_coins( + query, + root_entropy, + state.highest_coin_index, + state.coin_scan_horizon, + recover, + ), + scan_vouchers( + query, + root_entropy, + state.highest_voucher_index, + state.voucher_scan_horizon, + &state.vouchers, + recover, + ), + )?; + state.highest_coin_index = coins.highest_found; + state.highest_voucher_index = scanned_vouchers.highest_found; + state.coin_scan_horizon = max_horizon(state.coin_scan_horizon, coins.horizon); + state.voucher_scan_horizon = max_horizon(state.voucher_scan_horizon, scanned_vouchers.horizon); + reconcile_vouchers(&mut state.vouchers, scanned_vouchers.items); + if let Some(highest) = state.vouchers.keys().next_back().copied() { + state.highest_voucher_index = Some( + state + .highest_voucher_index + .map_or(highest, |known| known.max(highest)), + ); + } + if verbose { + let mut voucher_states = state + .vouchers + .iter() + .map(|(&index, voucher)| (index, voucher.state)) + .collect::>(); + query.populate_voucher_details(&mut voucher_states).await?; + reconcile_vouchers(&mut state.vouchers, voucher_states); + } + + let denomination = query.denomination(); + let vouchers = state + .vouchers + .iter() + .map(|(&index, voucher)| (index, *voucher)) + .collect::>(); + let details = cash_details(denomination, &coins.items, &vouchers)?; + let mut total = 0u128; + let mut on_hold = 0u128; + for (_, coin) in coins.items { + let value = denomination.value(coin.exponent)?; + total = total.checked_add(value).context("CASH balance overflows")?; + if coin.age >= RECYCLE_AT_AGE { + on_hold = on_hold + .checked_add(value) + .context("CASH on-hold balance overflows")?; + } + } + for (_, voucher) in vouchers + .into_iter() + .filter(|(_, voucher)| voucher.state.counted) + { + let value = denomination.value(voucher.state.exponent)?; + total = total.checked_add(value).context("CASH balance overflows")?; + if voucher.state.on_hold { + on_hold = on_hold + .checked_add(value) + .context("CASH on-hold balance overflows")?; + } + } + + let next_voucher_index = state.highest_voucher_index.map_or(Ok(0), |index| { + index + .checked_add(1) + .context("Coinage voucher derivation indices are exhausted") + })?; + Ok(CashDiscovery { + balance: CashBalance { + total: denomination.format(total)?, + on_hold: (on_hold > 0) + .then(|| denomination.format(on_hold)) + .transpose()?, + }, + next_voucher_index, + details, + }) +} + +fn reconcile_vouchers( + inventory: &mut BTreeMap, + observations: Vec<(u32, VoucherState)>, +) { + for (index, observation) in observations { + inventory + .entry(index) + .and_modify(|voucher| voucher.observe(observation)) + .or_insert_with(|| StoredVoucher::recovered(observation)); + } +} + +fn max_horizon(current: Option, observed: Option) -> Option { + match (current, observed) { + (Some(current), Some(observed)) => Some(current.max(observed)), + (current, observed) => current.or(observed), + } +} + +fn voucher_timing(voucher: &StoredVoucher) -> Result { + let (Some(allocated), Some(ready)) = (voucher.allocated_at_unix_ms, voucher.ready_at_unix_ms) + else { + return Ok(String::new()); + }; + Ok(format!( + " · allocated {} · ready {}", + format_unix_ms(allocated)?, + format_unix_ms(ready)? + )) +} + +fn format_unix_ms(timestamp: u64) -> Result { + let timestamp = i64::try_from(timestamp).context("voucher timestamp is out of range")?; + let date = DateTime::::from_timestamp_millis(timestamp) + .context("voucher timestamp is out of range")?; + let precision = if timestamp % 1_000 == 0 { + SecondsFormat::Secs + } else { + SecondsFormat::Millis + }; + Ok(date.to_rfc3339_opts(precision, true)) +} + +fn cash_details( + denomination: Denomination, + coins: &[(u32, CoinState)], + vouchers: &[(u32, StoredVoucher)], +) -> Result { + let now = current_unix_ms()?; + let coins = coins + .iter() + .map(|(index, coin)| { + let amount = denomination.format(denomination.value(coin.exponent)?)?; + let state = if coin.age >= RECYCLE_AT_AGE { + "on hold" + } else { + "available" + }; + Ok(format!( + "#{index} · 2^{} · {amount} CASH · {state} · age {}", + coin.exponent, coin.age + )) + }) + .collect::>>()?; + let vouchers = vouchers + .iter() + .map(|(index, voucher)| { + let voucher_state = voucher.state; + let amount = denomination.format(denomination.value(voucher_state.exponent)?)?; + let time_ready = voucher + .ready_at_unix_ms + .is_none_or(|ready_at| ready_at <= now); + let (state, privacy, ring) = voucher_detail(voucher_state.location, time_ready); + let excluded = (!voucher_state.counted).then_some(" · excluded from balance"); + let timing = voucher_timing(voucher)?; + Ok(format!( + "#{index} · 2^{} · {amount} CASH · {state} · {privacy}{}{}{timing}", + voucher_state.exponent, + ring.map_or_else(String::new, |ring| format!(" · {ring}")), + excluded.unwrap_or_default(), + )) + }) + .collect::>>()?; + Ok(CashDetails { coins, vouchers }) +} + +fn voucher_detail( + location: VoucherLocation, + time_ready: bool, +) -> (&'static str, &'static str, Option) { + match location { + VoucherLocation::Unlocated => ("unlocated", "degraded", None), + VoucherLocation::Onboarding => ("pending", "degraded", None), + VoucherLocation::Suspended => ("suspended", "degraded", None), + VoucherLocation::Unloaded { ring_index } => ( + "unloaded", + "privacy n/a", + Some(format!("ring {ring_index}")), + ), + VoucherLocation::Included { + ring_index, + ring_position, + ring_total, + ring_included, + } => { + let state = if ring_included.is_none_or(|included| included > ring_position) { + "in recycler" + } else { + "pending" + }; + let privacy = if time_ready + && ring_included.is_some_and(|included| included >= MINIMUM_FULL_PRIVACY_RING_SIZE) + { + "full" + } else { + "degraded" + }; + let ring = match (ring_included, ring_total) { + (Some(included), Some(total)) => { + format!("ring {ring_index} ({included}/{total} included)") + } + _ => format!("ring {ring_index}"), + }; + (state, privacy, Some(ring)) + } + } +} + +fn current_unix_ms() -> Result { + let milliseconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before the Unix epoch")? + .as_millis(); + u64::try_from(milliseconds).context("system time exceeds u64 milliseconds") +} + +async fn scan_coins( + query: &Q, + root_entropy: &[u8], + known_highest: Option, + previous_horizon: Option, + recover: bool, +) -> Result> { + let mut scanned = Scanned { + items: Vec::new(), + highest_found: known_highest, + horizon: None, + }; + if let Some(highest) = known_highest { + let mut start = 0u32; + loop { + let end = start.saturating_add(BATCH_SIZE - 1).min(highest); + let indices = inclusive_indices(start, end); + let batch = query.coins(root_entropy, &indices).await?; + absorb_batch(&indices, batch, &mut scanned)?; + scanned.horizon = max_horizon(scanned.horizon, Some(end)); + if end == highest { + break; + } + start = end + 1; + } + } + + let Some(mut start) = scan_extension_start(known_highest, previous_horizon, recover) else { + return Ok(scanned); + }; + let mut empty_batches = 0; + while empty_batches < EMPTY_BATCH_LIMIT { + let end = start.saturating_add(BATCH_SIZE - 1); + let indices = inclusive_indices(start, end); + let batch = query.coins(root_entropy, &indices).await?; + if absorb_batch(&indices, batch, &mut scanned)? { + empty_batches = 0; + } else { + empty_batches += 1; + } + scanned.horizon = max_horizon(scanned.horizon, Some(end)); + let Some(next) = end.checked_add(1) else { + break; + }; + start = next; + } + Ok(scanned) +} + +async fn scan_vouchers( + query: &Q, + root_entropy: &[u8], + known_highest: Option, + previous_horizon: Option, + known_vouchers: &BTreeMap, + recover: bool, +) -> Result> { + let mut scanned = Scanned { + items: Vec::new(), + highest_found: known_highest, + horizon: None, + }; + if let Some(highest) = known_highest { + let mut start = 0u32; + loop { + let end = start.saturating_add(BATCH_SIZE - 1).min(highest); + let indices = inclusive_indices(start, end); + let batch = query + .vouchers(root_entropy, &indices, known_vouchers) + .await?; + absorb_batch(&indices, batch, &mut scanned)?; + scanned.horizon = max_horizon(scanned.horizon, Some(end)); + if end == highest { + break; + } + start = end + 1; + } + } + + let Some(mut start) = scan_extension_start(known_highest, previous_horizon, recover) else { + return Ok(scanned); + }; + let mut empty_batches = 0; + while empty_batches < EMPTY_BATCH_LIMIT { + let end = start.saturating_add(BATCH_SIZE - 1); + let indices = inclusive_indices(start, end); + let batch = query + .vouchers(root_entropy, &indices, known_vouchers) + .await?; + if absorb_batch(&indices, batch, &mut scanned)? { + empty_batches = 0; + } else { + empty_batches += 1; + } + scanned.horizon = max_horizon(scanned.horizon, Some(end)); + let Some(next) = end.checked_add(1) else { + break; + }; + start = next; + } + Ok(scanned) +} + +fn scan_extension_start( + known_highest: Option, + previous_horizon: Option, + recover: bool, +) -> Option { + let after_known = known_highest.map_or(Some(0), |highest| highest.checked_add(1))?; + if !recover { + return Some(after_known); + } + let Some(previous_horizon) = previous_horizon else { + return Some(after_known); + }; + previous_horizon + .checked_add(1) + .map(|after_horizon| after_known.max(after_horizon)) +} + +fn inclusive_indices(start: u32, end: u32) -> Vec { + (start..=end).collect() +} + +fn absorb_batch( + indices: &[u32], + batch: Vec>, + scanned: &mut Scanned, +) -> Result { + if indices.len() != batch.len() { + bail!( + "Coinage query returned {} entries for {} indices", + batch.len(), + indices.len() + ); + } + let mut found = false; + for (index, item) in indices.iter().copied().zip(batch) { + if let Some(item) = item { + found = true; + scanned.highest_found = Some( + scanned + .highest_found + .map_or(index, |highest| highest.max(index)), + ); + scanned.items.push((index, item)); + } + } + Ok(found) +} + +#[derive(Decode)] +struct OnChainCoin { + value: i8, + age: u16, +} + +#[derive(Decode)] +enum RingPosition { + Onboarding { + _queue_page: u32, + _queued_at: u64, + }, + Included { + ring_index: u32, + _ring_page: u32, + ring_position: u32, + }, + Suspended, +} + +#[derive(Decode)] +struct AssetMetadata { + _deposit: u128, + _name: Vec, + _symbol: Vec, + decimals: u8, + _is_frozen: bool, +} + +#[derive(DecodeAsType)] +struct RingKeysStatus { + total: u32, + included: u32, +} + +#[derive(Deserialize)] +struct StorageChangeSet { + changes: Vec<(String, Option)>, +} + +struct ChainSnapshot { + rpc: RpcClient, + at: OnlineClientAtBlock, + block_hash: String, + denomination: Denomination, +} + +impl ChainSnapshot { + async fn connect(url: &str) -> Result { + let rpc = RpcClient::from_insecure_url(url) + .await + .with_context(|| format!("connect Coinage RPC {url}"))?; + let client = OnlineClient::::from_rpc_client(rpc.clone()) + .await + .context("initialize Coinage chain client")?; + let at = client + .at_current_block() + .await + .context("resolve finalized Coinage block")?; + let block_hash = format!("{:?}", at.block_hash()); + let constants = at.constants(); + let unit = constants + .entry(dynamic::constant::("Coinage", "UnderlyingAssetUnit")) + .context("read Coinage.UnderlyingAssetUnit")?; + let minimum_exponent = constants + .entry(dynamic::constant::("Coinage", "MinimumExponent")) + .context("read Coinage.MinimumExponent")?; + let maximum_exponent = constants + .entry(dynamic::constant::("Coinage", "MaximumExponent")) + .context("read Coinage.MaximumExponent")?; + + let underlying_entry = at + .storage() + .entry(dynamic::storage::<(), ()>("Coinage", "UnderlyingAssetId")) + .context("resolve Coinage.UnderlyingAssetId")?; + let underlying_key = underlying_entry + .fetch_key(()) + .context("encode Coinage.UnderlyingAssetId key")?; + let underlying = query_storage_values(&rpc, &block_hash, vec![underlying_key.clone()]) + .await? + .remove(&underlying_key) + .context("Coinage underlying asset is not configured")?; + let metadata_key = asset_metadata_key(&underlying); + let metadata = query_storage_values(&rpc, &block_hash, vec![metadata_key.clone()]) + .await? + .remove(&metadata_key) + .context("Coinage underlying asset has no Assets.Metadata record")?; + let metadata: AssetMetadata = decode_value(&metadata, "Assets.Metadata")?; + + Ok(Self { + rpc, + at, + block_hash, + denomination: Denomination { + unit, + precision: metadata.decimals, + minimum_exponent, + maximum_exponent, + }, + }) + } + + async fn values(&self, keys: Vec>) -> Result, Vec>> { + query_storage_values(&self.rpc, &self.block_hash, keys).await + } +} + +#[async_trait] +impl CoinageQuery for ChainSnapshot { + fn denomination(&self) -> Denomination { + self.denomination + } + + async fn coins(&self, root_entropy: &[u8], indices: &[u32]) -> Result>> { + let entry = self + .at + .storage() + .entry(dynamic::storage::<([u8; 32],), ()>( + "Coinage", + "CoinsByOwner", + )) + .context("resolve Coinage.CoinsByOwner")?; + let keys = indices + .par_iter() + .map(|index| { + let public = derive_coin_public_key(root_entropy, *index) + .with_context(|| format!("derive coin key {index}"))?; + entry + .fetch_key((public,)) + .with_context(|| format!("encode Coinage coin key {index}")) + }) + .collect::>>()?; + let values = self.values(keys.clone()).await?; + keys.iter() + .map(|key| { + values + .get(key) + .map(|value| { + let coin: OnChainCoin = decode_value(value, "Coinage.CoinsByOwner")?; + Ok(CoinState { + exponent: coin.value, + age: coin.age, + }) + }) + .transpose() + }) + .collect() + } + + async fn vouchers( + &self, + root_entropy: &[u8], + indices: &[u32], + known: &BTreeMap, + ) -> Result>> { + let materials = indices + .par_iter() + .map(|index| { + derive_voucher_keys(root_entropy, *index) + .with_context(|| format!("derive voucher key {index}")) + }) + .collect::>>()?; + let recycler_entry = self + .at + .storage() + .entry(dynamic::storage::<([u8; 32],), ()>( + "Coinage", + "RecyclersCoinToRecycler", + )) + .context("resolve Coinage.RecyclersCoinToRecycler")?; + let recycler_keys = materials + .iter() + .map(|material| recycler_entry.fetch_key((material.member,))) + .collect::, _>>() + .context("encode Coinage recycler keys")?; + let recycler_values = self.values(recycler_keys.clone()).await?; + + let members_entry = self + .at + .storage() + .entry(dynamic::storage::<([u8; 32], [u8; 32]), ()>( + "Members", "Members", + )) + .context("resolve Members.Members")?; + let mut located = Vec::new(); + for (position, ((material, recycler_key), _index)) in materials + .iter() + .zip(&recycler_keys) + .zip(indices) + .enumerate() + { + let Some(value) = recycler_values.get(recycler_key) else { + continue; + }; + let exponent: i8 = decode_value(value, "Coinage.RecyclersCoinToRecycler")?; + let collection = recycler_collection(exponent); + let member_key = members_entry + .fetch_key((collection, material.member)) + .context("encode recycler Members key")?; + located.push((position, exponent, *material, member_key)); + } + + let member_keys = located + .iter() + .map(|(_, _, _, key)| key.clone()) + .collect::>(); + let member_values = self.values(member_keys).await?; + let unloaded_entry = self + .at + .storage() + .entry(dynamic::storage::<(i8, u32, [u8; 32]), ()>( + "Coinage", + "RecyclersUnloaded", + )) + .context("resolve Coinage.RecyclersUnloaded")?; + let mut output = vec![None; indices.len()]; + let mut included = Vec::new(); + for (position, exponent, material, member_key) in located { + let Some(value) = member_values.get(&member_key) else { + continue; + }; + match decode_value(value, "Members.Members")? { + RingPosition::Included { + ring_index, + ring_position, + .. + } => { + let unloaded_key = unloaded_entry + .fetch_key((exponent, ring_index, material.recycler_alias)) + .context("encode Coinage unloaded voucher key")?; + included.push((position, exponent, ring_index, ring_position, unloaded_key)); + } + RingPosition::Onboarding { .. } => { + output[position] = Some(VoucherState { + exponent, + on_hold: true, + counted: true, + location: VoucherLocation::Onboarding, + }); + } + RingPosition::Suspended => { + output[position] = Some(VoucherState { + exponent, + on_hold: true, + counted: true, + location: VoucherLocation::Suspended, + }); + } + } + } + + let unloaded_keys = included + .iter() + .map(|(_, _, _, _, key)| key.clone()) + .collect::>(); + let unloaded_values = self.values(unloaded_keys).await?; + for (position, exponent, ring_index, ring_position, unloaded_key) in included { + let unloaded = unloaded_values.contains_key(&unloaded_key); + output[position] = Some(VoucherState { + exponent, + on_hold: false, + counted: !unloaded, + location: if unloaded { + VoucherLocation::Unloaded { ring_index } + } else { + VoucherLocation::Included { + ring_index, + ring_position, + ring_total: None, + ring_included: None, + } + }, + }); + } + + // Members may no longer expose an older voucher even though the + // unload marker remains queryable. A persisted recycler location lets + // us still reconcile that voucher instead of indefinitely retaining a + // stale spendable balance. + let fallback_unloaded = indices + .iter() + .enumerate() + .filter_map(|(position, index)| { + if output[position].is_some() { + return None; + } + let stored = known.get(index)?; + let ring_index = match stored.state.location { + VoucherLocation::Included { ring_index, .. } + | VoucherLocation::Unloaded { ring_index } => ring_index, + VoucherLocation::Unlocated + | VoucherLocation::Onboarding + | VoucherLocation::Suspended => return None, + }; + Some( + unloaded_entry + .fetch_key(( + stored.state.exponent, + ring_index, + materials[position].recycler_alias, + )) + .map(|key| (position, stored.state.exponent, ring_index, key)), + ) + }) + .collect::, _>>() + .context("encode persisted Coinage unloaded voucher keys")?; + let fallback_values = self + .values( + fallback_unloaded + .iter() + .map(|(_, _, _, key)| key.clone()) + .collect(), + ) + .await?; + for (position, exponent, ring_index, key) in fallback_unloaded { + if fallback_values.contains_key(&key) { + output[position] = Some(VoucherState { + exponent, + on_hold: false, + counted: false, + location: VoucherLocation::Unloaded { ring_index }, + }); + } + } + Ok(output) + } + + async fn populate_voucher_details(&self, vouchers: &mut [(u32, VoucherState)]) -> Result<()> { + let storage = self.at.storage(); + let mut positions_by_ring = HashMap::<(i8, u32), Vec>::new(); + for (position, (_, voucher)) in vouchers.iter().enumerate() { + let VoucherLocation::Included { ring_index, .. } = voucher.location else { + continue; + }; + positions_by_ring + .entry((voucher.exponent, ring_index)) + .or_default() + .push(position); + } + let statuses = try_join_all(positions_by_ring.into_iter().map( + |((exponent, ring_index), positions)| { + let storage = &storage; + async move { + let collection = recycler_collection(exponent); + let address = dynamic::storage::<([u8; 32], u32), RingKeysStatus>( + "Members", + "RingKeysStatus", + ); + let status = storage + .fetch(address, (collection, ring_index)) + .await + .context("read Members.RingKeysStatus")? + .decode() + .context("decode Members.RingKeysStatus")?; + Ok::<_, anyhow::Error>((positions, status)) + } + }, + )) + .await?; + for (positions, status) in statuses { + for position in positions { + let VoucherLocation::Included { + ring_total, + ring_included, + .. + } = &mut vouchers[position].1.location + else { + unreachable!("only included vouchers have ring-status queries"); + }; + *ring_total = Some(status.total); + *ring_included = Some(status.included); + } + } + Ok(()) + } +} + +fn recycler_collection(exponent: i8) -> [u8; 32] { + let mut identifier = [0u8; 32]; + identifier[..16].copy_from_slice(b"coinage/recycler"); + identifier[16] = exponent as u8; + identifier +} + +fn asset_metadata_key(asset_id: &[u8]) -> Vec { + [ + twox_128(b"Assets").as_slice(), + twox_128(b"Metadata").as_slice(), + blake2_128(asset_id).as_slice(), + asset_id, + ] + .concat() +} + +async fn query_storage_values( + rpc: &RpcClient, + block_hash: &str, + keys: Vec>, +) -> Result, Vec>> { + if keys.is_empty() { + return Ok(HashMap::new()); + } + let hex_keys = keys + .iter() + .map(|key| format!("0x{}", hex::encode(key))) + .collect::>(); + let change_sets = rpc + .request::>("state_queryStorageAt", rpc_params![hex_keys, block_hash]) + .await + .context("RPC state_queryStorageAt")?; + let mut values = HashMap::new(); + for change_set in change_sets { + for (key, value) in change_set.changes { + let Some(value) = value else { + continue; + }; + let key = decode_hex(&key).context("decode storage response key")?; + let value = decode_hex(&value).context("decode storage response value")?; + values.insert(key, value); + } + } + Ok(values) +} + +fn decode_hex(value: &str) -> Result> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)).map_err(Into::into) +} + +fn decode_value(bytes: &[u8], label: &str) -> Result { + let mut input = bytes; + let value = T::decode(&mut input).with_context(|| format!("decode {label}"))?; + if !input.is_empty() { + bail!("{label} has {} trailing SCALE bytes", input.len()); + } + Ok(value) +} + +fn load_scan_state(path: &Path) -> Result { + let text = match fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(ScanState::default()); + } + Err(error) => return Err(error).with_context(|| format!("read {}", path.display())), + }; + let state: ScanState = + serde_json::from_str(&text).with_context(|| format!("decode {}", path.display()))?; + if state.version != 1 && state.version != SCAN_STATE_VERSION { + bail!( + "{} has unsupported version {}", + path.display(), + state.version + ); + } + Ok(state) +} + +fn save_scan_state(path: &Path, state: &ScanState) -> Result<()> { + let parent = path + .parent() + .with_context(|| format!("{} has no parent directory", path.display()))?; + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + let temporary = parent.join(format!("{SCAN_STATE_FILE}.{}.tmp", std::process::id())); + let text = serde_json::to_string_pretty(state)?; + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&temporary) + .with_context(|| format!("open {}", temporary.display()))?; + file.write_all(format!("{text}\n").as_bytes()) + .with_context(|| format!("write {}", temporary.display()))?; + file.sync_all() + .with_context(|| format!("sync {}", temporary.display()))?; + fs::rename(&temporary, path).with_context(|| format!("replace {}", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::sync::Mutex; + + use super::*; + + struct FakeQuery { + denomination: Denomination, + coins: BTreeMap, + vouchers: BTreeMap, + coin_calls: Mutex>>, + voucher_calls: Mutex>>, + } + + impl Default for FakeQuery { + fn default() -> Self { + Self { + denomination: Denomination { + unit: 10_000, + precision: 6, + minimum_exponent: 0, + maximum_exponent: 14, + }, + coins: BTreeMap::new(), + vouchers: BTreeMap::new(), + coin_calls: Mutex::new(Vec::new()), + voucher_calls: Mutex::new(Vec::new()), + } + } + } + + #[async_trait] + impl CoinageQuery for FakeQuery { + fn denomination(&self) -> Denomination { + self.denomination + } + + async fn coins( + &self, + _root_entropy: &[u8], + indices: &[u32], + ) -> Result>> { + self.coin_calls.lock().unwrap().push(indices.to_vec()); + Ok(indices + .iter() + .map(|index| self.coins.get(index).copied()) + .collect()) + } + + async fn vouchers( + &self, + _root_entropy: &[u8], + indices: &[u32], + _known: &BTreeMap, + ) -> Result>> { + self.voucher_calls.lock().unwrap().push(indices.to_vec()); + Ok(indices + .iter() + .map(|index| self.vouchers.get(index).copied()) + .collect()) + } + } + + #[tokio::test] + async fn totals_coins_vouchers_and_on_hold_like_cash() { + let mut query = FakeQuery::default(); + query.coins.insert( + 3, + CoinState { + exponent: 6, + age: 1, + }, + ); + query.coins.insert( + 501, + CoinState { + exponent: 5, + age: RECYCLE_AT_AGE, + }, + ); + query.vouchers.insert( + 2, + VoucherState { + exponent: 10, + on_hold: false, + counted: true, + location: VoucherLocation::Included { + ring_index: 9, + ring_position: 2, + ring_total: Some(12), + ring_included: Some(12), + }, + }, + ); + query.vouchers.insert( + 700, + VoucherState { + exponent: 7, + on_hold: true, + counted: true, + location: VoucherLocation::Onboarding, + }, + ); + query.vouchers.insert( + 800, + VoucherState { + exponent: 12, + on_hold: false, + counted: false, + location: VoucherLocation::Unloaded { ring_index: 4 }, + }, + ); + let mut state = WalletScanState::default(); + + let balance = read_with_query_mode(&query, &[0xab; 32], &mut state, false, false) + .await + .unwrap(); + + assert_eq!( + balance.balance, + CashBalance { + total: "12.48".to_string(), + on_hold: Some("1.60".to_string()), + } + ); + assert_eq!(balance.next_voucher_index, 801); + assert_eq!(state.highest_coin_index, Some(501)); + assert_eq!(state.highest_voucher_index, Some(800)); + assert_eq!( + balance.verbose_lines(), + vec![ + "Coins (2)", + " #3 · 2^6 · 0.64 CASH · available · age 1", + " #501 · 2^5 · 0.32 CASH · on hold · age 14", + "Vouchers (3)", + " #2 · 2^10 · 10.24 CASH · in recycler · full · ring 9 (12/12 included)", + " #700 · 2^7 · 1.28 CASH · pending · degraded", + " #800 · 2^12 · 40.96 CASH · unloaded · privacy n/a · ring 4 · excluded from balance", + "Allocation and ready-at times are shown for locally allocated vouchers; chain-recovered vouchers have no historical timestamps.", + ] + ); + } + + #[tokio::test] + async fn zero_balance_omits_on_hold_detail() { + let mut state = WalletScanState::default(); + let balance = + read_with_query_mode(&FakeQuery::default(), &[0xab; 32], &mut state, false, false) + .await + .unwrap(); + + assert_eq!(balance.balance.total, "0.00"); + assert_eq!(balance.balance.on_hold, None); + assert_eq!(balance.next_voucher_index, 0); + } + + #[tokio::test] + async fn cached_highest_index_recovers_beyond_the_initial_gap() { + let mut query = FakeQuery::default(); + query.coins.insert( + 2_100, + CoinState { + exponent: 0, + age: 0, + }, + ); + let mut state = WalletScanState { + highest_coin_index: Some(2_100), + ..WalletScanState::default() + }; + + let balance = read_with_query_mode(&query, &[0xab; 32], &mut state, false, false) + .await + .unwrap(); + + assert_eq!(balance.balance.total, "0.01"); + assert_eq!(state.highest_coin_index, Some(2_100)); + assert!( + query + .coin_calls + .lock() + .unwrap() + .iter() + .any(|indices| indices.contains(&2_100)) + ); + } + + #[tokio::test] + async fn repeated_recovery_advances_beyond_previous_empty_ranges() { + let mut query = FakeQuery::default(); + let mut state = WalletScanState::default(); + read_with_query_mode(&query, &[0xab; 32], &mut state, false, false) + .await + .unwrap(); + query.coins.insert( + 2_100, + CoinState { + exponent: 0, + age: 0, + }, + ); + query.vouchers.insert( + 2_100, + VoucherState { + exponent: 0, + on_hold: false, + counted: true, + location: VoucherLocation::Included { + ring_index: 9, + ring_position: 0, + ring_total: None, + ring_included: None, + }, + }, + ); + + let recovered = read_with_query_mode(&query, &[0xab; 32], &mut state, false, true) + .await + .unwrap(); + let first_coin_horizon = state.coin_scan_horizon; + let first_voucher_horizon = state.voucher_scan_horizon; + read_with_query_mode(&query, &[0xab; 32], &mut state, false, true) + .await + .unwrap(); + + assert_eq!(recovered.balance.total, "0.02"); + assert_eq!(first_coin_horizon, Some(4_499)); + assert_eq!(first_voucher_horizon, Some(4_499)); + assert_eq!(state.coin_scan_horizon, Some(6_499)); + assert_eq!(state.voucher_scan_horizon, Some(6_499)); + assert!( + query + .coin_calls + .lock() + .unwrap() + .iter() + .any(|indices| indices.first() == Some(&4_500)) + ); + assert!( + query + .voucher_calls + .lock() + .unwrap() + .iter() + .any(|indices| indices.first() == Some(&4_500)) + ); + } + + #[tokio::test] + async fn locally_known_vouchers_survive_incomplete_chain_reconstruction() { + let exponents = [8, 7, 6, 5, 4, 2]; + let mut query = FakeQuery::default(); + let mut state = WalletScanState { + highest_voucher_index: Some(17), + ..WalletScanState::default() + }; + for index in 0..18 { + let exponent = exponents[index as usize % exponents.len()]; + state + .vouchers + .insert(index, StoredVoucher::allocated(exponent, 0, 1_000)); + if index < 6 { + query.vouchers.insert( + index, + VoucherState { + exponent, + on_hold: false, + counted: true, + location: VoucherLocation::Included { + ring_index: 9, + ring_position: index, + ring_total: None, + ring_included: None, + }, + }, + ); + } + } + + let balance = read_with_query_mode(&query, &[0xab; 32], &mut state, false, false) + .await + .unwrap(); + + assert_eq!(balance.balance.total, "15.00"); + assert_eq!(balance.balance.on_hold.as_deref(), Some("10.00")); + assert_eq!(state.vouchers.len(), 18); + } + + #[tokio::test] + async fn unloaded_observation_excludes_a_persisted_voucher() { + let mut query = FakeQuery::default(); + query.vouchers.insert( + 0, + VoucherState { + exponent: 8, + on_hold: false, + counted: false, + location: VoucherLocation::Unloaded { ring_index: 3 }, + }, + ); + let mut state = WalletScanState { + highest_voucher_index: Some(0), + vouchers: BTreeMap::from([(0, StoredVoucher::allocated(8, 0, 1_000))]), + ..WalletScanState::default() + }; + + let balance = read_with_query_mode(&query, &[0xab; 32], &mut state, false, false) + .await + .unwrap(); + + assert_eq!(balance.balance.total, "0.00"); + assert!(!state.vouchers[&0].state.counted); + } + + #[test] + fn allocated_vouchers_are_scoped_to_the_active_signer() { + let temporary = tempfile::tempdir().unwrap(); + let first_entropy = [0x11; 32]; + let second_entropy = [0x22; 32]; + + record_allocated_vouchers(Some(temporary.path()), &first_entropy, &[(0, 8, 100, 200)]) + .unwrap(); + record_allocated_vouchers(Some(temporary.path()), &second_entropy, &[(0, 7, 300, 400)]) + .unwrap(); + + let state = load_scan_state(&temporary.path().join(SCAN_STATE_FILE)).unwrap(); + assert_eq!(state.wallets.len(), 2); + assert_eq!( + state.wallets[&wallet_id(&first_entropy).unwrap()].vouchers[&0] + .state + .exponent, + 8 + ); + assert_eq!( + state.wallets[&wallet_id(&second_entropy).unwrap()].vouchers[&0] + .state + .exponent, + 7 + ); + } + + #[test] + fn version_one_high_water_marks_migrate_to_the_active_signer() { + let mut state = ScanState { + version: 1, + wallets: BTreeMap::new(), + highest_coin_index: Some(42), + highest_voucher_index: Some(84), + }; + + let wallet = state.wallet_mut("wallet"); + + assert_eq!(wallet.highest_coin_index, Some(42)); + assert_eq!(wallet.highest_voucher_index, Some(84)); + assert_eq!(state.version, SCAN_STATE_VERSION); + assert_eq!(state.highest_coin_index, None); + assert_eq!(state.highest_voucher_index, None); + } + + #[test] + fn scan_state_round_trips_without_secret_material() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join(SCAN_STATE_FILE); + let wallet_id = wallet_id(&[0xab; 32]).unwrap(); + let state = ScanState { + version: SCAN_STATE_VERSION, + wallets: BTreeMap::from([( + wallet_id, + WalletScanState { + highest_coin_index: Some(42), + highest_voucher_index: Some(84), + coin_scan_horizon: Some(999), + voucher_scan_horizon: Some(1_999), + vouchers: BTreeMap::new(), + }, + )]), + highest_coin_index: None, + highest_voucher_index: None, + }; + + save_scan_state(&path, &state).unwrap(); + + assert_eq!(load_scan_state(&path).unwrap(), state); + let text = fs::read_to_string(path).unwrap(); + assert!(!text.contains("entropy")); + assert!(!text.contains("mnemonic")); + } + + #[test] + fn voucher_timestamps_render_as_utc() { + assert_eq!( + format_unix_ms(1_775_000_123_456).unwrap(), + "2026-03-31T23:35:23.456Z" + ); + } + + #[test] + fn full_ring_is_degraded_until_the_local_ready_time() { + let location = VoucherLocation::Included { + ring_index: 9, + ring_position: 2, + ring_total: Some(12), + ring_included: Some(12), + }; + + assert_eq!(voucher_detail(location, false).1, "degraded"); + assert_eq!(voucher_detail(location, true).1, "full"); + } + + #[test] + fn scale_models_match_current_coinage_layout() { + let coin = decode_value::(&[7, 14, 0], "coin").unwrap(); + assert_eq!(coin.value, 7); + assert_eq!(coin.age, 14); + + let included = + decode_value::(&[1, 9, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0], "member") + .unwrap(); + assert!(matches!( + included, + RingPosition::Included { ring_index: 9, .. } + )); + } +} diff --git a/rust/crates/truapi-host-cli/src/coinage_top_up.rs b/rust/crates/truapi-host-cli/src/coinage_top_up.rs new file mode 100644 index 000000000..91b7b778d --- /dev/null +++ b/rust/crates/truapi-host-cli/src/coinage_top_up.rs @@ -0,0 +1,616 @@ +//! Testnet Coinage top-up flow for the signing-host `/top-up` command. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; +use bip39::Mnemonic; +use parity_scale_codec::{Compact, Encode}; +use rand::rngs::OsRng; +use rand::{Rng, RngCore}; +use rustls::SignatureScheme; +use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; +use rustls::sign::SigningKey; +use schnorrkel::{ExpansionMode, Keypair, MiniSecretKey}; +use subxt::client::OnlineClient; +use subxt::config::{RpcConfigFor, substrate::SubstrateConfig}; +use subxt::dynamic; +use subxt::utils::{AccountId32, MultiAddress, MultiSignature}; +use subxt_rpcs::client::RpcClient; +use subxt_rpcs::methods::LegacyRpcMethods; +use truapi_server::host_logic::coinage::derive_voucher_load; +use truapi_server::host_logic::product_account::derive_root_keypair_from_entropy; +use truapi_server::statement_allowance as alloc; +use truapi_server::statement_allowance::extension::{ChainState, EncodedExtension, Metadata}; + +/// The iOS testnet faucet account used by the Cash top-up service. +const FAUCET_MNEMONIC: &str = + "fluid truth dirt pulp rhythm decorate truck divert season tray cattle tumble"; +const TOP_UP_MINIMUM_UNITS: u128 = 500; +const AS_COINAGE: &str = "AsCoinage"; +const INFALLIBLE_UNPAID_SIGNED: &str = "InfallibleUnpaidSigned"; +const SR25519_SIGNING_CONTEXT: &[u8] = b"substrate"; +const VALUE_TRANSFER_AUTHORIZATION_ENV: &str = "HOST_CLI_VALUE_TRANSFER_AUTH_KEY"; +const IOS_VALUE_TRANSFER_AUTHORIZATION_ENV: &str = "W3S_AUTH_KEY"; +const MAX_VOUCHER_READY_DELAY_MS: u64 = 6 * 60 * 60 * 1_000; +const ED25519_PKCS8_SEED_PREFIX: &[u8] = &[ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, +]; + +/// Result rendered after both top-up transactions finalize successfully. +pub struct TopUpResult { + pub amount: String, + pub vouchers: Vec, +} + +/// Wallet-local metadata for one voucher allocated by `/top-up`. +pub struct TopUpVoucher { + pub index: u32, + pub exponent: i8, + pub allocated_at_unix_ms: u64, + pub ready_at_unix_ms: u64, +} + +/// Ed25519 authority used by the iOS app to unlock protected test-asset +/// transfers. +pub struct ValueTransferAuthorizer { + signing_key: std::sync::Arc, +} + +impl ValueTransferAuthorizer { + /// Load the same seed injected into iOS builds as `W3S_AUTH_KEY`. + pub fn from_environment() -> Result { + let value = std::env::var(VALUE_TRANSFER_AUTHORIZATION_ENV) + .or_else(|_| std::env::var(IOS_VALUE_TRANSFER_AUTHORIZATION_ENV)) + .with_context(|| { + format!( + "/top-up requires {VALUE_TRANSFER_AUTHORIZATION_ENV} \ + (or the iOS-compatible {IOS_VALUE_TRANSFER_AUTHORIZATION_ENV})" + ) + })?; + let seed = hex::decode(value.trim().strip_prefix("0x").unwrap_or(value.trim())) + .context("value-transfer authorization key must be hex")?; + if seed.len() != 32 { + bail!( + "value-transfer authorization key must be a 32-byte Ed25519 seed, got {} bytes", + seed.len() + ); + } + Self::from_seed(&seed) + } + + fn from_seed(seed: &[u8]) -> Result { + let mut pkcs8 = Vec::with_capacity(ED25519_PKCS8_SEED_PREFIX.len() + seed.len()); + pkcs8.extend_from_slice(ED25519_PKCS8_SEED_PREFIX); + pkcs8.extend_from_slice(seed); + let private_key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(pkcs8)); + let signing_key = rustls::crypto::ring::sign::any_supported_type(&private_key) + .context("initialize Ed25519 value-transfer authorization key")?; + Ok(Self { signing_key }) + } + + fn sign(&self, message: &[u8]) -> Result> { + let signer = self + .signing_key + .choose_scheme(&[SignatureScheme::ED25519]) + .context("value-transfer key does not support Ed25519")?; + signer + .sign(message) + .context("sign value-transfer authorization") + } +} + +#[derive(Debug, Clone, Copy, Encode)] +enum CodecPreservation { + Expendable, +} + +#[derive(Debug, Clone, Copy, Encode)] +struct UnpaidLoadInput { + preservation: CodecPreservation, + value: i8, + member_key: [u8; 32], + proof_of_ownership: [u8; 64], +} + +/// Transfer 5.00 of the configured test asset to a temporary holder, then +/// convert it into vouchers owned by the active wallet. +pub async fn submit( + people_ws: &str, + root_entropy: &[u8], + first_voucher_index: u32, + authorizer: &ValueTransferAuthorizer, +) -> Result { + let rpc = alloc::rpc::RpcClient::connect(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)?; + + let native_rpc = RpcClient::from_insecure_url(people_ws) + .await + .with_context(|| format!("connect Coinage top-up RPC {people_ws}"))?; + let client = OnlineClient::::from_rpc_client(native_rpc.clone()) + .await + .context("initialize Coinage top-up client")?; + let at = client + .at_current_block() + .await + .context("resolve finalized Coinage top-up block")?; + let constants = at.constants(); + let unit = constants + .entry(dynamic::constant::("Coinage", "UnderlyingAssetUnit")) + .context("read Coinage.UnderlyingAssetUnit")?; + let minimum_exponent = constants + .entry(dynamic::constant::("Coinage", "MinimumExponent")) + .context("read Coinage.MinimumExponent")?; + let maximum_exponent = constants + .entry(dynamic::constant::("Coinage", "MaximumExponent")) + .context("read Coinage.MaximumExponent")?; + let max_batch = constants + .entry(dynamic::constant::("Coinage", "MaxBatchUnpaidLoad")) + .context("read Coinage.MaxBatchUnpaidLoad")?; + let exponents = + denomination_breakdown(TOP_UP_MINIMUM_UNITS, minimum_exponent, maximum_exponent)?; + if exponents.len() > max_batch as usize { + bail!( + "5.00 CASH requires {} vouchers but the runtime permits {max_batch}", + exponents.len() + ); + } + let allocated_at_unix_ms = current_unix_ms()?; + let mut rng = OsRng; + let vouchers = allocate_voucher_metadata( + first_voucher_index, + &exponents, + allocated_at_unix_ms, + &mut rng, + )?; + let amount_planks = unit + .checked_mul(TOP_UP_MINIMUM_UNITS) + .context("Coinage top-up amount overflows")?; + + let finalized_hash = rpc.finalized_head().await.map_err(anyhow::Error::msg)?; + let underlying_entry = at + .storage() + .entry(dynamic::storage::<(), ()>("Coinage", "UnderlyingAssetId")) + .context("resolve Coinage.UnderlyingAssetId")?; + let underlying_key = underlying_entry + .fetch_key(()) + .context("encode Coinage.UnderlyingAssetId key")?; + let underlying_asset = rpc + .get_storage_at(&underlying_key, &finalized_hash) + .await + .map_err(anyhow::Error::msg)? + .context("Coinage underlying asset is not configured")?; + + let temporary = random_keypair()?; + let temporary_account = temporary.public.to_bytes(); + let faucet = faucet_keypair()?; + let legacy = LegacyRpcMethods::>::new(native_rpc.clone()); + + // Prepare and encode the voucher load before moving any faucet funds. This + // catches derivation or metadata incompatibilities while the temporary + // account is still empty. + let loads = build_voucher_loads( + root_entropy, + first_voucher_index, + temporary_account, + &exponents, + )?; + let temporary_nonce = legacy + .system_account_next_index(&AccountId32(temporary_account)) + .await + .context("read temporary top-up account nonce")?; + let load_call = build_unpaid_load_call(&metadata, &loads)?; + let as_coinage_variant = metadata + .extension_info_variant_index(AS_COINAGE, INFALLIBLE_UNPAID_SIGNED) + .map_err(anyhow::Error::msg)?; + let as_coinage_extra = encode_as_coinage(as_coinage_variant, temporary_nonce)?; + let load = build_signed_v4( + &metadata, + chain_state, + &temporary, + temporary_nonce, + &load_call, + authorizer, + Some(as_coinage_extra), + )?; + + let faucet_account = AccountId32(faucet.public.to_bytes()); + let faucet_nonce = legacy + .system_account_next_index(&faucet_account) + .await + .context("read top-up faucet nonce")?; + let transfer_call = build_asset_transfer_call( + &metadata, + &underlying_asset, + temporary_account, + amount_planks, + )?; + let transfer = build_signed_v4( + &metadata, + chain_state, + &faucet, + faucet_nonce, + &transfer_call, + authorizer, + None, + )?; + at.transactions() + .from_bytes(transfer) + .submit_and_watch() + .await + .context("submit top-up asset transfer")? + .wait_for_finalized_success() + .await + .context("finalize top-up asset transfer")?; + + at.transactions() + .from_bytes(load) + .submit_and_watch() + .await + .context("submit Coinage voucher load")? + .wait_for_finalized_success() + .await + .context("finalize Coinage voucher load")?; + + Ok(TopUpResult { + amount: "5.00".to_string(), + vouchers, + }) +} + +fn allocate_voucher_metadata( + first_voucher_index: u32, + exponents: &[i8], + allocated_at_unix_ms: u64, + rng: &mut R, +) -> Result> { + exponents + .iter() + .enumerate() + .map(|(offset, &exponent)| { + let offset = u32::try_from(offset).context("too many Coinage vouchers")?; + let index = first_voucher_index + .checked_add(offset) + .context("Coinage voucher derivation indices are exhausted")?; + let ready_delay = rng.gen_range(0..=MAX_VOUCHER_READY_DELAY_MS); + let ready_at_unix_ms = allocated_at_unix_ms + .checked_add(ready_delay) + .context("Coinage voucher ready-at time overflows")?; + Ok(TopUpVoucher { + index, + exponent, + allocated_at_unix_ms, + ready_at_unix_ms, + }) + }) + .collect() +} + +fn current_unix_ms() -> Result { + let milliseconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before the Unix epoch")? + .as_millis(); + u64::try_from(milliseconds).context("system time exceeds u64 milliseconds") +} + +fn random_keypair() -> Result { + let mut seed = [0u8; 32]; + OsRng.fill_bytes(&mut seed); + Ok(MiniSecretKey::from_bytes(&seed) + .map_err(|error| anyhow::anyhow!("generate temporary top-up key: {error:?}"))? + .expand_to_keypair(ExpansionMode::Ed25519)) +} + +fn faucet_keypair() -> Result { + let entropy = Mnemonic::parse(FAUCET_MNEMONIC) + .context("parse built-in testnet top-up faucet")? + .to_entropy(); + derive_root_keypair_from_entropy(&entropy) + .map_err(|error| anyhow::anyhow!("derive testnet top-up faucet: {error}")) +} + +fn denomination_breakdown( + minimum_units: u128, + minimum_exponent: i8, + maximum_exponent: i8, +) -> Result> { + if minimum_exponent > maximum_exponent { + bail!("invalid Coinage denomination range {minimum_exponent}..={maximum_exponent}"); + } + if maximum_exponent < 0 { + bail!("5.00 CASH cannot be represented by sub-unit denominations"); + } + let mut remaining = minimum_units; + let mut output = Vec::new(); + for exponent in (minimum_exponent.max(0)..=maximum_exponent).rev() { + let value = 1u128 + .checked_shl(exponent as u32) + .with_context(|| format!("Coinage denomination 2^{exponent} overflows"))?; + while remaining >= value { + remaining -= value; + output.push(exponent); + } + } + if remaining != 0 { + bail!( + "5.00 CASH cannot be represented by runtime denominations \ + {minimum_exponent}..={maximum_exponent}" + ); + } + Ok(output) +} + +fn build_voucher_loads( + root_entropy: &[u8], + first_index: u32, + external_asset_holder: [u8; 32], + exponents: &[i8], +) -> Result> { + exponents + .iter() + .enumerate() + .map(|(offset, exponent)| { + let offset = u32::try_from(offset).context("too many Coinage vouchers")?; + let index = first_index + .checked_add(offset) + .context("Coinage voucher derivation indices are exhausted")?; + let load = derive_voucher_load(root_entropy, index, &external_asset_holder) + .with_context(|| format!("derive Coinage voucher {index}"))?; + Ok(UnpaidLoadInput { + preservation: CodecPreservation::Expendable, + value: *exponent, + member_key: load.member, + proof_of_ownership: load.proof_of_ownership, + }) + }) + .collect() +} + +fn build_asset_transfer_call( + metadata: &Metadata, + underlying_asset: &[u8], + target: [u8; 32], + amount: u128, +) -> Result> { + let mut call = metadata + .call_indices("Assets", "transfer") + .map_err(anyhow::Error::msg)? + .to_vec(); + call.extend_from_slice(underlying_asset); + MultiAddress::::Id(AccountId32(target)).encode_to(&mut call); + Compact(amount).encode_to(&mut call); + Ok(call) +} + +fn build_unpaid_load_call(metadata: &Metadata, items: &[UnpaidLoadInput]) -> Result> { + let mut call = metadata + .call_indices("Coinage", "load_recycler_with_external_asset_unpaid_batch") + .map_err(anyhow::Error::msg)? + .to_vec(); + items.encode_to(&mut call); + Ok(call) +} + +fn encode_as_coinage(variant: u8, nonce: u64) -> Result> { + let nonce = u32::try_from(nonce).context("Coinage nonce exceeds u32")?; + Ok([vec![1, variant], nonce.encode()].concat()) +} + +fn build_signed_v4( + metadata: &Metadata, + mut chain_state: ChainState, + signer: &Keypair, + nonce: u64, + call_data: &[u8], + authorizer: &ValueTransferAuthorizer, + as_coinage_extra: Option>, +) -> Result> { + chain_state.nonce = u32::try_from(nonce).context("account nonce exceeds u32")?; + let mut extensions = metadata.encode_signed_extensions(&chain_state); + if let Some(extra) = as_coinage_extra { + metadata + .replace_signed_extension_extra(&mut extensions, AS_COINAGE, extra) + .map_err(anyhow::Error::msg)?; + } + let authorization_index = metadata + .extension_index("AuthorizeValueTransfer") + .context("AuthorizeValueTransfer extension not found in metadata")?; + let authorization_payload = + extension_implication_payload(call_data, &extensions, authorization_index); + let authorization_hash = sp_crypto_hashing::blake2_256(&authorization_payload); + let authorization_signature = authorizer.sign(&authorization_hash)?; + if authorization_signature.len() != 64 { + bail!( + "Ed25519 value-transfer signature is {} bytes, expected 64", + authorization_signature.len() + ); + } + let mut authorization_extra = Vec::with_capacity(65); + authorization_extra.push(1); + authorization_extra.extend_from_slice(&authorization_signature); + metadata + .replace_signed_extension_extra( + &mut extensions, + "AuthorizeValueTransfer", + authorization_extra, + ) + .map_err(anyhow::Error::msg)?; + + let signer_payload = signer_payload(call_data, &extensions); + let signature = + signer + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &signer_payload, &signer.public); + let address = MultiAddress::::Id(AccountId32(signer.public.to_bytes())); + let signature = MultiSignature::Sr25519(signature.to_bytes()); + + let mut inner = vec![0x84]; + address.encode_to(&mut inner); + signature.encode_to(&mut inner); + for extension in extensions { + inner.extend_from_slice(&extension.extra); + } + inner.extend_from_slice(call_data); + Ok(inner.encode()) +} + +fn extension_implication_payload( + call_data: &[u8], + extensions: &[EncodedExtension], + extension_index: usize, +) -> Vec { + let tail = &extensions[extension_index + 1..]; + let mut payload = Vec::with_capacity(1 + call_data.len()); + // Transaction-extension pipeline version. iOS adds this same zero byte + // when signing a V4 inherited implication. + payload.push(0); + payload.extend_from_slice(call_data); + for extension in tail { + payload.extend_from_slice(&extension.extra); + } + for extension in tail { + payload.extend_from_slice(&extension.additional_signed); + } + payload +} + +fn signer_payload(call_data: &[u8], extensions: &[EncodedExtension]) -> Vec { + let mut payload = Vec::with_capacity(call_data.len()); + payload.extend_from_slice(call_data); + for extension in extensions { + payload.extend_from_slice(&extension.extra); + } + for extension in extensions { + payload.extend_from_slice(&extension.additional_signed); + } + if payload.len() > 256 { + sp_crypto_hashing::blake2_256(&payload).to_vec() + } else { + payload + } +} + +#[cfg(test)] +mod tests { + use rand::SeedableRng; + use rand::rngs::StdRng; + + use super::*; + + const FIXTURE: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../truapi-server/tests/fixtures/paseo-next-v2-metadata.scale" + )); + + #[test] + fn five_cash_breaks_down_like_the_ios_wallet() { + assert_eq!( + denomination_breakdown(500, 0, 14).unwrap(), + vec![8, 7, 6, 5, 4, 2] + ); + } + + #[test] + fn denomination_breakdown_respects_runtime_bounds() { + assert!(denomination_breakdown(1, 1, 14).is_err()); + assert!(denomination_breakdown(500, 4, 14).is_err()); + assert!(denomination_breakdown(500, 14, 0).is_err()); + } + + #[test] + fn live_fixture_resolves_top_up_calls_and_coinage_authorization() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + + assert_eq!( + metadata.call_indices("Assets", "transfer").unwrap(), + [14, 8] + ); + assert_eq!( + metadata + .call_indices("Coinage", "load_recycler_with_external_asset_unpaid_batch") + .unwrap(), + [68, 16], + ); + assert_eq!( + metadata + .extension_info_variant_index(AS_COINAGE, INFALLIBLE_UNPAID_SIGNED) + .unwrap(), + 5, + ); + assert_eq!(encode_as_coinage(5, 9).unwrap(), [1, 5, 9, 0, 0, 0]); + } + + #[test] + fn voucher_batch_uses_consecutive_wallet_indices() { + let owner = [0x42; 32]; + let loads = build_voucher_loads(&[0xab; 32], 7, owner, &[8, 2]).unwrap(); + let first = derive_voucher_load(&[0xab; 32], 7, &owner).unwrap(); + let second = derive_voucher_load(&[0xab; 32], 8, &owner).unwrap(); + + assert_eq!(loads[0].member_key, first.member); + assert_eq!(loads[1].member_key, second.member); + assert_eq!(loads[0].value, 8); + assert_eq!(loads[1].value, 2); + } + + #[test] + fn allocated_voucher_metadata_uses_consecutive_indices_and_mobile_ready_delay() { + let mut rng = StdRng::seed_from_u64(42); + + let vouchers = allocate_voucher_metadata(7, &[8, 2], 1_000_000, &mut rng).unwrap(); + + assert_eq!( + vouchers + .iter() + .map(|voucher| (voucher.index, voucher.exponent)) + .collect::>(), + [(7, 8), (8, 2)] + ); + assert!(vouchers.iter().all(|voucher| { + (1_000_000..=1_000_000 + MAX_VOUCHER_READY_DELAY_MS).contains(&voucher.ready_at_unix_ms) + })); + } + + #[test] + fn value_transfer_authorizer_matches_the_ed25519_reference_vector() { + let seed = hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60") + .unwrap(); + let authorizer = ValueTransferAuthorizer::from_seed(&seed).unwrap(); + + assert_eq!( + hex::encode(authorizer.sign(&[]).unwrap()), + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155\ + 5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b" + ); + } + + #[test] + fn value_transfer_implication_has_version_call_and_extension_tail() { + let extensions = vec![ + EncodedExtension { + extra: vec![0xaa], + additional_signed: vec![0xbb], + }, + EncodedExtension { + extra: vec![0x11, 0x12], + additional_signed: vec![0x21], + }, + EncodedExtension { + extra: vec![0x31], + additional_signed: vec![0x41, 0x42], + }, + ]; + + assert_eq!( + extension_implication_payload(&[0x01, 0x02], &extensions, 0), + [0x00, 0x01, 0x02, 0x11, 0x12, 0x31, 0x21, 0x41, 0x42] + ); + } +} diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 66d615a39..cd590032b 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -12,6 +12,9 @@ mod accounts; mod attestation; mod chain; +mod chat_requests; +mod coinage_balance; +mod coinage_top_up; mod frame_server; mod network; mod platform; @@ -718,6 +721,7 @@ struct SigningHostSession { runtime: Arc, runtime_factory: Arc, responder: Option>, + chat_request_monitor: Option>, signer: Option, cached_user_id: Option, last_script: Option, @@ -764,7 +768,7 @@ async fn start_signing_host( { profile = Some(catalog.promote_to_user(current, user_id)?); } - let signer = profile + let mut signer = profile .as_ref() .map(|profile| { accounts::resolve_cached_signer( @@ -790,6 +794,25 @@ async fn start_signing_host( .profile(DEFAULT_SESSION_NAME) .expect("default session profile is valid") }); + if signer.is_none() && mnemonic.is_some() { + let mut explicit_signer = accounts::resolve_signer(ResolveSignerConfig { + base_path: &storage_profile.account_base_path, + network, + mnemonic: mnemonic.clone(), + account: None, + lite_username_prefix: None, + }) + .await?; + match attestation::registered_lite_username(network.people_ws, &explicit_signer.entropy) + .await + { + Ok(user_id) => explicit_signer.lite_username = Some(user_id), + Err(error) => { + tracing::warn!(%error, "explicit signer has no resolvable People-chain username") + } + } + signer = Some(explicit_signer); + } let approval = approval_policy(args.auto_accept); let runtime = build_signing_runtime( network, @@ -814,8 +837,10 @@ async fn start_signing_host( .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)?; + if let Some(user_id) = &cached_signer.lite_username { + if let Some(profile) = &profile { + catalog.store_user_id(profile, user_id)?; + } cached_user_id = Some(user_id.clone()); if let Some(ui) = &ui { ui.connection(user_id.clone()); @@ -838,10 +863,11 @@ async fn start_signing_host( ui.event(SystemEvent::SigningHostNeedsSession); } - Ok(SigningHostSession { + let mut session = SigningHostSession { runtime, runtime_factory, responder: None, + chat_request_monitor: None, signer, cached_user_id, last_script, @@ -853,7 +879,9 @@ async fn start_signing_host( lite_username_prefix: normalized(args.lite_username_prefix.clone()), approval, ui, - }) + }; + restart_chat_request_monitor(&mut session)?; + Ok(session) } fn build_signing_runtime( @@ -875,6 +903,7 @@ fn build_signing_runtime( platform_info(), network.people_genesis, network.bulletin_genesis, + network.asset_hub_genesis, ) .context("invalid signing host config")?; Ok(Arc::new(SigningHostRuntime::new( @@ -889,6 +918,9 @@ impl Drop for SigningHostSession { if let Some(responder) = self.responder.take() { responder.abort(); } + if let Some(monitor) = self.chat_request_monitor.take() { + monitor.abort(); + } } } @@ -1034,14 +1066,45 @@ async fn activate_current_signer(session: &mut SigningHostSession) -> Result<()> .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)?; + if let Some(user_id) = &signer.lite_username { + if let Some(profile) = &session.profile { + 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); + restart_chat_request_monitor(session)?; + Ok(()) +} + +fn restart_chat_request_monitor(session: &mut SigningHostSession) -> Result<()> { + if let Some(monitor) = session.chat_request_monitor.take() { + monitor.abort(); + } + let Some(signer) = &session.signer else { + return Ok(()); + }; + let state_directory = session + .profile + .as_ref() + .map(|profile| profile.path.clone()) + .or_else(|| { + session + .catalog + .profile(DEFAULT_SESSION_NAME) + .ok() + .map(|profile| profile.path) + }); + let network = session.network; + let entropy = signer.entropy.clone(); + session.chat_request_monitor = Some(tokio::spawn(chat_requests::monitor( + network, + entropy, + state_directory, + ))); Ok(()) } @@ -1276,6 +1339,7 @@ async fn switch_session(session: &mut SigningHostSession, name: String) -> Resul session.signer = Some(signer); session.last_script = last_script; session.profile = Some(profile); + restart_chat_request_monitor(session)?; if let Some(ui) = &session.ui { let current_name = session .profile @@ -1494,7 +1558,10 @@ async fn pairing_interactive_loop( Err(error) => ui.error(error.to_string()), } } - ShellCommand::Pair(_) | ShellCommand::Session(_) => { + ShellCommand::Pair(_) + | ShellCommand::Balance { .. } + | ShellCommand::TopUp + | ShellCommand::Session(_) => { ui.error("command is only available on the signing host"); } } @@ -1723,6 +1790,10 @@ async fn execute_interactive_operation( ) -> Result<()> { match command { ShellCommand::Pair(deeplink) => start_deeplink_responder(session, deeplink).await?, + ShellCommand::Balance { verbose, recover } => { + show_balance(session, verbose, recover).await? + } + ShellCommand::TopUp => top_up_balance(session).await?, ShellCommand::Script(Some(script)) => { let session_path = session.profile.as_ref().map(|profile| profile.path.clone()); let script = @@ -1768,6 +1839,10 @@ async fn execute_non_interactive_command( ) -> Result<()> { match command { ShellCommand::Pair(deeplink) => respond_to_deeplink(session, deeplink).await?, + ShellCommand::Balance { verbose, recover } => { + show_balance(session, verbose, recover).await? + } + ShellCommand::TopUp => top_up_balance(session).await?, ShellCommand::Script(script) => { let script = match script { Some(script) => { @@ -1829,6 +1904,124 @@ async fn execute_non_interactive_command( Ok(()) } +async fn show_balance( + session: &mut SigningHostSession, + verbose: bool, + recover: bool, +) -> Result<()> { + ensure_signer(session).await?; + terminal_ui::update_activity( + "balance", + if recover { + "Recovering CASH balance" + } else { + "Reading CASH balance" + }, + Some( + if recover { + "Scanning the next private-balance range at the latest finalized block" + } else { + "Scanning Coinage at the latest finalized block" + } + .to_string(), + ), + ActivityState::Running, + ); + let signer = session + .signer + .as_ref() + .context("signer was not initialized")?; + let session_path = session + .profile + .as_ref() + .map(|profile| profile.path.as_path()); + let (balance, details) = if recover { + let discovery = coinage_balance::recover( + session.network.people_ws, + &signer.entropy, + session_path, + verbose, + ) + .await?; + let details = if verbose { + discovery.verbose_lines() + } else { + Vec::new() + }; + (discovery.balance, details) + } else if verbose { + let discovery = + coinage_balance::inspect(session.network.people_ws, &signer.entropy, session_path) + .await?; + let details = discovery.verbose_lines(); + (discovery.balance, details) + } else { + ( + coinage_balance::read(session.network.people_ws, &signer.entropy, session_path).await?, + Vec::new(), + ) + }; + terminal_ui::output_event(SystemEvent::Balance { + total: balance.total, + on_hold: balance.on_hold, + details, + }); + Ok(()) +} + +async fn top_up_balance(session: &mut SigningHostSession) -> Result<()> { + ensure_signer(session).await?; + let authorizer = coinage_top_up::ValueTransferAuthorizer::from_environment()?; + terminal_ui::update_activity( + "top-up", + "Topping up CASH", + Some("Preparing 5.00 from the testnet faucet".to_string()), + ActivityState::Running, + ); + let signer = session + .signer + .as_ref() + .context("signer was not initialized")?; + let session_path = session + .profile + .as_ref() + .map(|profile| profile.path.as_path()); + let discovery = + coinage_balance::discover(session.network.people_ws, &signer.entropy, session_path).await?; + terminal_ui::update_activity( + "top-up", + "Topping up CASH", + Some("Funding a temporary holder and loading vouchers".to_string()), + ActivityState::Running, + ); + let result = coinage_top_up::submit( + session.network.people_ws, + &signer.entropy, + discovery.next_voucher_index, + &authorizer, + ) + .await?; + let allocated_vouchers = result + .vouchers + .iter() + .map(|voucher| { + ( + voucher.index, + voucher.exponent, + voucher.allocated_at_unix_ms, + voucher.ready_at_unix_ms, + ) + }) + .collect::>(); + coinage_balance::record_allocated_vouchers(session_path, &signer.entropy, &allocated_vouchers) + .context("top-up finalized but its voucher inventory could not be persisted")?; + terminal_ui::output_event(SystemEvent::TopUp { + amount: result.amount, + vouchers: result.vouchers.len(), + }); + Ok(()) +} + fn scratch_script_directory(session: &SigningHostSession) -> PathBuf { session.profile.as_ref().map_or_else( || std::env::temp_dir().join("truapi-host").join("scripts"), diff --git a/rust/crates/truapi-host-cli/src/network.rs b/rust/crates/truapi-host-cli/src/network.rs index c1f909202..311234aec 100644 --- a/rust/crates/truapi-host-cli/src/network.rs +++ b/rust/crates/truapi-host-cli/src/network.rs @@ -27,6 +27,9 @@ impl Network { bulletin_genesis: hex_literal_genesis( "8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22", ), + asset_hub_genesis: hex_literal_genesis( + "bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f", + ), live_chain_endpoints: PASEO_NEXT_V2_CHAIN_ENDPOINTS, }, } @@ -39,7 +42,7 @@ const PASEO_NEXT_V2_CHAIN_ENDPOINTS: &[ChainEndpoint] = &[ "bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f", ), ws: "wss://paseo-asset-hub-next-rpc.polkadot.io", - required_for_host: false, + required_for_host: true, }, ChainEndpoint { genesis: hex_literal_genesis( @@ -67,6 +70,7 @@ pub struct NetworkConfig { pub bulletin_ws: &'static str, pub people_genesis: [u8; 32], pub bulletin_genesis: [u8; 32], + pub asset_hub_genesis: [u8; 32], pub live_chain_endpoints: &'static [ChainEndpoint], } diff --git a/rust/crates/truapi-host-cli/src/signing_shell.rs b/rust/crates/truapi-host-cli/src/signing_shell.rs index 3650f58bc..b660f364d 100644 --- a/rust/crates/truapi-host-cli/src/signing_shell.rs +++ b/rust/crates/truapi-host-cli/src/signing_shell.rs @@ -33,6 +33,10 @@ pub enum SessionCommand { pub enum ShellCommand { /// Answer a Polkadot Mobile pairing deeplink. Pair(String), + /// Show the active wallet's Coinage CASH balance. + Balance { verbose: bool, recover: bool }, + /// Add 5.00 test CASH using the configured iOS-compatible faucet. + TopUp, /// Edit the remembered product script, or run an explicit one, through the /// public frame endpoint. Script(Option), @@ -85,6 +89,8 @@ pub fn parse_command(input: &str) -> Result { } Ok(ShellCommand::Script(Some(PathBuf::from(argument)))) } + "/balance" => parse_balance_arguments(argument), + "/top-up" => no_argument(name, argument, ShellCommand::TopUp), "/help" => no_argument(name, argument, ShellCommand::Help), "/clear" => no_argument(name, argument, ShellCommand::Clear), "/copy" => no_argument(name, argument, ShellCommand::Copy), @@ -142,6 +148,8 @@ pub struct Completion { const SIGNING_COMMANDS: &[(&str, &str)] = &[ ("/pair", "answer a Polkadot Mobile pairing URL"), + ("/balance", "show the active wallet's CASH balance"), + ("/top-up", "add 5.00 CASH from the testnet faucet"), ("/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"), @@ -164,6 +172,11 @@ const PAIRING_COMMANDS: &[(&str, &str)] = &[ ("/quit", "shut down the pairing host"), ]; +const BALANCE_ARGUMENTS: &[(&str, &str)] = &[ + ("--verbose", "show finalized coin and voucher details"), + ("--recover", "scan the next private-balance recovery range"), +]; + const LOG_ARGUMENTS: &[(&str, &str)] = &[ ("error", "show only errors"), ("warn", "show warnings and errors"), @@ -190,6 +203,11 @@ fn completions_for_scope( 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("/balance ") + { + return balance_argument_completions(prefix); + } if scope == CommandScope::SigningHost && let Some(prefix) = input.strip_prefix("/session ") { @@ -232,6 +250,58 @@ fn completions_for_scope( .collect() } +fn parse_balance_arguments(argument: &str) -> Result { + let mut verbose = false; + let mut recover = false; + for flag in argument.split_whitespace() { + match flag { + "--verbose" if !verbose => verbose = true, + "--recover" if !recover => recover = true, + _ => return Err("usage: /balance [--verbose] [--recover]".to_string()), + } + } + Ok(ShellCommand::Balance { verbose, recover }) +} + +fn balance_argument_completions(prefix: &str) -> Vec { + let trailing_space = prefix.ends_with(char::is_whitespace); + let mut tokens = prefix.split_whitespace().collect::>(); + let partial = if trailing_space { + "" + } else { + tokens.pop().unwrap_or_default() + }; + if tokens + .iter() + .any(|token| !BALANCE_ARGUMENTS.iter().any(|(flag, _)| flag == token)) + || tokens.iter().any(|token| { + tokens + .iter() + .filter(|candidate| *candidate == token) + .count() + > 1 + }) + { + return Vec::new(); + } + + BALANCE_ARGUMENTS + .iter() + .filter(|(flag, _)| !tokens.contains(flag) && flag.starts_with(partial)) + .map(|(flag, description)| Completion { + value: format!( + "/balance {}{}", + tokens + .iter() + .map(|token| format!("{token} ")) + .collect::(), + flag + ), + description, + }) + .collect() +} + fn fixed_argument_completions( command: &str, prefix: &str, @@ -524,6 +594,10 @@ pub fn parse_approval(input: &str) -> Option { /// Text displayed by `/help` in either presentation mode. pub const HELP_TEXT: &str = "\ /pair answer a Polkadot Mobile pairing URL +/balance show the active wallet's CASH balance +/balance --verbose include finalized coin and voucher details +/balance --recover scan the next private-balance recovery range +/top-up add 5.00 CASH from the testnet faucet /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 @@ -576,6 +650,35 @@ mod tests { )))) ); assert_eq!(parse_command("/script"), Ok(ShellCommand::Script(None))); + assert_eq!( + parse_command("/balance"), + Ok(ShellCommand::Balance { + verbose: false, + recover: false, + }) + ); + assert_eq!( + parse_command("/balance --verbose"), + Ok(ShellCommand::Balance { + verbose: true, + recover: false, + }) + ); + assert_eq!( + parse_command("/balance --recover"), + Ok(ShellCommand::Balance { + verbose: false, + recover: true, + }) + ); + assert_eq!( + parse_command("/balance --recover --verbose"), + Ok(ShellCommand::Balance { + verbose: true, + recover: true, + }) + ); + assert_eq!(parse_command("/top-up"), Ok(ShellCommand::TopUp)); assert_eq!(parse_command("/login"), Ok(ShellCommand::Login)); assert_eq!(parse_command("/logout"), Ok(ShellCommand::Logout)); assert_eq!( @@ -614,6 +717,12 @@ mod tests { ); assert!(parse_command("/whoami").is_err()); assert!(parse_command("/copy now").is_err()); + assert_eq!( + parse_command("/balance now").unwrap_err(), + "usage: /balance [--verbose] [--recover]" + ); + assert!(parse_command("/balance --recover --recover").is_err()); + assert!(parse_command("/top-up 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()); @@ -712,6 +821,8 @@ mod tests { assert!(commands.contains(&"/logout".to_string())); assert!(commands.contains(&"/product".to_string())); assert!(commands.contains(&"/copy".to_string())); + assert!(!commands.contains(&"/balance".to_string())); + assert!(!commands.contains(&"/top-up".to_string())); assert!(!commands.iter().any(|command| command.starts_with("/pair"))); assert!( !commands @@ -720,6 +831,35 @@ mod tests { ); } + #[test] + fn signing_host_completion_offers_balance() { + let matches = completions_for_scope("/bal", &[], CommandScope::SigningHost); + + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].value, "/balance"); + } + + #[test] + fn signing_host_completion_offers_balance_option_after_command() { + let matches = completions_for_scope("/balance ", &[], CommandScope::SigningHost); + + assert_eq!( + matches + .iter() + .map(|completion| completion.value.as_str()) + .collect::>(), + ["/balance --verbose", "/balance --recover"] + ); + } + + #[test] + fn signing_host_completion_offers_the_remaining_balance_option() { + let matches = completions_for_scope("/balance --verbose ", &[], CommandScope::SigningHost); + + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].value, "/balance --verbose --recover"); + } + #[test] fn log_completion_offers_levels_after_command_for_both_hosts() { for scope in [CommandScope::SigningHost, CommandScope::PairingHost] { @@ -753,4 +893,12 @@ mod tests { ); } } + + #[test] + fn signing_host_completion_offers_top_up() { + let matches = completions_for_scope("/top", &[], CommandScope::SigningHost); + + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].value, "/top-up"); + } } diff --git a/rust/crates/truapi-host-cli/src/terminal_ui.rs b/rust/crates/truapi-host-cli/src/terminal_ui.rs index ae0f777c9..0145058a5 100644 --- a/rust/crates/truapi-host-cli/src/terminal_ui.rs +++ b/rust/crates/truapi-host-cli/src/terminal_ui.rs @@ -127,6 +127,25 @@ pub enum SystemEvent { ring_index: u32, members: usize, }, + Balance { + total: String, + on_hold: Option, + details: Vec, + }, + TopUp { + amount: String, + vouchers: usize, + }, + ChatRequestDetected { + username: String, + }, + ChatRequestAccepted { + username: String, + }, + ChatRequestFailed { + username: String, + reason: String, + }, AllowanceChecking { target: String, }, @@ -1297,6 +1316,46 @@ impl App { "LitePeople ring ready".to_string(), Some(format!("Ring {ring_index} · {members} members")), ), + SystemEvent::Balance { + total, + on_hold, + details, + } => { + let mut detail = on_hold + .map(|amount| format!("{amount} on hold")) + .into_iter() + .collect::>(); + detail.extend(details); + self.activity( + "balance".to_string(), + format!("CASH {total}"), + (!detail.is_empty()).then(|| detail.join("\n")), + ActivityState::Succeeded, + ); + } + SystemEvent::TopUp { amount, vouchers } => self.activity( + "top-up".to_string(), + format!("CASH topped up {amount}"), + Some(format!("{vouchers} vouchers added")), + ActivityState::Succeeded, + ), + SystemEvent::ChatRequestDetected { username } => self.start_activity( + format!("chat-request:{username}"), + format!("Chat request from {username}"), + Some("Accepting automatically".to_string()), + ), + SystemEvent::ChatRequestAccepted { username } => self.activity( + format!("chat-request:{username}"), + format!("Accepted chat request from {username}"), + None, + ActivityState::Succeeded, + ), + SystemEvent::ChatRequestFailed { username, reason } => self.activity( + format!("chat-request:{username}"), + format!("Could not accept chat request from {username}"), + Some(reason), + ActivityState::Failed, + ), SystemEvent::AllowanceChecking { target } => self.start_activity( format!("allowance:{target}"), format!("Preparing {} access", allowance_name(&target)), @@ -2716,6 +2775,17 @@ mod tests { ); assert!( completions[visible] + .iter() + .any(|completion| completion.value == "/top-up") + ); + + let last_visible = completion_window( + completions.len(), + completions.len() - 1, + MAX_VISIBLE_COMPLETIONS, + ); + assert!( + completions[last_visible] .iter() .any(|completion| completion.value == "/copy") ); @@ -2739,17 +2809,17 @@ mod tests { .lines() .find(|line| line.contains("edit the last")) .context("render script completion")?; - let clear = screen + let top_up = screen .lines() - .find(|line| line.contains("clear the visible")) - .context("render clear completion")?; + .find(|line| line.contains("add 5.00")) + .context("render top-up 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")); + assert_eq!(column(script, "edit"), column(top_up, "add 5.00")); Ok(()) } @@ -2889,6 +2959,70 @@ mod tests { ); } + #[test] + fn balance_event_shares_cash_copy_between_renderers() { + let event = SystemEvent::Balance { + total: "12.50".to_string(), + on_hold: Some("0.32".to_string()), + details: Vec::new(), + }; + assert_eq!(event.human(), "✓ CASH 12.50\n 0.32 on hold"); + + let mut app = test_app(); + app.handle_system_event(event); + assert_eq!(app.transcript_text(), "✓ CASH 12.50\n 0.32 on hold"); + } + + #[test] + fn verbose_balance_event_renders_every_detail_line() { + let event = SystemEvent::Balance { + total: "5.00".to_string(), + on_hold: None, + details: vec![ + "Coins (0)".to_string(), + " none".to_string(), + "Vouchers (1)".to_string(), + " #0 · 2^8 · 2.56 CASH · in recycler · full · ring 4 (10/12 included)".to_string(), + ], + }; + + assert_eq!( + event.human(), + "✓ CASH 5.00\n Coins (0)\n none\n Vouchers (1)\n #0 · 2^8 · 2.56 CASH · in recycler · full · ring 4 (10/12 included)" + ); + } + + #[test] + fn top_up_event_shares_cash_copy_between_renderers() { + let event = SystemEvent::TopUp { + amount: "5.00".to_string(), + vouchers: 6, + }; + assert_eq!(event.human(), "✓ CASH topped up 5.00\n 6 vouchers added"); + + let mut app = test_app(); + app.handle_system_event(event); + assert_eq!( + app.transcript_text(), + "✓ CASH topped up 5.00\n 6 vouchers added" + ); + } + + #[test] + fn chat_request_acceptance_replaces_detection_activity() { + let mut app = test_app(); + app.handle_system_event(SystemEvent::ChatRequestDetected { + username: "alice.42".to_string(), + }); + app.handle_system_event(SystemEvent::ChatRequestAccepted { + username: "alice.42".to_string(), + }); + + let transcript = app.transcript_text(); + assert!(transcript.contains("Accepted chat request from alice.42")); + assert!(!transcript.contains("Accepting automatically")); + } + #[test] fn streaming_pairing_event_keeps_the_actionable_link() { let event = SystemEvent::PairingDeeplink { 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 bca4d6c68..0b0adb1c3 100644 --- a/rust/crates/truapi-host-cli/tests/signing_host_cli.rs +++ b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs @@ -41,6 +41,7 @@ fn exec_help_is_plain_and_exits_successfully() { 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("/balance")); 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")); diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 6214ebac8..cb830e423 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -69,6 +69,8 @@ pub struct SigningHostConfig { pub people_chain_genesis_hash: [u8; 32], /// Bulletin-chain genesis hash used for in-core preimage submission. pub bulletin_chain_genesis_hash: [u8; 32], + /// Asset Hub genesis hash used for smart-contract (PGAS) allowance claims. + pub asset_hub_chain_genesis_hash: [u8; 32], } /// Product identity attached to one product-facing TrUAPI connection. @@ -164,11 +166,13 @@ impl SigningHostConfig { platform_info: PlatformInfo, people_chain_genesis_hash: [u8; 32], bulletin_chain_genesis_hash: [u8; 32], + asset_hub_chain_genesis_hash: [u8; 32], ) -> Result { Ok(Self { host: HostRuntimeConfig::new(host_info, platform_info)?, people_chain_genesis_hash, bulletin_chain_genesis_hash, + asset_hub_chain_genesis_hash, }) } } diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 2e6849513..ea6cf5cc0 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -248,7 +248,8 @@ impl SigningHostRuntime { config.bulletin_chain_genesis_hash, spawner, ); - let signing_host = SigningHostRole::new(services.clone()); + let signing_host = + SigningHostRole::new(services.clone(), config.asset_hub_chain_genesis_hash); Self { services, signing_host, diff --git a/rust/crates/truapi-server/src/host_logic.rs b/rust/crates/truapi-server/src/host_logic.rs index e687fafa1..e3f07a1c5 100644 --- a/rust/crates/truapi-server/src/host_logic.rs +++ b/rust/crates/truapi-server/src/host_logic.rs @@ -6,6 +6,8 @@ pub mod attestation; pub mod bulletin; +pub mod chat; +pub mod coinage; pub mod dotns; pub mod entropy; pub mod extrinsic; diff --git a/rust/crates/truapi-server/src/host_logic/chat.rs b/rust/crates/truapi-server/src/host_logic/chat.rs new file mode 100644 index 000000000..40325fbc1 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/chat.rs @@ -0,0 +1,715 @@ +//! iOS-compatible wallet chat-request discovery and acceptance. +//! +//! A contact request is not part of the product-facing [`truapi::api::Chat`] +//! surface. The wallet publishes it as an encrypted statement on the +//! recipient identity's `chat-request` topic. Acceptance is a regular +//! MessageExchange `chatAccepted` message on the pair's identity session. + +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; +use p256::{PublicKey as P256PublicKey, SecretKey as P256SecretKey}; +use parity_scale_codec::{Decode, Encode, Error as CodecError, Input, Output}; +use schnorrkel::{PublicKey as Sr25519PublicKey, Signature as Sr25519Signature}; +use sha2::Sha256; +use thiserror::Error; + +use super::product_account::{ProductAccountError, derive_sr25519_hard_path}; +use super::sso::pairing::{ + AES_GCM_NONCE_LEN, PairingBootstrapError, ResponderIdentity, SsoStatementData, + derive_p256_keypair_from_entropy, encrypt_session_statement_data, + establish_responder_session_info, +}; +use super::statement_store::{StatementProof, build_signed_statement}; + +const CLI_CHAT_ENCRYPTION_KEY_LABEL: &[u8] = b"chat-encryption"; +const CHAT_REQUEST_CONTEXT: &[u8] = b"chat-request"; +const MULTI_DEVICE_IDENTITY_CONTEXT: &str = "mds-chat-request"; +const STATEMENT_SIGNING_CONTEXT: &[u8] = b"substrate"; +const CHAT_PRIORITY_EPOCH: u64 = 1_763_164_800; +const CHAT_PRIORITY_PREFIX: u64 = 0xffff_ffff_0000_0000; + +/// Persistent identity keys used by a CLI Lite user for wallet chat. +pub struct ChatIdentity { + statement_secret: [u8; 64], + /// `//wallet//sso` account targeted by incoming discovery statements. + pub statement_account_id: [u8; 32], + encryption_secret: [u8; 32], + /// P-256 identifier key stored in `Resources.Consumers`. + pub encryption_public_key: [u8; 65], +} + +/// Identity derivations supported by the CLI. +/// +/// CLI-managed Lite users use the SSO identity introduced by the headless +/// host. Existing iOS users use their main wallet identity and the +/// `//wallet//chat` encryption key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatIdentityDerivation { + /// iOS main identity: `//wallet` signer and `//wallet//chat` P-256 key. + Main, + /// CLI identity: `//wallet//sso` signer and the headless chat key. + Sso, +} + +/// A cryptographically valid request whose peer People identity must still be +/// resolved and, for V2, checked against the included identity proof. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IncomingChatRequest { + /// Sender-chosen request/message identifier. + pub request_id: String, + /// Millisecond Unix timestamp from the request. + pub timestamp_ms: u64, + /// People identity account to resolve for username and identifier key. + pub peer_account_id: [u8; 32], + /// sr25519 account that signed the request proof. + pub peer_statement_account_id: [u8; 32], + identity_proof: Option<[u8; 32]>, +} + +/// Chat-request codec or cryptographic validation failure. +#[derive(Debug, Error)] +pub enum ChatRequestError { + /// SCALE payload does not match the iOS chat-request shape. + #[error("invalid chat request payload: {0}")] + InvalidPayload(String), + /// A required key has the wrong representation. + #[error("invalid chat request key: {0}")] + InvalidKey(String), + /// The inner request proof is not sr25519 or does not verify. + #[error("invalid chat request signature")] + InvalidSignature, + /// The V2 device-to-identity binding does not verify. + #[error("invalid chat request identity proof")] + InvalidIdentityProof, + /// Local account derivation failed. + #[error("chat identity derivation failed: {0}")] + IdentityDerivation(#[from] ProductAccountError), + /// Local P-256 derivation failed. + #[error("chat encryption-key derivation failed: {0}")] + EncryptionDerivation(#[from] PairingBootstrapError), + /// Encryption or statement construction failed. + #[error("chat acceptance construction failed: {0}")] + Acceptance(String), +} + +/// Derive a supported People/chat identity from root entropy. +pub fn derive_chat_identity( + entropy: &[u8], + derivation: ChatIdentityDerivation, +) -> Result { + let (statement, encryption_secret, encryption_public_key) = match derivation { + ChatIdentityDerivation::Main => { + let statement = derive_sr25519_hard_path(entropy, &["wallet"])?; + let (secret, public) = derive_ios_main_chat_keypair(entropy)?; + (statement, secret, public) + } + ChatIdentityDerivation::Sso => { + let statement = derive_sr25519_hard_path(entropy, &["wallet", "sso"])?; + let (secret, public) = + derive_p256_keypair_from_entropy(entropy, CLI_CHAT_ENCRYPTION_KEY_LABEL)?; + (statement, secret, public) + } + }; + Ok(ChatIdentity { + statement_secret: statement.secret.to_bytes(), + statement_account_id: statement.public.to_bytes(), + encryption_secret, + encryption_public_key, + }) +} + +/// Derive identities in preference order for an active CLI signer. +pub fn derive_chat_identities(entropy: &[u8]) -> Result<[ChatIdentity; 2], ChatRequestError> { + Ok([ + derive_chat_identity(entropy, ChatIdentityDerivation::Sso)?, + derive_chat_identity(entropy, ChatIdentityDerivation::Main)?, + ]) +} + +/// Topic carrying every current contact request addressed to `account_id`. +/// +/// Mirrors iOS `ChatRequest.allPeerStatementsTopic`: BLAKE2b-256 over SCALE +/// `(Data("chat-request"), Data(account id))`. Both values are SCALE byte +/// vectors, so the fixed-size account still carries its compact length prefix. +pub fn chat_request_discovery_topic(account_id: [u8; 32]) -> [u8; 32] { + let mut encoded = CHAT_REQUEST_CONTEXT.encode(); + encoded.extend(account_id.to_vec().encode()); + sp_crypto_hashing::blake2_256(&encoded) +} + +/// Decode, decrypt, and verify the request's inner sr25519 proof. +/// +/// The outer statement proof is deliberately not trusted by iOS either; the +/// encrypted model carries its own proof over `(request, acceptor account)`. +pub fn decode_incoming_chat_request( + scale_encoded_statement_data: &[u8], + own: &ChatIdentity, +) -> Result { + // iOS passes a SCALE-encoded `Data` into `addScaleEncodedPayload`. The + // statement builder unwraps that outer `Data` while constructing the + // statement field, so `decode_statement_data` returns the envelope itself. + let encrypted: EncryptedRemoteModel = + decode_complete(scale_encoded_statement_data, "encrypted request envelope")?; + let ephemeral_public_key: [u8; 65] = + encrypted + .encryption_public_key + .try_into() + .map_err(|key: Vec| { + ChatRequestError::InvalidKey(format!( + "ephemeral P-256 key must be 65 bytes, got {}", + key.len() + )) + })?; + let decrypted = decrypt_p256( + own.encryption_secret, + ephemeral_public_key, + &encrypted.encrypted_data, + )?; + let remote: RemoteModel = decode_complete(&decrypted, "decrypted request")?; + + let StatementProof::Sr25519 { signature, signer } = remote.proof else { + return Err(ChatRequestError::InvalidSignature); + }; + let public = + Sr25519PublicKey::from_bytes(&signer).map_err(|_| ChatRequestError::InvalidSignature)?; + let signature = + Sr25519Signature::from_bytes(&signature).map_err(|_| ChatRequestError::InvalidSignature)?; + let proof_payload = ProofPayload { + message: remote.message.clone(), + request_acceptor_id: own.statement_account_id.to_vec(), + } + .encode(); + public + .verify_simple(STATEMENT_SIGNING_CONTEXT, &proof_payload, &signature) + .map_err(|_| ChatRequestError::InvalidSignature)?; + + let (peer_account_id, identity_proof) = match remote.message.content { + VersionedRequestContent::V1(_) => (signer, None), + VersionedRequestContent::V2(content) => ( + content.identity_proof.identity_account_id, + Some(content.identity_proof.proof), + ), + }; + Ok(IncomingChatRequest { + request_id: remote.message.message_id, + timestamp_ms: remote.message.timestamp, + peer_account_id, + peer_statement_account_id: signer, + identity_proof, + }) +} + +/// Validate the V2 device signer against the peer identity's People-chain +/// identifier key. V1 requests are already identity-signed and need no extra +/// binding proof. +pub fn validate_peer_identity( + request: &IncomingChatRequest, + own: &ChatIdentity, + peer_identifier_key: [u8; 65], +) -> Result<(), ChatRequestError> { + let Some(actual_proof) = request.identity_proof else { + return Ok(()); + }; + let shared_secret = p256_shared_secret(own.encryption_secret, peer_identifier_key)?; + let mut payload = Vec::with_capacity(64 + MULTI_DEVICE_IDENTITY_CONTEXT.len() + 1); + payload.extend(request.peer_account_id); + payload.extend(request.peer_statement_account_id); + payload.extend(MULTI_DEVICE_IDENTITY_CONTEXT.encode()); + let expected = keyed_blake2b256(&payload, &shared_secret); + if expected != actual_proof { + return Err(ChatRequestError::InvalidIdentityProof); + } + Ok(()) +} + +/// Build the signed statement that tells iOS its request was accepted. +pub fn build_chat_acceptance_statement( + own: &ChatIdentity, + request: &IncomingChatRequest, + peer_identifier_key: [u8; 65], + message_id: String, + transport_request_id: String, + timestamp_ms: u64, + priority: u64, +) -> Result, ChatRequestError> { + let identity = ResponderIdentity { + statement_secret: own.statement_secret, + statement_public_key: own.statement_account_id, + encryption_secret_key: own.encryption_secret, + encryption_public_key: own.encryption_public_key, + }; + let session = + establish_responder_session_info(&identity, request.peer_account_id, peer_identifier_key) + .map_err(ChatRequestError::Acceptance)?; + let message = RemoteMessage { + message_id, + timestamp: timestamp_ms, + content: VersionedRemoteMessage::V1(RemoteMessageV1 { + content: RemoteMessageContent::ChatAccepted(ChatAccepted { + message_id: request.request_id.clone(), + }), + }), + } + .encode(); + let encrypted = encrypt_session_statement_data( + &session, + &SsoStatementData::Request { + request_id: transport_request_id, + data: vec![message], + }, + ) + .map_err(ChatRequestError::Acceptance)?; + build_signed_statement( + &session, + session.request_channel, + session.session_id_own, + encrypted, + priority, + ) + .map_err(ChatRequestError::Acceptance) +} + +/// iOS timestamp-based statement priority. +pub fn chat_statement_priority(now_unix_secs: u64) -> u64 { + CHAT_PRIORITY_PREFIX | now_unix_secs.saturating_sub(CHAT_PRIORITY_EPOCH) +} + +fn decode_complete(input: &[u8], label: &str) -> Result { + let mut remaining = input; + let value = T::decode(&mut remaining) + .map_err(|error| ChatRequestError::InvalidPayload(format!("{label}: {error}")))?; + if !remaining.is_empty() { + return Err(ChatRequestError::InvalidPayload(format!( + "{label}: trailing bytes" + ))); + } + Ok(value) +} + +fn p256_shared_secret( + own_secret_key: [u8; 32], + peer_public_key: [u8; 65], +) -> Result<[u8; 32], ChatRequestError> { + let secret = P256SecretKey::from_slice(&own_secret_key) + .map_err(|error| ChatRequestError::InvalidKey(error.to_string()))?; + let peer = P256PublicKey::from_sec1_bytes(&peer_public_key) + .map_err(|error| ChatRequestError::InvalidKey(error.to_string()))?; + Ok((*diffie_hellman(secret.to_nonzero_scalar(), peer.as_affine()).raw_secret_bytes()).into()) +} + +fn derive_ios_main_chat_keypair(entropy: &[u8]) -> Result<([u8; 32], [u8; 65]), ChatRequestError> { + // Mirrors iOS ChatPrivateKeyFactory for `//wallet//chat`: derive the + // sr25519 keypair, hash the first 32 private-key bytes, then interpret the + // digest as a P-256 agreement key. + let chat = derive_sr25519_hard_path(entropy, &["wallet", "chat"])?; + let secret = sp_crypto_hashing::blake2_256(&chat.secret.to_bytes()[..32]); + let key = P256SecretKey::from_slice(&secret) + .map_err(|error| ChatRequestError::InvalidKey(error.to_string()))?; + let encoded = key.public_key().to_encoded_point(false); + let public: [u8; 65] = encoded.as_bytes().try_into().map_err(|_| { + ChatRequestError::InvalidKey(format!( + "derived P-256 public key must be 65 bytes, got {}", + encoded.as_bytes().len() + )) + })?; + Ok((secret, public)) +} + +fn decrypt_p256( + own_secret_key: [u8; 32], + peer_public_key: [u8; 65], + encrypted: &[u8], +) -> Result, ChatRequestError> { + if encrypted.len() < AES_GCM_NONCE_LEN + 16 { + return Err(ChatRequestError::InvalidPayload( + "encrypted request is too short".to_string(), + )); + } + let shared_secret = p256_shared_secret(own_secret_key, peer_public_key)?; + let key = aes_key(shared_secret)?; + let (nonce, ciphertext) = encrypted.split_at(AES_GCM_NONCE_LEN); + Aes256Gcm::new_from_slice(&key) + .map_err(|error| ChatRequestError::InvalidKey(error.to_string()))? + .decrypt(Nonce::from_slice(nonce), ciphertext) + .map_err(|_| ChatRequestError::InvalidPayload("request decryption failed".to_string())) +} + +fn aes_key(shared_secret: [u8; 32]) -> Result<[u8; 32], ChatRequestError> { + let hkdf = Hkdf::::new(None, &shared_secret); + let mut key = [0u8; 32]; + hkdf.expand(&[], &mut key) + .map_err(|error| ChatRequestError::InvalidKey(error.to_string()))?; + Ok(key) +} + +fn keyed_blake2b256(message: &[u8], key: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .key(key) + .hash(message) + .as_bytes() + .try_into() + .expect("32-byte BLAKE2b output") +} + +#[derive(Clone, Encode, Decode)] +struct RequestMessage { + message_id: String, + timestamp: u64, + content: VersionedRequestContent, +} + +#[derive(Clone, Encode, Decode)] +enum VersionedRequestContent { + #[codec(index = 0)] + V1(RequestContentV1), + #[codec(index = 1)] + V2(RequestContentV2), +} + +#[derive(Clone, Encode, Decode)] +struct RequestContentV1 { + push_token: Option, + welcome_message: Option, +} + +#[derive(Clone, Encode, Decode)] +struct RequestContentV2 { + identity_proof: IdentityProof, + device_encryption_public_key: [u8; 65], + push_token: Option, + welcome_message: Option, +} + +#[derive(Clone, Encode, Decode)] +struct IdentityProof { + identity_account_id: [u8; 32], + proof: [u8; 32], +} + +#[derive(Clone, Encode, Decode)] +struct RemoteTokenContent { + token: Vec, + push_type: u8, +} + +/// Current iOS request creation accepts text only and always encodes +/// `attachments` as `None`. Reject a future attachment-bearing request +/// explicitly instead of guessing its nested file layout. +#[derive(Clone)] +struct RequestRichText { + text: Option, +} + +impl Encode for RequestRichText { + fn size_hint(&self) -> usize { + self.text.size_hint() + 1 + } + + fn encode_to(&self, output: &mut T) { + self.text.encode_to(output); + Option::<()>::None.encode_to(output); + } +} + +impl Decode for RequestRichText { + fn decode(input: &mut I) -> Result { + let text = Option::::decode(input)?; + let attachment_option = u8::decode(input)?; + if attachment_option != 0 { + return Err("chat-request attachments are unsupported".into()); + } + Ok(Self { text }) + } +} + +#[derive(Encode, Decode)] +struct EncryptedRemoteModel { + encryption_public_key: Vec, + encrypted_data: Vec, +} + +#[derive(Encode, Decode)] +struct RemoteModel { + message: RequestMessage, + proof: StatementProof, +} + +#[derive(Encode)] +struct ProofPayload { + message: RequestMessage, + request_acceptor_id: Vec, +} + +#[derive(Encode, Decode)] +struct RemoteMessage { + message_id: String, + timestamp: u64, + content: VersionedRemoteMessage, +} + +#[derive(Encode, Decode)] +enum VersionedRemoteMessage { + #[codec(index = 0)] + V1(RemoteMessageV1), +} + +#[derive(Encode, Decode)] +struct RemoteMessageV1 { + content: RemoteMessageContent, +} + +#[derive(Encode, Decode)] +enum RemoteMessageContent { + #[codec(index = 14)] + ChatAccepted(ChatAccepted), +} + +#[derive(Encode, Decode)] +struct ChatAccepted { + message_id: String, +} + +#[cfg(test)] +mod tests { + use super::super::sso::pairing::{ + decrypt_session_statement_data, encrypt_session_statement_data_with_nonce, + }; + use super::super::statement_store::decode_verified_statement_data; + use super::*; + + const OWN_ENTROPY: [u8; 16] = [0x11; 16]; + const PEER_ENTROPY: [u8; 16] = [0x22; 16]; + + fn encrypt_request( + own: &ChatIdentity, + peer: &ChatIdentity, + peer_device: &schnorrkel::Keypair, + request_id: &str, + ) -> Vec { + let shared_secret = + p256_shared_secret(peer.encryption_secret, own.encryption_public_key).unwrap(); + let mut identity_payload = Vec::new(); + identity_payload.extend(peer.statement_account_id); + identity_payload.extend(peer_device.public.to_bytes()); + identity_payload.extend(MULTI_DEVICE_IDENTITY_CONTEXT.encode()); + let identity_proof = keyed_blake2b256(&identity_payload, &shared_secret); + let message = RequestMessage { + message_id: request_id.to_string(), + timestamp: 1_800_000_000_123, + content: VersionedRequestContent::V2(RequestContentV2 { + identity_proof: IdentityProof { + identity_account_id: peer.statement_account_id, + proof: identity_proof, + }, + device_encryption_public_key: peer.encryption_public_key, + push_token: Some(RemoteTokenContent { + token: vec![1, 2, 3], + push_type: 1, + }), + welcome_message: Some(RequestRichText { + text: Some("hello from iOS".to_string()), + }), + }), + }; + let proof_payload = ProofPayload { + message: message.clone(), + request_acceptor_id: own.statement_account_id.to_vec(), + } + .encode(); + let signature = peer_device + .secret + .sign_simple( + STATEMENT_SIGNING_CONTEXT, + &proof_payload, + &peer_device.public, + ) + .to_bytes(); + let remote = RemoteModel { + message, + proof: StatementProof::Sr25519 { + signature, + signer: peer_device.public.to_bytes(), + }, + } + .encode(); + let nonce = [0x33; AES_GCM_NONCE_LEN]; + let key = aes_key(shared_secret).unwrap(); + let mut encrypted = nonce.to_vec(); + encrypted.extend( + Aes256Gcm::new_from_slice(&key) + .unwrap() + .encrypt(Nonce::from_slice(&nonce), remote.as_slice()) + .unwrap(), + ); + EncryptedRemoteModel { + encryption_public_key: peer.encryption_public_key.to_vec(), + encrypted_data: encrypted, + } + .encode() + } + + #[test] + fn decodes_and_validates_ios_v2_chat_request() { + let own = derive_chat_identity(&OWN_ENTROPY, ChatIdentityDerivation::Sso).unwrap(); + let peer = derive_chat_identity(&PEER_ENTROPY, ChatIdentityDerivation::Sso).unwrap(); + let peer_device = derive_sr25519_hard_path(&PEER_ENTROPY, &["wallet", "device"]).unwrap(); + let encoded = encrypt_request(&own, &peer, &peer_device, "request-123"); + + let request = decode_incoming_chat_request(&encoded, &own).unwrap(); + validate_peer_identity(&request, &own, peer.encryption_public_key).unwrap(); + + assert_eq!(request.request_id, "request-123"); + assert_eq!(request.peer_account_id, peer.statement_account_id); + assert_eq!( + request.peer_statement_account_id, + peer_device.public.to_bytes() + ); + } + + #[test] + fn builds_identity_lane_chat_accepted_message() { + let own = derive_chat_identity(&OWN_ENTROPY, ChatIdentityDerivation::Sso).unwrap(); + let peer = derive_chat_identity(&PEER_ENTROPY, ChatIdentityDerivation::Sso).unwrap(); + let request = IncomingChatRequest { + request_id: "request-123".to_string(), + timestamp_ms: 1_800_000_000_123, + peer_account_id: peer.statement_account_id, + peer_statement_account_id: peer.statement_account_id, + identity_proof: None, + }; + let statement = build_chat_acceptance_statement( + &own, + &request, + peer.encryption_public_key, + "message-456".to_string(), + "transport-789".to_string(), + 1_800_000_001_000, + chat_statement_priority(1_800_000_001), + ) + .unwrap(); + let verified = + decode_verified_statement_data(&statement, Some(own.statement_account_id)).unwrap(); + let peer_identity = ResponderIdentity { + statement_secret: peer.statement_secret, + statement_public_key: peer.statement_account_id, + encryption_secret_key: peer.encryption_secret, + encryption_public_key: peer.encryption_public_key, + }; + let peer_session = establish_responder_session_info( + &peer_identity, + own.statement_account_id, + own.encryption_public_key, + ) + .unwrap(); + // iOS `StatementDataCoder` receives the statement field's SCALE + // `Data` bytes and decodes that prefix exactly once. Rust's + // `StatementField::Data(Vec)` supplies that prefix, so the + // extracted field value must already be the ciphertext rather than + // another SCALE-wrapped `Vec`. + let decoded = decrypt_session_statement_data(&peer_session, &verified.data).unwrap(); + let SsoStatementData::Request { request_id, data } = decoded else { + panic!("acceptance should be a MessageExchange request"); + }; + assert_eq!(request_id, "transport-789"); + let message: RemoteMessage = decode_complete(&data[0], "accepted message").unwrap(); + let VersionedRemoteMessage::V1(RemoteMessageV1 { + content: RemoteMessageContent::ChatAccepted(accepted), + }) = message.content; + assert_eq!(accepted.message_id, "request-123"); + assert_eq!(message.message_id, "message-456"); + } + + #[test] + fn rejects_v2_request_with_wrong_people_identifier_key() { + let own = derive_chat_identity(&OWN_ENTROPY, ChatIdentityDerivation::Sso).unwrap(); + let peer = derive_chat_identity(&PEER_ENTROPY, ChatIdentityDerivation::Sso).unwrap(); + let other = derive_chat_identity(&[0x44; 16], ChatIdentityDerivation::Sso).unwrap(); + let peer_device = derive_sr25519_hard_path(&PEER_ENTROPY, &["wallet", "device"]).unwrap(); + let encoded = encrypt_request(&own, &peer, &peer_device, "request-123"); + let request = decode_incoming_chat_request(&encoded, &own).unwrap(); + + assert!(matches!( + validate_peer_identity(&request, &own, other.encryption_public_key), + Err(ChatRequestError::InvalidIdentityProof) + )); + } + + #[test] + fn chat_priority_matches_ios_epoch_layout() { + assert_eq!( + chat_statement_priority(CHAT_PRIORITY_EPOCH + 42), + 0xffff_ffff_0000_002a + ); + } + + #[test] + fn chat_request_topic_is_account_scoped() { + assert_ne!( + chat_request_discovery_topic([1; 32]), + chat_request_discovery_topic([2; 32]) + ); + assert_eq!( + hex::encode(chat_request_discovery_topic([1; 32])), + "2eff37d3b7fde15ba550f0fc8bc131cc3b12aefafda3e7a614a2cdb3b6df88d0" + ); + } + + #[test] + fn request_proof_payload_scale_encodes_account_id_as_data() { + let payload = ProofPayload { + message: RequestMessage { + message_id: "request".to_string(), + timestamp: 42, + content: VersionedRequestContent::V1(RequestContentV1 { + push_token: None, + welcome_message: None, + }), + }, + request_acceptor_id: vec![0x55; 32], + } + .encode(); + let expected_suffix = [vec![0x80], vec![0x55; 32]].concat(); + + assert!(payload.ends_with(&expected_suffix)); + } + + #[test] + fn main_and_sso_chat_identities_use_distinct_ios_compatible_keys() { + let main = derive_chat_identity(&OWN_ENTROPY, ChatIdentityDerivation::Main).unwrap(); + let sso = derive_chat_identity(&OWN_ENTROPY, ChatIdentityDerivation::Sso).unwrap(); + + assert_ne!(main.statement_account_id, sso.statement_account_id); + assert_ne!(main.encryption_public_key, sso.encryption_public_key); + assert_eq!(main.encryption_public_key[0], 0x04); + } + + #[test] + fn sso_message_encryption_remains_nonce_compatible() { + let own = derive_chat_identity(&OWN_ENTROPY, ChatIdentityDerivation::Sso).unwrap(); + let peer = derive_chat_identity(&PEER_ENTROPY, ChatIdentityDerivation::Sso).unwrap(); + let own_identity = ResponderIdentity { + statement_secret: own.statement_secret, + statement_public_key: own.statement_account_id, + encryption_secret_key: own.encryption_secret, + encryption_public_key: own.encryption_public_key, + }; + let session = establish_responder_session_info( + &own_identity, + peer.statement_account_id, + peer.encryption_public_key, + ) + .unwrap(); + let data = SsoStatementData::Request { + request_id: "request".to_string(), + data: vec![vec![1, 2, 3]], + }; + let encrypted = + encrypt_session_statement_data_with_nonce(&session, &data, [7; AES_GCM_NONCE_LEN]) + .unwrap(); + assert_eq!(&encrypted[..AES_GCM_NONCE_LEN], &[7; AES_GCM_NONCE_LEN]); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/coinage.rs b/rust/crates/truapi-server/src/host_logic/coinage.rs new file mode 100644 index 000000000..a079674a7 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/coinage.rs @@ -0,0 +1,169 @@ +//! Coinage key derivation shared by wallet-local hosts. +//! +//! These paths mirror the Polkadot app's `CoinKeypairFactory` and +//! `VoucherKeypairFactory`. Coin keys use Substrate sr25519 hard derivation at +//! `//pps//coin//`. Voucher keys apply keyed BLAKE2b at each hard +//! junction in `//pps//ring-vrf//` before deriving the Bandersnatch +//! member key and recycler alias. + +use thiserror::Error; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use super::product_account::{ProductAccountError, create_chain_code, derive_sr25519_hard_path}; + +const RECYCLER_ALIAS_CONTEXT: &[u8] = b"pop:polkadot.network/coinrecyclr"; + +/// Public material needed to locate one voucher on chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VoucherKeys { + /// Bandersnatch member key stored by Coinage and Members. + pub member: [u8; 32], + /// Ring-VRF alias used by `Coinage.RecyclersUnloaded`. + pub recycler_alias: [u8; 32], +} + +/// Public material submitted when an external asset holder loads a voucher. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VoucherLoad { + /// Bandersnatch member key that becomes the voucher owner. + pub member: [u8; 32], + /// Plain Bandersnatch signature proving the external asset holder approved + /// this voucher member key. + pub proof_of_ownership: [u8; 64], +} + +/// Failure while deriving Coinage keys from wallet entropy. +#[derive(Debug, Error)] +pub enum CoinageKeyError { + /// The sr25519 derivation path or root entropy was invalid. + #[error(transparent)] + Sr25519(#[from] ProductAccountError), + /// Bandersnatch could not derive the recycler alias. + #[error("derive Coinage recycler alias: {0:?}")] + RecyclerAlias(verifiable::Error), + /// Bandersnatch could not sign the external asset holder's account id. + #[error("sign Coinage voucher ownership: {0:?}")] + ProofOfOwnership(verifiable::Error), +} + +/// Derive the sr25519 public key for `//pps//coin//`. +pub fn derive_coin_public_key( + root_entropy: &[u8], + index: u32, +) -> Result<[u8; 32], CoinageKeyError> { + let index = index.to_string(); + Ok( + derive_sr25519_hard_path(root_entropy, &["pps", "coin", &index])? + .public + .to_bytes(), + ) +} + +/// Derive the member key and recycler alias for +/// `//pps//ring-vrf//`. +pub fn derive_voucher_keys( + root_entropy: &[u8], + index: u32, +) -> Result { + let entropy = derive_voucher_entropy(root_entropy, index)?; + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let member = BandersnatchVrfVerifiable::member_from_secret(&secret); + let recycler_alias = + BandersnatchVrfVerifiable::alias_in_context(&secret, RECYCLER_ALIAS_CONTEXT) + .map_err(CoinageKeyError::RecyclerAlias)?; + + Ok(VoucherKeys { + member, + recycler_alias, + }) +} + +/// Derive one voucher member key and sign ownership by `external_asset_holder`. +/// +/// The signature layout matches the iOS Cash top-up flow: the voucher's +/// Bandersnatch secret signs the temporary sr25519 deposit account id. +pub fn derive_voucher_load( + root_entropy: &[u8], + index: u32, + external_asset_holder: &[u8; 32], +) -> Result { + let entropy = derive_voucher_entropy(root_entropy, index)?; + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let member = BandersnatchVrfVerifiable::member_from_secret(&secret); + let proof_of_ownership = BandersnatchVrfVerifiable::sign(&secret, external_asset_holder) + .map_err(CoinageKeyError::ProofOfOwnership)?; + Ok(VoucherLoad { + member, + proof_of_ownership, + }) +} + +fn derive_voucher_entropy( + root_entropy: &[u8], + index: u32, +) -> Result<[u8; 32], ProductAccountError> { + let index = index.to_string(); + let mut entropy = root_entropy.to_vec(); + for junction in ["pps", "ring-vrf", &index] { + let chain_code = create_chain_code(junction)?; + let mut params = blake2b_simd::Params::new(); + params.hash_length(32).key(&chain_code); + entropy = params.hash(&entropy).as_bytes().to_vec(); + } + entropy.try_into().map_err(|entropy: Vec| { + ProductAccountError::InvalidEntropy(format!( + "Coinage voucher entropy is {} bytes", + entropy.len() + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ENTROPY: [u8; 32] = [0xabu8; 32]; + + #[test] + fn coin_keys_are_stable_and_indexed() { + let first = derive_coin_public_key(&ENTROPY, 0).unwrap(); + let repeated = derive_coin_public_key(&ENTROPY, 0).unwrap(); + let second = derive_coin_public_key(&ENTROPY, 1).unwrap(); + + assert_eq!(first, repeated); + assert_ne!(first, second); + } + + #[test] + fn voucher_keys_are_stable_and_indexed() { + let first = derive_voucher_keys(&ENTROPY, 0).unwrap(); + let repeated = derive_voucher_keys(&ENTROPY, 0).unwrap(); + let second = derive_voucher_keys(&ENTROPY, 1).unwrap(); + + assert_eq!(first, repeated); + assert_ne!(first, second); + assert_ne!(first.member, first.recycler_alias); + } + + #[test] + fn voucher_load_proves_the_external_asset_holder() { + let owner = [0x42; 32]; + let load = derive_voucher_load(&ENTROPY, 7, &owner).unwrap(); + + assert!(BandersnatchVrfVerifiable::verify_signature( + &load.proof_of_ownership, + &owner, + &load.member, + )); + assert!(!BandersnatchVrfVerifiable::verify_signature( + &load.proof_of_ownership, + &[0x24; 32], + &load.member, + )); + assert_eq!( + load.member, + derive_voucher_keys(&ENTROPY, 7).unwrap().member + ); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/identity.rs b/rust/crates/truapi-server/src/host_logic/identity.rs index bcf0d834a..87c90236c 100644 --- a/rust/crates/truapi-server/src/host_logic/identity.rs +++ b/rust/crates/truapi-server/src/host_logic/identity.rs @@ -14,6 +14,8 @@ use sp_crypto_hashing::{blake2_128, twox_128}; /// Username fields read from a People-chain `Resources.Consumers` record. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PeopleIdentity { + /// SEC1-uncompressed P-256 public key used for identity-chat encryption. + pub identifier_key: [u8; 65], /// Lite username; `None` when the record stores an empty string. pub lite_username: Option, /// Full username, when the account has registered one. @@ -56,6 +58,7 @@ pub fn decode_people_identity(value: &[u8]) -> Result { .transpose()? .flatten(); Ok(PeopleIdentity { + identifier_key: decoded.identifier_key, lite_username, full_username, }) @@ -100,6 +103,7 @@ mod tests { let decoded = decode_people_identity(&value).expect("identity should decode"); + assert_eq!(decoded.identifier_key, [0x04; 65]); assert_eq!(decoded.full_username.as_deref(), Some("Alice Smith")); assert_eq!(decoded.lite_username.as_deref(), Some("alice.01")); } 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 2cea5a29b..c9e596d87 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -119,7 +119,7 @@ pub fn derive_sr25519_hard_path( } /// Create a Substrate soft-derivation chain code for one junction. -fn create_chain_code(code: &str) -> Result<[u8; 32], ProductAccountError> { +pub(super) fn create_chain_code(code: &str) -> Result<[u8; 32], ProductAccountError> { let encoded = if !code.is_empty() && code.bytes().all(|byte| byte.is_ascii_digit()) { code.parse::() .map_err(|_| ProductAccountError::NumericJunctionOutOfRange)? diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index eab2d7bb1..2c6df27f1 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -239,6 +239,8 @@ pub struct NativeRuntimeConfig { pub people_chain_genesis_hash: Vec, /// Bulletin-chain genesis hash. Must be exactly 32 bytes. pub bulletin_chain_genesis_hash: Vec, + /// Asset Hub genesis hash. Must be exactly 32 bytes. + pub asset_hub_chain_genesis_hash: Vec, /// Optional local signing-host secret material (raw BIP-39 entropy). pub local_session_secret: Option>, /// Optional lite username attached to the local signing-host session. @@ -276,6 +278,12 @@ pub enum NativeRuntimeConfigError { /// Supplied byte length. actual: u64, }, + /// Asset Hub genesis hash was not exactly 32 bytes. + #[error("asset_hub_chain_genesis_hash must be exactly 32 bytes, got {actual}")] + InvalidAssetHubChainGenesisHash { + /// Supplied byte length. + actual: u64, + }, /// Host icon URL could not be parsed. #[error("host_icon must be an absolute HTTPS URL: {reason}")] InvalidHostIcon { @@ -324,6 +332,12 @@ impl TryFrom for NativeResolvedRuntimeConfig { actual: config.bulletin_chain_genesis_hash.len() as u64, } })?; + let asset_hub_chain_genesis_hash = + <[u8; 32]>::try_from(config.asset_hub_chain_genesis_hash.as_slice()).map_err(|_| { + NativeRuntimeConfigError::InvalidAssetHubChainGenesisHash { + actual: config.asset_hub_chain_genesis_hash.len() as u64, + } + })?; let product = ProductContext::new(config.product_id).map_err(NativeRuntimeConfigError::from)?; let signing = SigningHostConfig::new( @@ -338,6 +352,7 @@ impl TryFrom for NativeResolvedRuntimeConfig { }, people_chain_genesis_hash, bulletin_chain_genesis_hash, + asset_hub_chain_genesis_hash, )?; Ok(Self { signing, @@ -1264,6 +1279,7 @@ mod tests { platform_version: None, people_chain_genesis_hash: vec![0xa2; 32], bulletin_chain_genesis_hash: vec![0xbb; 32], + asset_hub_chain_genesis_hash: vec![0xcc; 32], local_session_secret: None, local_session_lite_username: None, pairing_deeplink_scheme: NativePairingDeeplinkScheme::PolkadotApp, @@ -1399,6 +1415,20 @@ mod tests { )); } + #[test] + fn runtime_config_rejects_wrong_size_asset_hub_genesis_hash() { + let err = NativeResolvedRuntimeConfig::try_from(NativeRuntimeConfig { + asset_hub_chain_genesis_hash: vec![0; 31], + ..native_runtime_config("app.dot") + }) + .unwrap_err(); + + assert!(matches!( + err, + NativeRuntimeConfigError::InvalidAssetHubChainGenesisHash { actual: 31 } + )); + } + #[test] fn runtime_config_rejects_empty_required_fields() { let err = NativeResolvedRuntimeConfig::try_from(NativeRuntimeConfig { diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 0b5cf5fc6..1b5479ed2 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -15,6 +15,9 @@ mod authority; pub(crate) mod bulletin_rpc; mod identity; mod pairing_host; +/// Native Asset Hub PGAS allowance allocation. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) mod pgas_allowance; /// Role-neutral runtime services shared by product-facing runtimes. pub(crate) mod services; mod signing_host; diff --git a/rust/crates/truapi-server/src/runtime/pgas_allowance.rs b/rust/crates/truapi-server/src/runtime/pgas_allowance.rs new file mode 100644 index 000000000..adeb81924 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/pgas_allowance.rs @@ -0,0 +1,398 @@ +//! Asset Hub PGAS allowance claims. +//! +//! This mirrors the mobile wallet flow: derive a product account, select the +//! first free daily PGAS alias, prove Lite People membership with the +//! `AsPgas` transaction extension, and submit `Pgas.claim_pgas`. + +use std::time::{Duration, Instant}; + +use parity_scale_codec::{Decode, Encode}; +use scale_decode::DecodeAsType; +use sp_crypto_hashing::{blake2_128, twox_128}; +use thiserror::Error; +use verifiable::Error as VerifiableError; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use super::statement_allowance::StatementAllowanceError; +use super::statement_allowance::extension::{ + ChainState, Metadata, MetadataError, build_proof_message_after_extension, +}; +use super::statement_allowance::extrinsic::build_unsigned_extrinsic_with_extra; +use super::statement_allowance::proof::{domain_for_ring_exponent, ring_vrf_proof}; +use super::statement_allowance::ring::{self, RingParams}; +use super::statement_allowance::rpc::RpcClient; + +const AS_PGAS: &str = "AsPgas"; +const PGAS_CONTEXT_PREFIX: &[u8] = b"pop:gas:"; +const LITE_PEOPLE_IDENTIFIER: &[u8; 32] = b"pop:polkadot.network/people-lite"; +const MILLIS_PER_DAY: u64 = 86_400_000; +const OPTION_SOME: u8 = 1; +const RING_REVISION_WAIT: Duration = Duration::from_secs(60); + +/// Failure while selecting or claiming a PGAS allowance. +#[derive(Debug, Error)] +pub enum PgasAllowanceError { + /// Shared chain, metadata, ring, proof, or submission failure. + #[error(transparent)] + Allowance(#[from] StatementAllowanceError), + /// Asset Hub had no timestamp value. + #[error("Timestamp.Now is unavailable")] + TimestampUnavailable, + /// Asset Hub timestamp storage did not decode as milliseconds. + #[error("Timestamp.Now decode failed: {0}")] + TimestampDecode(#[source] parity_scale_codec::Error), + /// PGAS maximum-claims constant did not decode as a `u32`. + #[error("Pgas.MaxClaimsPerPeriodPerLitePerson decode failed: {0}")] + MaxClaimsDecode(#[source] parity_scale_codec::Error), + /// No daily PGAS alias remained unused. + #[error("no free PGAS slot on day {day} (max {max})")] + NoFreeSlot { + /// UTC day scanned. + day: u32, + /// Number of configured Lite People claims. + max: u32, + }, + /// Bandersnatch alias derivation failed. + #[error("PGAS alias derivation failed: {0:?}")] + Alias(VerifiableError), + /// Asset Hub ring commitment storage failed to decode. + #[error("MembersSubscriber.RingRoots decode failed: {0}")] + RingRootsDecode(#[source] scale_decode::Error), + /// Asset Hub has already pruned the People ring revision used by the proof. + #[error("Asset Hub pruned ring {ring_index} revision {revision}")] + RingRevisionPruned { + /// People ring index. + ring_index: u32, + /// Revision selected on People. + revision: u32, + }, + /// Asset Hub did not import the People ring revision in time. + #[error("timed out waiting for Asset Hub ring {ring_index} revision {revision}")] + RingRevisionTimeout { + /// People ring index. + ring_index: u32, + /// Revision selected on People. + revision: u32, + }, +} + +/// Successful on-chain PGAS claim. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PgasClaimOutcome { + /// Asset Hub block containing the claim. + pub block_hash: String, + /// UTC day used by the claim. + pub day: u32, + /// Free slot selected within that day. + pub slot_index: u32, + /// People ring index authorizing the claim. + pub ring_index: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +struct ClaimPgasCallArgs { + slot_index: u32, + target: [u8; 32], +} + +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +struct ClaimPgasInfo { + proof: Vec, + ring_index: u32, + revision: u32, + collection: u8, + day: u32, +} + +#[derive(Debug, DecodeAsType)] +struct RingCommitmentRecord { + revision: u32, +} + +/// Claim one PGAS allowance for `target`. +pub async fn claim_pgas( + asset_hub_rpc: &RpcClient, + asset_hub_metadata: &Metadata, + asset_hub_state: &ChainState, + people_rpc: &RpcClient, + people_metadata: &Metadata, + entropy: [u8; 32], + target: &[u8; 32], + ring: &RingParams, +) -> Result { + let day = current_day(asset_hub_rpc).await?; + let max = max_lite_people_claims(asset_hub_metadata)?; + let slot_index = first_free_slot(asset_hub_rpc, entropy, day, max).await?; + let context = pgas_context(day, slot_index); + let call = build_claim_pgas_call(asset_hub_metadata, slot_index, target)?; + let revision = ring::read_ring_revision( + people_rpc, + people_metadata, + ring.ring_index, + &ring.block_hash, + ) + .await?; + await_ring_revision(asset_hub_rpc, asset_hub_metadata, ring.ring_index, revision).await?; + let message = + build_proof_message_after_extension(asset_hub_metadata, &call, asset_hub_state, AS_PGAS)?; + let domain = domain_for_ring_exponent(ring.exponent)?; + let proof = ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; + let extra = build_as_pgas_extra(asset_hub_metadata, &proof, ring.ring_index, revision, day)?; + let extrinsic = build_unsigned_extrinsic_with_extra( + asset_hub_metadata, + asset_hub_state, + &call, + AS_PGAS, + &extra, + )?; + let block_hash = asset_hub_rpc.submit_and_watch(&extrinsic).await?; + Ok(PgasClaimOutcome { + block_hash, + day, + slot_index, + ring_index: ring.ring_index, + }) +} + +async fn await_ring_revision( + rpc: &RpcClient, + metadata: &Metadata, + ring_index: u32, + revision: u32, +) -> Result<(), PgasAllowanceError> { + let value_type = metadata + .storage_value_type("MembersSubscriber", "RingRoots") + .ok_or_else(|| { + PgasAllowanceError::Allowance( + MetadataError::MissingStorageType { + pallet: "MembersSubscriber", + entry: "RingRoots", + } + .into(), + ) + })?; + let started = Instant::now(); + loop { + if let Some(bytes) = rpc.get_storage(&ring_roots_key(ring_index)).await? { + let mut input = bytes.as_slice(); + let records = Vec::::decode_as_type( + &mut input, + value_type, + metadata.registry(), + ) + .map_err(PgasAllowanceError::RingRootsDecode)?; + if records.iter().any(|record| record.revision == revision) { + return Ok(()); + } + if records + .iter() + .map(|record| record.revision) + .min() + .is_some_and(|oldest| oldest > revision) + { + return Err(PgasAllowanceError::RingRevisionPruned { + ring_index, + revision, + }); + } + } + if started.elapsed() >= RING_REVISION_WAIT { + return Err(PgasAllowanceError::RingRevisionTimeout { + ring_index, + revision, + }); + } + futures_timer::Delay::new(Duration::from_secs(1)).await; + } +} + +async fn current_day(rpc: &RpcClient) -> Result { + let bytes = rpc + .get_storage(×tamp_now_key()) + .await? + .ok_or(PgasAllowanceError::TimestampUnavailable)?; + let millis = u64::decode(&mut &bytes[..]).map_err(PgasAllowanceError::TimestampDecode)?; + Ok((millis / MILLIS_PER_DAY) as u32) +} + +fn max_lite_people_claims(metadata: &Metadata) -> Result { + let bytes = metadata + .constant("Pgas", "MaxClaimsPerPeriodPerLitePerson") + .ok_or_else(|| { + PgasAllowanceError::Allowance( + MetadataError::MissingConstant { + pallet: "Pgas", + constant: "MaxClaimsPerPeriodPerLitePerson", + } + .into(), + ) + })?; + u32::decode(&mut &bytes[..]).map_err(PgasAllowanceError::MaxClaimsDecode) +} + +async fn first_free_slot( + rpc: &RpcClient, + entropy: [u8; 32], + day: u32, + max: u32, +) -> Result { + for slot_index in 0..max { + let alias = pgas_alias(entropy, day, slot_index)?; + if rpc + .get_storage(&claimed_gas_alias_key(day, &alias)) + .await? + .is_none() + { + return Ok(slot_index); + } + } + Err(PgasAllowanceError::NoFreeSlot { day, max }) +} + +fn pgas_context(day: u32, slot_index: u32) -> [u8; 32] { + let mut context = [0u8; 32]; + context[..PGAS_CONTEXT_PREFIX.len()].copy_from_slice(PGAS_CONTEXT_PREFIX); + let day_start = PGAS_CONTEXT_PREFIX.len(); + context[day_start..day_start + 4].copy_from_slice(&day.to_le_bytes()); + context[day_start + 4..day_start + 8].copy_from_slice(&slot_index.to_le_bytes()); + context +} + +fn pgas_alias( + entropy: [u8; 32], + day: u32, + slot_index: u32, +) -> Result<[u8; 32], PgasAllowanceError> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + BandersnatchVrfVerifiable::alias_in_context(&secret, &pgas_context(day, slot_index)) + .map_err(PgasAllowanceError::Alias) +} + +fn build_claim_pgas_call( + metadata: &Metadata, + slot_index: u32, + target: &[u8; 32], +) -> Result, PgasAllowanceError> { + let indices = metadata.call_indices("Pgas", "claim_pgas")?; + let mut call = Vec::with_capacity(2 + 4 + 32); + call.extend_from_slice(&indices); + ClaimPgasCallArgs { + slot_index, + target: *target, + } + .encode_to(&mut call); + Ok(call) +} + +fn build_as_pgas_extra( + metadata: &Metadata, + proof: &[u8], + ring_index: u32, + revision: u32, + day: u32, +) -> Result, PgasAllowanceError> { + let (claim, lite_people) = + metadata.extension_info_and_field_variant_indices(AS_PGAS, "Claim", "LitePeople")?; + let mut extra = Vec::with_capacity(2 + proof.len() + 16); + extra.push(OPTION_SOME); + extra.push(claim); + ClaimPgasInfo { + proof: proof.to_vec(), + ring_index, + revision, + collection: lite_people, + day, + } + .encode_to(&mut extra); + Ok(extra) +} + +fn timestamp_now_key() -> Vec { + [ + twox_128(b"Timestamp").as_slice(), + twox_128(b"Now").as_slice(), + ] + .concat() +} + +fn claimed_gas_alias_key(day: u32, alias: &[u8; 32]) -> Vec { + [ + twox_128(b"Pgas").as_slice(), + twox_128(b"ClaimedGasAliases").as_slice(), + &day.to_be_bytes(), + &blake2_128_concat(alias), + ] + .concat() +} + +fn ring_roots_key(ring_index: u32) -> Vec { + [ + twox_128(b"MembersSubscriber").as_slice(), + twox_128(b"RingRoots").as_slice(), + &blake2_128_concat(LITE_PEOPLE_IDENTIFIER), + &blake2_128_concat(&ring_index.to_le_bytes()), + ] + .concat() +} + +fn blake2_128_concat(value: &[u8]) -> Vec { + [blake2_128(value).as_slice(), value].concat() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn context_matches_mobile_wallet_layout() { + let context = pgas_context(0x0102_0304, 0x0506_0708); + assert_eq!(&context[..8], b"pop:gas:"); + assert_eq!(&context[8..12], &[0x04, 0x03, 0x02, 0x01]); + assert_eq!(&context[12..16], &[0x08, 0x07, 0x06, 0x05]); + assert_eq!(&context[16..], &[0; 16]); + } + + #[test] + fn claimed_alias_storage_key_uses_big_endian_day_and_blake_concat() { + let alias = [0x42; 32]; + let key = claimed_gas_alias_key(0x0102_0304, &alias); + assert_eq!( + &key[32..36], + &[0x01, 0x02, 0x03, 0x04], + "Identity day key must match the mobile wallet's big-endian bytes", + ); + assert_eq!(&key[52..], &alias); + } + + #[test] + fn subscriber_ring_key_blake_concats_both_map_keys() { + let key = ring_roots_key(136); + assert_eq!(key.len(), 32 + 16 + 32 + 16 + 4); + assert_eq!(&key[48..80], LITE_PEOPLE_IDENTIFIER); + assert_eq!(&key[96..], &136u32.to_le_bytes()); + } + + #[test] + fn claim_payload_matches_mobile_wallet_field_order() { + let encoded = ClaimPgasInfo { + proof: vec![0xaa, 0xbb], + ring_index: 7, + revision: 9, + collection: 1, + day: 11, + } + .encode(); + let decoded = ClaimPgasInfo::decode(&mut &encoded[..]).unwrap(); + assert_eq!( + decoded, + ClaimPgasInfo { + proof: vec![0xaa, 0xbb], + ring_index: 7, + revision: 9, + collection: 1, + day: 11, + } + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 749691453..dc765cf87 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -67,13 +67,18 @@ pub(crate) struct SigningHost { session_state: Arc, auth_state: AuthStateMachine, ring_resolver: Arc, + /// Asset Hub receiving smart-contract (PGAS) allowance claims. + asset_hub_chain_genesis_hash: [u8; 32], /// Root BIP-39 entropy held only while a session is active. root_entropy: Mutex>>>, } impl SigningHost { /// Build a signing host with no active session. - pub(crate) fn new(services: Arc) -> Arc { + pub(crate) fn new( + services: Arc, + asset_hub_chain_genesis_hash: [u8; 32], + ) -> Arc { let platform = services.platform.clone(); let ring_resolver = ChainRingResolver::new(services.chain.clone()); Arc::new(Self { @@ -82,6 +87,7 @@ impl SigningHost { session_state: SessionState::new(), auth_state: AuthStateMachine::new(platform), ring_resolver, + asset_hub_chain_genesis_hash, root_entropy: Mutex::new(None), }) } @@ -103,6 +109,7 @@ impl SigningHost { session_state: SessionState::new(), auth_state: AuthStateMachine::new(platform), ring_resolver, + asset_hub_chain_genesis_hash: [0; 32], root_entropy: Mutex::new(None), }) } @@ -451,8 +458,17 @@ impl ProductAuthority for SigningHost { .await .map(|_| v01::AllocationOutcome::Allocated) } - v01::AllocatableResource::SmartContractAllowance(_) - | v01::AllocatableResource::AutoSigning => Ok(v01::AllocationOutcome::NotAvailable), + v01::AllocatableResource::SmartContractAllowance(index) => { + sso_responder::allocate_smart_contract_allowance( + &self.services, + self, + &product_id, + index, + ) + .await + .map(|_| v01::AllocationOutcome::Allocated) + } + v01::AllocatableResource::AutoSigning => Ok(v01::AllocationOutcome::NotAvailable), }; match outcome { Ok(outcome) => outcomes.push(outcome), @@ -726,6 +742,7 @@ mod tests { PlatformInfo::default(), [0; 32], [0xbb; 32], + [0xcc; 32], ) .expect("signing host config is valid"); let services = RuntimeServices::new( @@ -734,7 +751,8 @@ mod tests { config.bulletin_chain_genesis_hash, test_spawner(), ); - let signing_host = SigningHostRole::new(services.clone()); + let signing_host = + SigningHostRole::new(services.clone(), config.asset_hub_chain_genesis_hash); (services, signing_host) } @@ -1467,7 +1485,7 @@ mod tests { } #[test] - fn direct_allocation_handles_empty_and_optional_resource_batches() { + fn direct_allocation_handles_empty_and_auto_signing_batches() { let (_services, authority) = signing_runtime(); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation"); @@ -1488,19 +1506,44 @@ mod tests { &session, "myapp.dot".to_string(), v01::HostRequestResourceAllocationRequest { - resources: vec![ - v01::AllocatableResource::SmartContractAllowance(0), - v01::AllocatableResource::AutoSigning, - ], + resources: vec![v01::AllocatableResource::AutoSigning], }, )) .expect("optional allocation succeeds"); assert_eq!( optional.outcomes, - vec![ - v01::AllocationOutcome::NotAvailable, - v01::AllocationOutcome::NotAvailable, - ] + vec![v01::AllocationOutcome::NotAvailable] + ); + } + + #[test] + fn direct_smart_contract_allocation_uses_asset_hub() { + let platform: Arc = Arc::new(StubPlatform { + chain_connect_error: Some("asset hub unavailable"), + ..StubPlatform::default() + }); + let services = RuntimeServices::new( + platform, + [0; 32], + [0xbb; 32], + crate::test_support::test_spawner(), + ); + let authority = SigningHostRole::new(services, [0xcc; 32]); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + let response = futures::executor::block_on(authority.allocate_resources( + &CallContext::default(), + &session, + "myapp.dot".to_string(), + v01::HostRequestResourceAllocationRequest { + resources: vec![v01::AllocatableResource::SmartContractAllowance(0)], + }, + )) + .expect("item failure is represented as an allocation outcome"); + assert_eq!( + response.outcomes, + vec![v01::AllocationOutcome::NotAvailable], ); } } 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 46c2131f2..85bddb380 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 @@ -50,11 +50,15 @@ use crate::host_logic::statement_store::{ build_signed_statement, parse_new_statements_result, validate_unsigned_statement_signing_payload, }; +#[cfg(not(target_arch = "wasm32"))] +use crate::host_rpc_client::HostRpcClient; use crate::runtime::authority::{ AccountAliasAuthorityRequest, AuthorityError, CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, ProductAuthority, SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; +#[cfg(not(target_arch = "wasm32"))] +use crate::runtime::pgas_allowance::PgasAllowanceError; use crate::runtime::services::RuntimeServices; use crate::runtime::sso_remote::fresh_statement_expiry; #[cfg(not(target_arch = "wasm32"))] @@ -133,6 +137,10 @@ pub(super) enum AllowanceAllocationError { #[cfg(not(target_arch = "wasm32"))] #[error("{0}")] StatementAllowance(#[from] StatementAllowanceError), + /// Asset Hub PGAS slot selection or claim failed. + #[cfg(not(target_arch = "wasm32"))] + #[error("{0}")] + PgasAllowance(#[from] PgasAllowanceError), /// Runtime service could not open the required Statement Store RPC client. #[cfg(not(target_arch = "wasm32"))] #[error("{0}")] @@ -147,6 +155,13 @@ pub(super) enum AllowanceAllocationError { #[source] source: RuntimeFailure, }, + /// Runtime service could not open the configured Asset Hub RPC client. + #[cfg(not(target_arch = "wasm32"))] + #[error("Asset Hub allowance client: {reason}")] + AssetHubRpcClient { + /// Platform connection failure. + reason: String, + }, /// Allocation helper is unavailable for this target. #[cfg(target_arch = "wasm32")] #[error("signing host: {resource} allowance allocation is native-only")] @@ -784,8 +799,19 @@ async fn resource_allocation_response( slot_account_key, }) }), - SsoAllocatableResource::SmartContractAllowance(_) - | SsoAllocatableResource::AutoSigning => Ok(SsoAllocationOutcome::NotAvailable), + SsoAllocatableResource::SmartContractAllowance(index) => { + allocate_smart_contract_allowance( + services, + signing_host, + &request.calling_product_id, + index, + ) + .await + .map(|_| { + SsoAllocationOutcome::Allocated(SsoAllocatedResource::SmartContractAllowance) + }) + } + SsoAllocatableResource::AutoSigning => Ok(SsoAllocationOutcome::NotAvailable), }; match outcome { Ok(outcome) => outcomes.push(outcome), @@ -978,6 +1004,73 @@ pub(super) async fn allocate_bulletin_allowance( Ok(allowance.secret.to_bytes().to_vec()) } +#[cfg(not(target_arch = "wasm32"))] +pub(super) async fn allocate_smart_contract_allowance( + services: &Arc, + signing_host: &SigningHost, + product_id: &str, + derivation_index: u32, +) -> Result<(), AllowanceAllocationError> { + use crate::runtime::pgas_allowance; + use crate::runtime::statement_allowance::{ + self, fetch_chain_state, fetch_metadata, find_including_ring, + }; + + let entropy = signing_host.root_entropy()?; + let target = signing_host + .product_keypair(&api::ProductAccountId { + dot_ns_identifier: product_id.to_string(), + derivation_index, + })? + .public + .to_bytes(); + let asset_hub_connection = services + .platform + .connect(signing_host.asset_hub_chain_genesis_hash) + .await + .map_err(|error| AllowanceAllocationError::AssetHubRpcClient { + reason: error.reason, + })?; + let asset_hub_rpc = statement_allowance::rpc::RpcClient::new(subxt_rpcs::RpcClient::new( + HostRpcClient::new(Arc::from(asset_hub_connection), services.spawner.clone()), + )); + let asset_hub_metadata = fetch_metadata(&asset_hub_rpc).await?; + let asset_hub_state = fetch_chain_state(&asset_hub_rpc).await?; + + let people_rpc = statement_allowance::rpc::RpcClient::new( + services.statement_store.client("PGAS allowance").await?, + ); + let people_metadata = fetch_metadata(&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, &people_metadata, bandersnatch, current) + .await? + .ok_or(AllowanceAllocationError::MissingLitePeopleMembership { + resource: "smart-contract", + })?; + let outcome = pgas_allowance::claim_pgas( + &asset_hub_rpc, + &asset_hub_metadata, + &asset_hub_state, + &people_rpc, + &people_metadata, + bandersnatch, + &target, + &ring, + ) + .await?; + debug!( + %product_id, + derivation_index, + block_hash = %outcome.block_hash, + day = outcome.day, + slot_index = outcome.slot_index, + ring_index = outcome.ring_index, + "claimed smart-contract PGAS allowance" + ); + Ok(()) +} + #[cfg(target_arch = "wasm32")] pub(super) async fn allocate_statement_store_allowance( _services: &Arc, @@ -1002,6 +1095,18 @@ pub(super) async fn allocate_bulletin_allowance( }) } +#[cfg(target_arch = "wasm32")] +pub(super) async fn allocate_smart_contract_allowance( + _services: &Arc, + _signing_host: &SigningHost, + _product_id: &str, + _derivation_index: u32, +) -> Result<(), AllowanceAllocationError> { + Err(AllowanceAllocationError::NativeOnly { + resource: "smart-contract", + }) +} + #[cfg(not(target_arch = "wasm32"))] fn current_unix_secs() -> Result { std::time::SystemTime::now() @@ -1240,6 +1345,7 @@ mod tests { PlatformInfo::default(), [0; 32], [0xbb; 32], + [0xcc; 32], ) .expect("signing host config is valid"); let services = RuntimeServices::new( @@ -1248,7 +1354,7 @@ mod tests { config.bulletin_chain_genesis_hash, test_spawner(), ); - let signing_host = SigningHost::new(services.clone()); + let signing_host = SigningHost::new(services.clone(), config.asset_hub_chain_genesis_hash); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); (services, signing_host) diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index 24667e236..5082baf5b 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -107,11 +107,11 @@ impl<'a> SsoPairingFlow<'a> { read_last_processed_pairing_statement(self.host.platform.as_ref()) .await .map_err(|reason| self.fail_before_pairing(reason))?; - // Pairing success statements are retained by statement-store. Reusing a - // previous pairing identity means reusing its topic, where the only - // retained response may be the last processed success. Rotate before - // presenting QR so every explicit login waits on a fresh wallet scan. - if reused_identity { + // Pairing success statements are retained by statement-store. Reuse a + // successfully processed identity so reconnects can reuse the existing + // device allowance, but rotate abandoned identities that have no + // processed statement marker. + if reused_identity && last_processed_statement.is_none() { debug!("regenerating stored pairing device identity"); pairing_identity = create_fresh_pairing_device_identity(self.host.platform.as_ref()) .await @@ -656,7 +656,7 @@ mod tests { } #[test] - fn request_login_regenerates_marked_stored_pairing_device_identity() { + fn request_login_reuses_marked_stored_pairing_device_identity() { let platform = stub_platform(); let identity = generate_pairing_device_identity().unwrap(); platform @@ -698,7 +698,7 @@ mod tests { _ => None, }) .expect("pairing state should be emitted"); - assert_ne!( + assert_eq!( pairing_device_from_deeplink(&deeplink), ( identity.statement_store_public_key, @@ -713,7 +713,7 @@ mod tests { .contains_key(&core_storage_test_key( CoreStorageKey::PairingDeviceIdentity )), - "cancelled pairing keeps the rotated identity; the next login rotates again" + "cancelled pairing keeps the marked identity for reconnect reuse" ); } 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 9deccdc46..ba88b1c7e 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs @@ -66,6 +66,36 @@ pub enum MetadataError { /// `AsResources` extension is absent from metadata. #[error("{AS_RESOURCES} extension not found in metadata")] MissingAsResourcesExtension, + /// A requested transaction extension is absent from metadata. + #[error("{identifier} extension not found in metadata")] + MissingExtension { + /// Runtime metadata identifier. + identifier: String, + }, + /// A transaction extension's extra did not contain the expected `Option`. + #[error("{identifier} extension extra is not an Option")] + ExtensionExtraNotOption { + /// Runtime metadata identifier. + identifier: String, + }, + /// A named transaction-extension authorization variant was absent. + #[error("{identifier} extension variant {variant} not found in metadata")] + MissingExtensionVariant { + /// Runtime metadata identifier. + identifier: String, + /// Authorization variant name. + variant: String, + }, + /// No field on an authorization variant carried the requested enum variant. + #[error("{identifier} extension variant {info_variant} has no field variant {field_variant}")] + MissingExtensionFieldVariant { + /// Runtime metadata identifier. + identifier: String, + /// Authorization variant name. + info_variant: String, + /// Nested field variant name. + field_variant: String, + }, /// `AsResources` extra type did not contain the expected `Option`. #[error("{AS_RESOURCES} extra is not an Option")] AsResourcesExtraNotOption, @@ -442,6 +472,150 @@ impl Metadata { .collect() } + /// Encode every transaction extension, replacing one extension's `extra` + /// bytes with caller-supplied SCALE. + /// + /// This is used by signed flows such as Coinage, where the normal + /// metadata-shaped default is `None` but one transaction must carry a + /// runtime authorization value. + pub fn encode_signed_extensions_with_extra( + &self, + state: &ChainState, + identifier: &str, + extra: Vec, + ) -> Result, MetadataError> { + let mut encoded = self.encode_signed_extensions(state); + self.replace_signed_extension_extra(&mut encoded, identifier, extra)?; + Ok(encoded) + } + + /// Replace one already-encoded extension's explicit bytes. + pub fn replace_signed_extension_extra( + &self, + encoded: &mut [EncodedExtension], + identifier: &str, + extra: Vec, + ) -> Result<(), MetadataError> { + let index = + self.extension_index(identifier) + .ok_or_else(|| MetadataError::MissingExtension { + identifier: identifier.to_string(), + })?; + encoded[index].extra = extra; + Ok(()) + } + + /// Position of a transaction extension in the runtime's implication + /// pipeline. + pub fn extension_index(&self, identifier: &str) -> Option { + self.extensions + .iter() + .position(|extension| extension.identifier == identifier) + } + + /// Resolve the enum index of a named authorization carried by an + /// extension shaped as `Wrapper(Option)`. + pub fn extension_info_variant_index( + &self, + identifier: &str, + variant: &str, + ) -> Result { + let extension = self + .extensions + .iter() + .find(|extension| extension.identifier == identifier) + .ok_or_else(|| MetadataError::MissingExtension { + identifier: identifier.to_string(), + })?; + let option_type = match &self.resolve_type(extension.extra_type)?.type_def { + TypeDef::Composite(_) => self.single_field_type(extension.extra_type)?, + _ => extension.extra_type, + }; + let info_type = self + .resolve_variant(option_type)? + .variants + .iter() + .find(|candidate| candidate.name == "Some") + .and_then(|some| match some.fields.as_slice() { + [field] => Some(field.ty.id), + _ => None, + }) + .ok_or_else(|| MetadataError::ExtensionExtraNotOption { + identifier: identifier.to_string(), + })?; + self.resolve_variant(info_type)? + .variants + .iter() + .find(|candidate| candidate.name == variant) + .map(|candidate| candidate.index) + .ok_or_else(|| { + MetadataError::MissingExtensionVariant { + identifier: identifier.to_string(), + variant: variant.to_string(), + } + .into() + }) + } + + /// Resolve both an extension authorization variant and a nested enum field + /// variant by name from `Wrapper(Option)`. + pub fn extension_info_and_field_variant_indices( + &self, + identifier: &str, + info_variant: &str, + field_variant: &str, + ) -> Result<(u8, u8), StatementAllowanceError> { + let extension = self + .extensions + .iter() + .find(|extension| extension.identifier == identifier) + .ok_or_else(|| MetadataError::MissingExtension { + identifier: identifier.to_string(), + })?; + let option_type = match &self.resolve_type(extension.extra_type)?.type_def { + TypeDef::Composite(_) => self.single_field_type(extension.extra_type)?, + _ => extension.extra_type, + }; + let info_type = self + .resolve_variant(option_type)? + .variants + .iter() + .find(|candidate| candidate.name == "Some") + .and_then(|some| match some.fields.as_slice() { + [field] => Some(field.ty.id), + _ => None, + }) + .ok_or_else(|| MetadataError::ExtensionExtraNotOption { + identifier: identifier.to_string(), + })?; + let info = self + .resolve_variant(info_type)? + .variants + .iter() + .find(|candidate| candidate.name == info_variant) + .ok_or_else(|| MetadataError::MissingExtensionVariant { + identifier: identifier.to_string(), + variant: info_variant.to_string(), + })?; + let nested = info + .fields + .iter() + .filter_map(|field| self.resolve_variant(field.ty.id).ok()) + .find_map(|variants| { + variants + .variants + .iter() + .find(|candidate| candidate.name == field_variant) + .map(|candidate| candidate.index) + }) + .ok_or_else(|| MetadataError::MissingExtensionFieldVariant { + identifier: identifier.to_string(), + info_variant: info_variant.to_string(), + field_variant: field_variant.to_string(), + })?; + Ok((info.index, nested)) + } + /// The signed-extension identifiers, in metadata order. #[cfg(test)] pub fn extension_ids(&self) -> Vec<&str> { @@ -547,12 +721,25 @@ pub fn build_proof_message( metadata: &Metadata, call_data: &[u8], state: &ChainState, +) -> Result<[u8; 32], StatementAllowanceError> { + build_proof_message_after_extension(metadata, call_data, state, AS_RESOURCES) +} + +/// Build the inherited-implication proof message using transaction extensions +/// strictly after `identifier`. +pub fn build_proof_message_after_extension( + metadata: &Metadata, + call_data: &[u8], + state: &ChainState, + identifier: &str, ) -> Result<[u8; 32], StatementAllowanceError> { let all = metadata.encode_signed_extensions(state); let tail_start = metadata - .as_resources_index() + .extension_index(identifier) .map(|i| i + 1) - .ok_or(MetadataError::MissingAsResourcesExtension)?; + .ok_or_else(|| MetadataError::MissingExtension { + identifier: identifier.to_string(), + })?; let tail = &all[tail_start..]; let mut payload = Vec::with_capacity(1 + call_data.len()); 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 c6e03dd39..6fee20061 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs @@ -140,16 +140,36 @@ pub fn build_unsigned_extrinsic( state: &ChainState, call_data: &[u8], as_resources_extra: &[u8], +) -> Result, StatementAllowanceError> { + build_unsigned_extrinsic_with_extra( + metadata, + state, + call_data, + super::extension::AS_RESOURCES, + as_resources_extra, + ) +} + +/// Assemble an unsigned General (v5) extrinsic with one transaction +/// extension's explicit bytes replaced by `extension_extra`. +pub fn build_unsigned_extrinsic_with_extra( + metadata: &Metadata, + state: &ChainState, + call_data: &[u8], + extension_identifier: &str, + extension_extra: &[u8], ) -> Result, StatementAllowanceError> { let all = metadata.encode_signed_extensions(state); - let as_resources_index = metadata - .as_resources_index() - .ok_or(MetadataError::MissingAsResourcesExtension)?; + let extension_index = metadata + .extension_index(extension_identifier) + .ok_or_else(|| MetadataError::MissingExtension { + identifier: extension_identifier.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); + if i == extension_index { + body.extend_from_slice(extension_extra); } else { body.extend_from_slice(&ext.extra); } diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index 4f01189d7..062df68d3 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -460,7 +460,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(services.clone()); + let signing_host = SigningHostRole::new(services.clone(), [0xcc; 32]); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); let host = ProductRuntimeHost::from_services( diff --git a/rust/crates/truapi/src/api/resource_allocation.rs b/rust/crates/truapi/src/api/resource_allocation.rs index 3de1003b2..081576da4 100644 --- a/rust/crates/truapi/src/api/resource_allocation.rs +++ b/rust/crates/truapi/src/api/resource_allocation.rs @@ -23,17 +23,17 @@ 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. + /// // Statement Store, Bulletin, and smart-contract PGAS are implemented + /// // by signing hosts. /// assert( - /// result.value.outcomes.slice(0, 2).every((outcome) => outcome === "Allocated"), - /// "statement-store or bulletin allowance was not allocated:", + /// result.value.outcomes.slice(0, 3).every((outcome) => outcome === "Allocated"), + /// "an allowance was not allocated:", /// result.value, /// ); - /// // Smart-contract allowance and auto-signing are host capabilities: - /// // unsupported hosts report NotAvailable rather than rejecting the request. + /// // Auto-signing remains an optional host capability. /// assert( - /// result.value.outcomes.slice(2).every((outcome) => outcome !== "Rejected"), - /// "an optional allocation was rejected:", + /// result.value.outcomes[3] !== "Rejected", + /// "auto-signing allocation was rejected:", /// result.value, /// ); /// console.log("resource allocation outcomes:", result.value.outcomes); diff --git a/rust/crates/truapi/src/api/statement_store.rs b/rust/crates/truapi/src/api/statement_store.rs index 38b16fb7f..8ccd47cee 100644 --- a/rust/crates/truapi/src/api/statement_store.rs +++ b/rust/crates/truapi/src/api/statement_store.rs @@ -68,8 +68,9 @@ pub trait StatementStore: Send + Sync { /// /// **Deprecated:** use [`create_proof_authorized`](Self::create_proof_authorized) /// instead, which uses a pre-allocated allowance account and does not - /// require a per-call signing prompt. Pairing hosts may reject this method - /// when their signing channel cannot sign statement proof payloads exactly. + /// require a per-call signing prompt. Pairing hosts forward this method to + /// the signing host as a `StatementStoreProductSign` review so the exact + /// unsigned statement is approved and signed without a `` envelope. /// /// ```ts /// // Expiry packs a Unix-seconds timestamp in the high 32 bits; a day out @@ -85,11 +86,12 @@ pub trait StatementStore: Send + Sync { /// }, /// statement, /// }); - /// if (result.isErr()) { - /// console.log("deprecated createProof unavailable:", result.error); - /// } else { - /// console.log("proof created:", result.value); - /// } + /// assert( + /// result.isOk(), + /// "StatementStoreProductSign confirmation failed:", + /// result, + /// ); + /// console.log("proof created:", result.value); /// ``` #[wire(request_id = 60)] async fn create_proof(

#&t+Qh>dI$UiUGJ;CJ;(J# z69qz?E*o4e;je$Q2QqyB!J)B0eLGuA?Oqa78SQ~<&&O*$LX52bhs;lak8-{h?y@1qm3#@7(kH|B+f?QV(U zVRpFhp@YdIw$r2R^yqExbUf_z@zPGmyLLJ;xkhJmkWtcoWt+oi7dp%7v7QYML;hE) zn>!+=J&&zs`B-YNCFuyR4#e5+Z9T4SjUP;&JUCQ2@LqVS9}XlMSb10^wVN>OKt6BpWr`5wmfKx)i&6;&jt#j6-6r26=zF|!Av ztY?}XZk1Uqh-@AA1_eQA5vYyDvt5EEeX|TeI7;wOX;f4lV1m-9tlhrQ#mJhthWT66 zT~4DJ9S~e->6Av~jU@#ftx&(+^w^u*dmubd9UPjI>ir59U-wj+yK{5!aZLELd0O&; zP6jcMMv(Q&1W4KagvDXe?57W;mFbh@>Dxy2$$;voOQ=3+q+|Fm45>WpnkI**m(H|^ z7ELF(o5KO;s$^)0REM%CK0^2Dm8H%^8sANlPwOO}KbTy-?fv&Ep9{P1>RhmbZ*BwT z>vWBUSk(>(eC1FmP!@K9O9k)ywIh2^XYRELt`*1_c0cc1uKh+Id}zY&Fp%V0B><0dNJa2M8h@wVX| z;OcDfw#^q5Ve4S}^)f}6Z7-68JytKx)`tX);Gt1aWjcJV-G+w}rso$nFXNP4#6&`% z?3o=m+TJTFXR>wMfV&+K_cu$3yKRKv+Xse|-xNad@wawAjX?zR5-}v?EBvXV0=u zFcQlxxx+s7$yv<0FK_2C2Pd8Kuj)CZX2{xhePIVkMlMmId=rJ4=d@4UD4PF+Yoc4E6YrYTD zG?gagDxxQEMBX4tk02-xM&0V8xFG9*j04&6pxm5VSwMx8cC#L@iz2ivFq$O%R-*C3 z;hE<@TzfsjF}CyKaq~V#%*;kEv^Q~w<@J0YLppdq_lnmEC&-3vI zo)+YY_L|MPV`F5S`N?DLpNhI@e);#iy0;!J2%m|9iAh&7xi6G7*a#%qm3&mHJWI?u zvw#SA)nC$3q!(!RKek=MPh=wL|MIxCe{{{P8)A2>U&^1kzZr8~By8Iy64!#EM) zn##$Dj9$x*Ok^R5#u^!^NY+?0GA6b=cQp4(nt1+s?u@0Ou zo^#G~p8wBty0}{?O(3tC#z>>4EuK(e+)KrC-AI?fvmytV)Qx05d2Fk3mMyMs;sM3Z z#+QT}z6edr=hjkM6fF!h1fEE)c3}J}678R4+ZtoMsqry&gsX#4GMx|13$`iFJNYGG zNuoX7g!FD;!DCqbgc*>6<%R}#maGO6ZELMgu@26*tFfTXcJueoYiuv0lgKQT=~KDL z(R^9g-1`Wbu~@j|2$GB(SFbu0&QDyfVyN-knq2BEt%t{nt9ycR@d;upnFHr}R})gK;HP;}#lbGlbD+A$^99EWk%=r*yV!+MrU&Yd3hyIsj;!sLL{ zM#nt)m<78n3 zfga(it&D1ssef+iu%)iX(lRQ<^&ZYz@iUD-ROj*Pq_lX7#5j**gfKQamVGAmahQ}2 zYc+QQ0XNbJ=eZop(`FASes&#&6VN?u*BD7M9<*XV3C>dvL^0gB(H0@$-o~Cn0EroF z9de`RVj5{y*I;#c17f(&ezH0&brzTAI>F{JZad2CFz0U@I%x%H%SK%-K?57$Y6W6# z{8!}PAaQe)BOrp-&&K5Fj&{nhgnny0?&%ac}v5K>ZJ1=Wh4(>cEcIY zaS#r>H0K4vVO&3~fg>USDm^sy9+m?+yDhllyWu!KvVc@o;rpF|KCT>J07($HpI-N7 zJQq+Hki_lwNC)mUarBR3E}U)g9AdeNxezgt!z|`TnK`rn>l`c`9XPloKGfc3Pkf)d z$mrUWV9eA8$bn(X zTuqZ}R&n#@YN_@eQP3D)jx$L_|2NsQiyO2ANEsJIV`VJJx!N^>B*}n?{%^7A+j-Tt zML4~{rO9$YP)+)=lBI#6PJ0ef8(korHwfSHc7juhb)Ue`BVdBPODr=8Be! zC<$+h@2Ce+Ex(i_P{GhqdZ}mpQ_BX-%;Nb4ZGLfM;|ul9qSzI|k&%1>w2O0utcEYE zk#LL^KK&aR5%QG91Hs!k2P?=%T1N%-DdM?0HnBH7>=XVsE6k^zOt@| zv#S`i#oFZr%m|u41?%nG>LGnOsu~t271)L7XVH?HGzZrL z4Xe34tpM%u1<08VgFf+X$35Rr+dt`l+7u=4#C^;#n(G`2=wRYS#99K*0VbJsH!Dsc zN@;{(Eom({L)r)6nL{&|C)8Zzr)Ahc%eZ8Y2wqx|f+!zD@Z1pIg?Hcq8zE5gKu1ul zil1tH#C~ZW+WWF*__=;u73?McPU<-4Ts~zJ(|s`rqCl_sv>y#4tC@1L{7OuB)K0+L zO7K~=JNNMXMyu9?Xk~iqL^wTAloUUOV(^~Oti@oW(xj6w$LLy$;j2#YX`F8Cws^s| zGiB-`Zw}mO+CCSqhawE{<-j)BPO;ZuoC&%7+8C1uoeDo?#04+Z+$xjgT|3d?0M_t; zi-i<1F&Th3g#tpv5uHsyZi;5(Yuf%=XkwRU_v#7-Jn-DQj_zs^4D{evcaje7%tfU5 z7K_1%^ctcYcf+K#B)!krMOPJcS5j9_)d;J1RMjTBC;=Z5Y%ZGBlZ9)m;DTldSCEGs z+&($mwd)Lz;ME@rh zHX2=sNkX%)5PU3T#E;L_L!jQg-cCBdcy?-eb*>Yn!`&wr&mKWZm|dya<}EXl5NQmq z9&WsAMiH3xXI+dsgA3PxACd~x6YEsG)y&9)jpFTBfuMN`ygGt`mnZ-aJ{1KhZ;_vE zr-kJ1D-Ta}X0WEk@W59PY{Yeg9R?QvKWw zlmpXiok%kV78Kdy^g|_FEQTR86d$-swG8PoY z?_O=-F-Wrp))HNbJZ;|n2d~ooF{j02@Ti!Yw=Mfj*ZCxOmOHJwJD%XvGWR+uk8~ini^9=q+yu+4=CZ_BehtcpN`o z^EiI=DvNRe9|e(-6LRptxh}sT1Lf{Czjcdp*u6$mOPys>WLd2GstnPJ;dXsM(#Pfh z{OI=L6Wa&&cbBH7aUWbDvvhW5SiPY7QA}hP2UUS9*i%4=ZL0^0;!{_dPA%4X9Lx3u z7cMzMxu!h>a%B^Y+tkBr;$fIxOd)?vDNIS)@Yir%u85lwmSmO*>nh0GSO^#7+g#Df z!A`Q_g=O091z|Ym5i_LJdyp}L)WS~14N4T*GCvVEkTazODH>>uGK7?tBzXy|gM3UaRY!eBZp|I*N?&k~;0X5^1emFT#+BmhIO^^>S{h*@^VTjZ2 zUEg?JhM_gaD;PhPAoI=fZYD-WM^F^@ z?_EdzucF)^+NMk&SWl;mre}t61OiK5_r?zh38&DlYx{cSu}vY&QgxOqYr$Gy+};zd zRqm2Q0Yo7wC&uz5Aw)L)a!A($OX1pO47fv}OfiI*N0Y z3MG4Hdl?^CNLI~e-XH=occ##?FJ1$T^|&?A{kX!wiFz)Irv5bUSr&ul8>7cBUqg>W zeij;sLfBY14&Cyr+l$?Cx4*tUrDp}r1vEJ<%i?35u)*XeeU-P?^-`2A*s**m>9r{Z z2X$7ub@bYQb~?Y%^UJtpYj(SM>rxLrFCcYk3mCrM;RvL2_A#)7vfK6c^^tBZD4%Rb zsc-%;&RP4Y`0BHkG&jk2`g%`HDjM+m%*S^l@ezLcjb}aBw}M#votjwtt!vo%1CG4Y za~{{$h%s^;pK{1I30mMkl8PsR^jq7D?_NXCL$19_O&LViYTvSz(`X9qD|jl(ZKh8L z6_=E*@0-2mzVbe3_>QY{iG8jM%lfg3HD(*9&}k*-VE2*5d9THx#q8U`WXS(3wqHZH zHQD${+*%MkH%u3ePliKLH|k4OKw zsYg%D!4~KA5;kR}arIQe2g%8&>@1mhLq!6Wrp17&wCc@+4ix1tfypBKB_Sk_a??Z5 zWDvb*S-?v7#l7GTEWIkbumsBK5O+wR+3525E;9O4NI9`Id^8M3hi>%U=aDRqCU9p2 z_fHC*(5L&A06|Ntvf+{?H(61F^H(CXMJBiCBW&d^FQiULUI?{V=!W<+v#v-!Q(A=r z4X<}oZvHaX6hY?niX!vG`m0>Oq_7yi2eL`dMfNt=ZT`A$vo$?c6R@a-w93_R&C9So zM5P4@q_zXdYs+#(;4F4axJOFuaCbLqpNTB`pgyJ{l7~zk!Wl%!VMII0-A`$q{nM~Q z7L^FY4n&hXg3g(uA$NK$B5A-iz2y#<-dkwN3TlbFLH0*el-a&xx|a9YS;o}tYaT7+ zENieIRU`hT&+9opuT8(34h~tL*KQ(mC;)~Wh9rKIp>q7nv`~dE3jL>*W+RO$x*M3! z2nDUZ?6zE8Is9j*#~aqL#&9eI0a^KHUw-HV{{MM4*zB_S zEpN5C^&Sopmo_1B3(BOB$QRyMq9(djxS}oCjgtA7j@yZ>|3u>>8IS8>JR1%|l$xud z;|l$27teEQnodtN^J;;5uuRcXM#7;H;l!kX(!c|U+@KhlV(}iIC-ahC_!b;_hH(KyeJ(VhSY{ zKIgB1OOTosBk(%K+-@B3`I`->Pk*pM>jNH(Hjqg;vJ!ewXnxvaIOIszQs&JFwhzGe z_->BHVK<=EK7N76x%k(jH6}uC>_i+jSNQjl*()p8p>1+aU>RTK7`)UqB2^-iL1AMY z&n-FjJ;n0mUgT9|WkWB`>Bmsvq`M~u7Ss-WhlF5C1F4??i{yy{3(K>+u_Am6YhMc{ zcT_U>HCCZt1*h9D>Yc^XN7aS!_36&+JU9Au%^QZphB#ru_uk@@sQu)HRyg|FywJzh zO&2$q7XK(1#yEwTgLt=%w01p;x5nMQ9d5Zd6>~_G`({)zJdZdyw>T;D2`kc zd_4OX7FC6|x3Lw&sixq6Q?fTrd1iSmKv<5{8bePbW)O6{&#gPkOTGd%4gXMWEvRX6 z^Nv48>%;SR)bkN<*|9`Co~EhDad5DW#_z7Z!`%zmEAbe&3GbWZzQg##ZXLZTvonTQM;y6( zK9}EVrL%WOIW9n?Zgvg@J9lR*^tCu@YcXagu-ucTf zd3hqAvnf?*u(;NE;EEIK=jSm`P-ng4Wi!B$A8bi3vq@yxb(Z#zUL+VJ#Mf=Uom-3I z&K{C<`_>I4>2_X%B;9rSl@Bx_poE6*UH@%1wNgB#()aTfX4g=WvI_wyO63xOG)H1+L zJn02lij+?hyOt^oVI+}K5vB3Xa#prAE;zO(4YB^d4;xH06EaF{Z;X3mdSBvF75sfW z8gux?gPIfsV-sG@zJ%nz5(Psa6syhKw4_o z8b#JkZ?NSIYHadfvi4AB`9`lvkNC06=@CG+63BU&&|u>awdmx)MFsXOScM_LrehbR zTDbh>-l#61$~$-{o?+$HH~fmQax=(wbE8hQ6cp2s`5^m+eko(f*^GhR|TNe?{7As%zB=4P=&Rwq2jqCSxP zuAYZt813a3iA^xKm)6CNSTRG-L$pI$H*-oOx9vo9tH;BsF#$!=cUb+2nDIeyx+;iy zq_18``;ZjLx=yvUzN4_>o-%QhrxcoK)YBKJ9hVs(*#gTXo?h@A7T=$WI3c({UU7@y zr~^*iI@vM)V-tstn?kCUT_#<*E8)f%iy%_ zS{1W-Y^|~H#PoEZY|IK62fm=MVa)kT3o>iM9s;cu_@ltPCYQr>=OwfyqXSvIdnM;z zgLnSSj)7x6imj;zuG#9+MX1H>5|+t&q^>;PQXryKJ%oji;F?$MD2FEOucADbrQT3z zb?xCD2<({TGKf4ldqucpw(?-P zI*tr-HN=&|{zfc{hiko&-Rfa+yZtNNxb@baAi=oy^H%_C_i2G89dccX*c{l1lXAC9 z>zFdu2XAb|%Hq*yEx@hraOrZ5gAp&JTD=0$KVZyZ(0Q*EY)BxF3nJ_0S*)?iTUK`z zuiX@^je_MI=F6PT`Gkm|OxGOPN!8K0f8M{#PJX-donq;4iV9H z0crdQ^?KS^MiFd3xxQ2N5jnC`Qc35|5E&4Wv})P$IJNL{p@>-A-9C&V)VuAvp!ne1|6m*U+a! z%qwzOoHXO=K{5+`J&2OkN3PH(okvMDFE;Y^OKk(5$OR^NHps&;tDg~=z28GZ3piSc?)$2i$7ZC&TlD8GN zr5o+PZ)yS50?-=)(q{xArB z7J0cTp>0pqGMQqg6AvY;KVgNq5a@3&!;gFOWb!SkT$dqFt6ZAtLZ8;s;?gRfUKvSc zt|W*YZ>ef3x2lISJ&uD}Jt$ndfmj)J)-LT$pCuyTvr`%c;r*(xNWvCzsmdeM8dpp8 z-XYN`Yxb9zCUXB5Mr&A5>pyK!%V>56*l!+>3Cx1ZNPp@#?Z)#O-@8)ZQFCjZ9DQJF z{OIY4Q}-S_b!u#C^4RI46UR=S7;9B?b=W-zAaBFaw$J6C*8zUT%~-n9W?agB#?4^J zlZza$#f90K;^u8Pmid+1u4MJj|6?*7M(}Jp3EQJ?b9kDJSI!C< z{s~WfV;L7pPG}5o5XlFMPA~7NE@TjDP}+6N#ZPYgwg`jWY_PN$=Ge{}mGKLEI4!kd zyYCsk{Q79`sUd&xAbZx@a}Ao!uk09jphvT5ug=e}O|@s|gIJ?DdjeJR|GjDK#WS z@Gc}6?F~ByT0Pt5Urv!=7#*%B_;cJ=(**of(1brIxYaJ-xP#5yr?lRw z$Cl4c!KZ3Uv>>5Ti^R!>qProIwh->QDa;(`56B=s0Z&EpEYfOuY z5q`WLg_)XYy`iQ@!{=!u-GBO?I8ywyv!IB!z#Kt$NtnW^z=GCQYU%SbWwrH){lMw4 zab311z;%ASSUO}K)dh%M)JhS=u3~+FV56#zjq0l)xpHPFs4;MThyc1$e>%MMRh>8j zN?3VLG=x*8AFz7n=Sq7fgEpL zPr9LXi>tcGC{##dGAg;SEP|q|HbPpkN)w5GeCGP{Aq5z(!iVi#9=BX${Y;{WDoRa( z6jtjNPvPO<+&mMS2zl5qR7>ylRvN8`EW91dR4rJcDBgY*$a~(#nWx2u=!EEgve|84 z8s$#gv!Wa^SFGP~un!~Fi}zbv`#jMVIqZXUbu<3FYe%Da*N%bxPLBgo->F+vy88%f zJjw~3UO20!WO!V<6X*+AW%f-SXJZRbK81I`rcrY9(WkU~i zJC8=YWlW{DRVdzTQ!d`WL;s`J@4I$TNZj{5#8E=P1%BCK$pP1yy%_$;z@JKa?Hrx?=4y%9}8- zEsfVia#DZ4#=9=dQ@1wW5%uz`1i-EHZ#MYQBqEI{evn6V`w@iM2%JTjDo@B?$HjW7 z4y-<%wi0=fFOt$pvM}@-jlR8x73-6dGOYB!#8#&y;pG&WYDmP-DRf+3Th>sSIoR8; zZ(OK7Mvy(N$ZE6JepS{NRCZek0LRq$2$aP*cRi+8fNLYY6RtkgS1~nsIgeeXYJIga zJTQH`v+mWj*P%{L{@Bn2(X76$js^sars;VNvmMTrYC|U`X33VA#c)2Iu<1BAA&0cz zV&hYIs?9R)TyPKi0;IcByaOD-76jh=K}~Q9%Vv6p*j3!H*m*VX_@LNh_WEcje0oEy zSR)&)Y>QC0#W)gR;~bXUaNK+#SvZ->i@Vs z1Q~=${?>M8>H+2Cc=0;rZK>^r&IQbk^#7p zKeDdNCHo;RfMy>BH~*!2qNkrapQ`Ql&c=t<_3KomH-@3U+i$yUw_%ZwdgUXTldjZt zTygWetMYkBK9baX8*3t&vcf+dxIR%Ax@c9@8k3JjNMcU%+DOa!H&)ZFyO)tqo zZ7IEmC3JydPri1uT1bq3;skLolhk{bgu#8+;TpUX^6MWppFep0Ja>mjOKG}nF`1UG z&`IkW)ZkB;G;)0FVDYJE&HX*A zwzed}70-xue`ZJV`5gn0j9Sw6jNa-JiMzmxEN|qu1N*HXu<0JT4&b&=P6oaT!DPv; z1Mr=cm~}}u(FJZDs099R>&vvV{vp9dTG$Ff6uu6p&%Sn*XN!C1kvoW5 z_7T0#`DLuI2yp(|HSEh5cMOd8EKDdm8Dhb6=`S-)WZ|iKB3z+SKCMbu7S#S_;vn+} z76E@K(4dj@KFY)3Jks=6ca+W3?Al5GE~q&o>xRs^$G4XI)DFqU@JdutIj$>f2nwZ; znriwHGe9)KN4Mmg7+<`fz~?VtaUzG-!BxAqBSBU^O>lekLpO@2%k&GVv{Un)>#>C( zL4I||l`BZqX1HybtU-20SG&<)^B(C6pswQlzW%I_M5AGGemB6Z!*4mVp3H3`MX3x zSh!1g_Rov&Y&@Q0VF2l!vld-Xjis$Rq~G26@^E-rCWEcx_SW|`zk$Z!yLJ?1|CNw- z*QvR7njo3Gn@?~gMwj6&kAi+*zYj53Z0~=zk(Zn7$PN8h#*Sg(xri3ozbe#bm4l)( z#|iezfwAGxEc&`t3*(%iVQn}8H$H2aH;ER#NlUP;K?vzpfVV?BuG={!0do^gE?kIu zXu=A)CFcpM**(rb0e5!W54wRRuDLS)eWJww8x=4L$7fod_?i8%vAg}=9-xRTRSObP zWd8H7GLyp^DB_z1WRW;-H0qZA(tix4NsU$&w_HmipfV?A$mi51nVV+Mm8(+UQm#Ht*pu&{9s z-?>;y2^OeisU#)#6kXmL%&$7Fvf(c94{Y(-6m1 zPosCSJRw^E^-;HuKC9Bf7N&~-2q*Z4%E5ECK^z!}^)iNop0RFdykit@X>I9ilk9>= zOrek#Hqz?-vR0bmIgPJ*oaUxAgV%R%CrARN7YyFCtnH6IyRx>9(f{Gf+75cE<&UbY z?Tyc>tnINZ3?}*h$K63PBcF<2-I1Jsro>g)%i~YIy5Ms+eA%o0rMr-G2VDXm-h`$X zli6d(QB!1tO`y(cp&a@NgyVY_my_IhQ{%=mKA=hDKFlADHgEdi$>~e+C7t&uRa;eR zFA^M`AoZWoKA$jkM|2$p*(C9(jt9FrR*n3tx&qc5|l*(kDwi4tJSA$)Ckb zuRKOA+TeBLCr!j4av_|xGgvw3H)fNkpOKtUw>@;&Lh%v!9>Tf3MzQ7P`0c{nB3GuW zr0aN8t}#n35|Z!c+eUHXNWFoRsS+1`1zO|m!u`s^4o%k;WA&zqd2pz>zIY%t9`Bxj ze@qe&#btA-Hgp_%Fc+p(l`6IutrG;Stfk)=9b1OjPO)Q~*SMMCJ5Tbi2}5&yrGP6g zXTpfYCo)9H1`7TYy@jdU|j4oZxL-=3=0jwBJV;+V*m)?|nnD9{rzF>|-gF zvLbTREq<(+9BsETNp{0#Va3$46obWOoeDP=BNo(koGUjd=lJ6V#%ka2H1Kmncp?{; z!kYcZNPt4fVj}Ok$habZF$%{Is#cwj`LsoV*;p)#A|a-*amk6}S6qE4LT^+YP$)eM z+aJWhNT$dfF==;|>cx`i2?>ygJtoO;XUyI}=GYdjg?{%#lcaXL>dfy^yk~iF9=u{1 znvAh01slfoh>APp{J0Gjr^bsfp#QL1@fu^}e(OScMW|NxBq4u-az`u0^NKO^ z6lnY8Kb%#b+K{rESdbi36DA-_wB3LLCWGn2E$Ekr!x56@vXGyOm3uj)k8uDEgNvJs z3u0-V4ohjyQsWma>eBSA>Wh5nmAbgZFBS={S{tebwL2r`fwLz7G-~m~~D%?rf0G z<52{012gO5YBkTbN*k0JRNh42ETwk!2%s@S#=_<>dA4!4h#uHICo-}8w3h6@@ zpeH0PMW=|lH$-DpO=>KB9uBR#ctPU^L$S;CF&$FZQa=V|((~e_oP-|PO+&uh%}|L4 zS(iK^h}~wrr<3fn2b&&jfji`{9XxnXD7M&bJ$wzu(9!+@V#I1zV(0N))pC$Lu^g$zp5bE`se^xEb_UX->~}~w%hOGo zSbn36ZEA_*XP4r3)5s~TzfzGQSToXeM2N0#?IS|yrDd(nukJI=3$_x%)}chnLzCmz zEh@Qf^!pYqb2A3fg>luN0@@w!>tIs)Fr!4!rGIIc^-sAd?o#obfdH`CTvE|72IHH zT~cCeItRo>Jg$du@tFvdgij^!U+<94qzT_7)qJE@2%v9mF1^Ym|lvzNeH&<+O(t zw%W8YNWgu$Od|tODQzB~R7_$D~ zafPf>C3=7&S=JmIwZ`?;*SFWe1Rw4nc%p|1D#{5;5521|L^!%5rxwP6)kqFx4`aSB zFv{9~lVyOwd3jkJ1~XyE?d~Ld-z6LjW$nQIa^fnP-F`tw8_^#;k1rzJH;Pt>%BO{E zc20A5fhT8n(a#>pzggl${N=i0&`JnjUlh~*15aMVY%Cmm(!wSlx*JJaV^=O*@ihsDSr`r$iNbp4HT>TX=JVcSNeao|IPjH?0;|n z@AiMR|5N>6?*B%AvDQEEc+b|F<~jwChFnt$?%ADGrw0@Yq*$MhEA-(lgJ+ykir4m+ zPXtcRA}R&*;K5G&jyvzX{VuNBiP7eGnqS*ruY&cd>o=5~`4o5udi+ZNz(WB&EK*?& zzg>t~&tM&L{7?N$n8XbYJGYAUtn-k5>$Hg|OrL+^`ch$_=_A4yQy}BI_|T&GRUAt_ z%j5|N8HDDjL{5xCCdf95H}sbe#d1dB7dihAO}>oDzoBRHZ|vVN`8Tp}8PUqcz2pD{IjxRit5--X4muTH6-r8TjEpPs2FJ@eLYY#5GeIqWs-EraV z*9Mt(<8lKUf(#rO?W*+hnl_1@S&tOMqoiqibt=^MUX>}v04WilW-5evwyRS z5qZIm8EXNyoF^OB>RtWilVPnkzB5LE&!LF1Iyvv^S*>?(T&;KeYQ6g^@WAvGC|-w0 zV>Nc#*+69@IGzN#89eXa#6?yxsvZ)c>LGcCR!u1{kR!tT`^%*;Ih6=WKl{8iq4)Pp z=mQ%k^Z}pH2d+H&*oN+Hd4WF6BJWnYYt}ZAB!zWkF4cJ-nY6;QA&RAkWFBKT{MU4< ziAC?fe0o-1p#z>Fb^-(v8wvaW!T$0!VTpQw*BTiFtkMU2R_VhVSLwsPN*}%^K#px3 zXj|O)opyRkXvQGKRj>xKt^ln_I3Ms|){`Hdm+($mjIVpg`ZdDpLnxtS_h|&O;}Yqi z8kwdVMYLsFN5E_JEy4nzSM86;pO3D08w8_F-W?d2I|RbMe7w%U{Fh9$KHjq*pV+t` zpYZ+o#8ro7k#px7x@?S4*PR;zEuN*ZDFfkZtMwhr0E&E_=L4~l5tX(uq0jV}twfZ= zPkBb4>6y_p8)x*4&*+(}4vbZ>*1JtMX|)o_%~s5ghBgd!{VkS2Lzr zCw9ED@vP>~!TM6bcHJ}ISXZ@{BEmwISRIob0Yd!0 z&y9sS_pDFx#cKfN&^k2#V+7~R{p*s{aqW3xVkU1R64OF!AaGypFE1nlmwwD7?yEf{ z?&}*#+}EANef=7SKU5oDBN&+*yyQ6qo#mbkfSNY2?i8+LfyL`L{T_E=Hm+SwHMy%S zgWp?q!}E90v)9Dl&Ca4c0qNE`Tkr?&dP^~GciXLt?jO907PTQLxVS9vaPV*R5AF*$ zVyzK7KR=w5^6r6{fzz3i_jP|#(q(}#2GkkM!yuTnLL>S8ntBTB8v$!lPY`2m`WXViN!_n-OG1%%)8=#_n9da ze!24xTR*xHnB%O5(98EP-^*d|WrYgi{>cIBWAajO59PpR*L1)4AoCBmJnys5!FKo@a;;^4&U?R^UphcKb@W( zz89othp(BQ9ll%Av%~ko^z88ciS+F7?M}}Q->vD{;rp5N?C||;eBSTy4X0;^Z%=x5 z_+FHr9loDS&ko;6dUp8sre}w*O3x18ZRy$J+n1glzT4yToetj}>Dl4?`Sk4Y?N84R z-<|2%;d^m^?Dl2smYyBHHXI-eSDcSsj<^1{F z;E?`>+~APDEjKu%Z_f=5>0itZ4(VUY4G!r$a)U$q&fMUT{d(vRf^hxFsQ!6E(Ma)U$qcVfdG`<(JWof{m|Pvi!N^!IXu zL;81fgG2hs+~AOYDmOT!|9ft5NdJ%A;E;YgH#nr9NezKV{k`1akpBJL;E;YcH#nrv zDO|DL;CgH;E?|F z+~AP@i`?Ll{y}bVNWYOA9MXT88ywPql^Oz%`etr$NWYaE9MXTC8ywPqlN%h;Z|4Sw z^gFr1A^pE|gG2gnbAvOz^Gr$h!m1n3` zuG-U7^CeW%Yn)L{v^tQ2lE}NH0t7X2ipEn-tI4Wd2oG4|URwQ{imSv5F<#p5@bLwu zSyBE<5d)-xm0V9*G=8&5egF}S3Jv9Y$M_CW?vD{a*&%g+3wZT<<#Euyb7ir6$d{+_ zdYe`p%QrcJ;6v}W2x_J48RP@M9UxbAi{>x^n`hSu@7$9T%~q)$$2|Pw;^`*QaWjwj zH7xcCaeRuov6|-OQP$Gz3Rf)i z@g6eCEr`7=1_uQ?$vZu(TnCa(!tt!+SE3oo2&kGQA6V*A=Tc{e8_7KOyj2Youfej~ zsub6-Buh=b8aIXfo5yAsS1Devf|AwH*z9>M%w=(i>3@^xEIq|{d*(a>Vs%x7pHeMh zRb1cj%+T-9FflmE)oaXo3h89y_~GeY$y^^m@xTx0<0BXS=#V^qj6v zHZ&Vwyx;Od1^skH%DwEk!R-enlvw7wy*T4lNnD7^b-Ig}tPh4}j@n}qC#bitkYC*= z?=OFk+tgKa43Kmwm!ch2V!KN^;+#QixIa&N(BizsN%N)-K!|Ish7A-W4GCkQ4!lvj z@W>$uU$z{0BG_6M8e5H6!{p-9>`WK5-m-s{ijTv$mO2(o!mqQPm5W9Vm;^5=?+TP4 zh*}wUWMttx7P{>5qN%AHzH8`VzMZ zGF_f0O=5p9ST-5ynJd+?6V!}9t>oJI&Y2}jfl}V6crEDFI8!#w zYvVBe@;gL5j0AWjuc~O(mFv_fn*iV5%>yrtooDc+X%oedb37BcTZ4CDZOQNEwF>vU&5`Jm>q)V#2fVOQ_a={M2=+;N$pv4MgX@YcEsr9cGXJE)uoRRIV}-p7{3`@W0EZlqDP3e zu5pgUYpdcG9X+J zhK{XJ1)y*OTkL9QD&SKNJa1LSqa}5<&O_ANgn@7)or2xrek)`mdp>!dT6@(?F?dA- zON;SwBRy2^^J9x-DW)`<5jaTrtSaBQwR~V`pPdh1%I-V2zM;In_}6_eDhvF4fAYPb zDYq1lZ`soM)blAS^^yB;_*X}1_()my7jOHwFMF83zf>X`$KQW{>Q#S}w$>JYPao6Y z>remk%9i3ue@_C;r<*ot^JlW;LvC+Jgra+pJROdJ(L)JGD*j19tZtAvPjnW}uUvTV z*8N3sh_1%{{~GVHO(!MQGL`1+C2d$>(KWfUI$&dqJ=v*6rb4O`^@({-nN1qvMz+af z`ZGvL5gO-&9HB)@k9U@u!zw|_HZ8%2g>JLUU2=e%V`qJ7e|PKQ!o@7>TL{I~c=zx;PEy#4yt zUlPc_@K652_g?en?|en=pZxsq&;0qP-~M}g|M0&%_}TIg-})D?RaCPf`9RA`ujuge8ZbgJ^1UtsBeGa>A&^d@%R45 zdkE1k{>{JN`OIHEz4R)5yZD{zYY+U>kAGP2zxe}Ce_&?#U;MiM{>y(f{!71g;2*q0 z-+uaU{++-5Cm-3~)Zf4UXa49pTYl>4N&Wr0mn=T<2amt)@96J;{^$P5w-#SIdscn^ z(Z?@-@E^YXU;P8MtFN;0Is%Hkx`oMqCV%fW5`-Y2h=gi*V&cvNq)MNgy>wr>?M1~- zM>Y1^vK82By`YoN8%`rQ`PN%S*oRXX#3NO(^j&;mw|nOS_&KWH3ni%&?MF@$1rE{k zoH~fiSPxBc!3s{Hq7=xqw~ML6K=eXHh9KH1Ets2cls%?j13Zu#PpAV;=cgYrAwfWr z8aLft;&?S{)vN-6bJ)vIY%LCl6eNAzaZ zlxbcStC93txmQb^iy|GgTT^5aHII0tO=4qp{0RDy88LML0oz3Mq&s&zonz!U+neDE zdD5GUTg&DvVDP@e54IGFdhj4|Eh%rS(|XwG4JHH0uo``RFG!e>=4uRlF#YEUV;dDI ziqA0bfj&)30u&(4nEIWW2|HjL6PHwMz#4jWsru3=ixgghi07IQS57)woFi#TEZAaD zM1qJ8lARzZDU1&z0uf{`38GtA|7wgX7onouMCZILC}r`C_Ut%}M~RhEJ-p&vSZq*z z2L8vbkiAq0lSF3;fxl`V2g{hsa`Pg{)*aEbAmL#1G@>@4_Ton-U}(MpE0|Z{pVB8J z=;|U(1ZESNq!=0FPq2j5TI6o0<;Obh6EHGz3ipO%qTK9&Rmkc-FUm}aR|u1gg$R3m zTVo0&eXOi58@ZM;CFm?S9z}**RgNt#J_1T04Trm452Y;w8%gt@8$C~nsq+lo-ms`e z@qHo0tP8bLDM*l~ikTf>F$~sHs)P>XS-9NA**2isP6r^ld?4;aoY3?f*>0X23#@3J z+Hp3_@;fi`H#BAWk*cY3Fm^U<-V!4;HQz7ohX&QHY3h7q>a?vpiSwz$i zhun@WHL1@-N@|N_qFFc$72IV#=dn&5>Y!9ggLtsD;kan4x>4~2j>wA_(T+N8Wyu35 zdm6|F=zzej-OhLuDOt2PPGjn@3#~zL&CIxPm*Q|DnU233?3a1f&78+)^QtYJI9nhF zj2uvOi8nA&%>}A6-yxe<(wwTis8sd^{?_0DxOLyR48g2OhrN}s^*~U`U%oghvRM}2 zfqU)zLerzp87>l_b%fQekQ%TKw3Jvwp8u4V=+J3xi$$1^Tb>Sg2*CF~X6LOd0Yuc9 zy>~hj?Na(k_HG2NNO;&{TODVCprLlr7%ylR51WxlKf2=2wq3P@Aaaw5jbK0lLF^pC z{&dz&+=1N)`2t49?H*}%s`Hhknf0`JMMvKHR)H%KK?0kRtlsAk`>3epGUD4cn~>Eg zI)#Yp>H!w2^B6Nai3=i^&MBxY2u(0XLB9+5J}w~T0h-x3d71P?b^)}4$d zi>jpB;#mn{u!OXi|G!$q17fRV^N*f ziw<4$WoIgJSo1u#Jiinsiks~&aAgq95BdQW(sA7gy-ahx;Onrt496T*mxeJA9tB-! z_>VKeHiX&zo?~H4oCI~4srya`p01hwH?CkHnJhcBF}e5tz=_`vBHC6Vc&D>m6!(D*R2wxk#jbC^FmDbLz7wWt${-j4 z>Q6V%P)o~a5dEXZ1YA@Lf)-H%)kD4r)M~~svAlLqr*jXrpB9(P;#bPUMbZAPzy3GA zbVKAZjj!0?=j;s)N#P(Onu4Znz5r@DTSrzn6AG`L2A1k|qZG!(9@uHuPM!+W9EmDS zJki^N?3EoH^+RZ6J~AjF|9aU_2w=Wlvr-vV2|WSIH!%_z7-9k-O7hVtP?`FLYkUWRe3ZO7X;m4m*9y_1)|boLrVWqdQDp z+Hyjlzz|;)?T9ameTDa-Q|9M@Bn2Nlk~jPY{NRowQL4v8h^pqRs0qNtDe24nWpf5Q;(!aoI*! zm!~mQ$g?4XQj~Vvi>vxt0_IYj4{IGFROo(1_r`atV#|IZy2$|wGAVl>8&uJazhYkE zG^k*GQNCAx->v(d=!Z}a!{|mc#PW7o_~|)wBkkgLQtT1OyWLeim&MRwd$;!gh}{&Y zh^5CBLLTo^5J9UfVf$&4JJa8%xH1e!Hf$UM(PPA z-l=$rAL_ryTLGxJC~?oiewslAF(dNf2$YT+I*o@BSH*DiPY|VQ7*OnQwps+FsLbN* zT<5o&%~=p5bUPKk$LkR|p_uAv58!J4>6^DS`Om_cLV(c(V^gZUsejQXi5Q%?Fgt(9 z-L2uBL*aYk+3JN4tN@OVz;f+{@X7J^2z*8^+t46(MyU`(jF-J^pxJ!wpr!$4>G;Tg z>sBhboSj$>U)Y|Wm57_wnlNR#6{h_oTcDN8EsqVC!M+bHH`hI9n2kUj@iVOeIo!TD zRW%W9>U4+%j~f$b5XeI~!8YwK)~{B5g$dL@+eU#ZrxyDS+3hsySXv(2VR`Q9+sTMnaY2 z2W-Z1le?8}Bz(l8SBv)}M+`oND?*iDsl4B&aD!q+ktxKT6k!$DATDZkYXoTGf=2_( z5sz5Ji?A}tiG?2q>NjF$4;2YFhZ=?ZEee}BUZ|JvZi|rk`-?BB z6XGT)D9#bZfS?J4`xvsxB@C(lewHt#;#eEI34qmxHZHz$vc-rM}8qo+^Z zqS)T!=bCGfqZbhi0k?jUn{VW!F|ATHRYvRu(W}maikjM)JlC;%gPJXT;{+Klcf9E}drxhS8OC`Ob;GHP3zRwTK622%Fs?4Rk<2)s^V7c!H6L4? z2qD^4&5JLv&1w`>QCO#!DiWRsS!EfV3isASq4b5^2Id`5;0cj%!KL~HXNS(n89=A@ zC6SI5%~o26s`w@m@+(qG@cVTR0+vmo3)KhIa7LtrS|Af^$Q^H$t+jRe>N7}Du%*on)t{|`(ChM7h<-66vT1h=!e zVHR>9BO$Efk#tfS4*!*=BRt_o9n&?c@>`7^$51F2x{})nV)iVua+9_Jf-EkrV7EEx zwiqtB*wl7Y90*+Qeh(SMc?x+d4y6<|3Vp+JD2^mzEK?o|?5Tw#mdsWI1?Q|}6;}+! z{oBjaoZgj7X7Q1OuPW|z`rL{?lO0C|xS$H>YX?FrM8^3Qi^Z{$+!~qwj2K5%l)uxU zbRw#RN0aqTc3Y!w5_&M|7sRABFr~8oQ43W9x4jByOaGAg!u>Cu<_jO2U0%^nVaR%+ z5xhnDpb)}mAyus<)GYC`R)Or(t49WkgD)exGFoOV(tR@~N4UcZs<3X15b7cy;_|nf z(`~M`(!kO)mkh3OQyVm~bKdgmG8l-A9zzFKbSu<7lu&oC8*(Ac+^Z17IRMD$V{#^o zyRfnswVi>w1S=EAIk&b~IX`VSI#facHD$Jp}{7v#YT zXmF7E{04UnB+y<&TB?7}1!NG*&bWy-O*T((P_y$|ue8-npoJyTjQcKK^J{H0k-Ff< zvT>XAX&2|HgW4-iu{G4>xSVHK=a(#Cy$#8Nh3Iv7c5Q`P&NF1&F{e4I6$Sn6Mbs>a zkk~1Z0kfx83SoP&=0F_)6z%?F6OYU;aa47UR-j*K(obRAmf5oGLzHq0NVF2Inj27ho|#!BIIXNzWnW*3%3W^nyMdyoaPMvz{vFzB z&+6~#8`RxobF70$dCp=!#Z~os6%}C$xNq5VQ{T;c)6L$JBSCwA-C~}VUzB7(LBh!* zILn)@x80*=sAo~RU3trvEw}dVp~C?YtZ2cH3ZLyCVhW<_JqpW@N`kC6+OQz%vHouP z*Ld+8N#f{4?XRtSnN4ev<#xofQ)kv%QMmTFOA&INW63uaMjjuQ;K9_3=0GwpYMphD zJ1_m__bj@#eRjEB|4H{bD&7>SlLeN-CUN-by?x4q*zjNDf%KY?ENw)OitzNE+K2Bc zwGR{wd*wtkrme|{!Wx7q*f>+j6$=&Qo)NK1!-C`3b)Ie%yHPzSwYFiRNrXx^Nehqk zpX~xJC|sdSydMx30 z$H9Z@ddgOZe0nZd=y|oZM`?a8MO5WoedT<)>+oq^`rA3qMp12>6rRDsuWf}!LD!M@ zVpa`!sjW1@0o&)w@*=wrw`+xmsi4zgVbI#<=6L#Bww9J^aS}GDC9B~ov=mg5^8z({ zIy#Vyp-YP(=2MZV+6`RiJuY9$SF>=JRTmq7Xy}b+PK3(um1Hgi=C?&u5IN&*M?z&q ztQ)q)16ENjn(jy4mXk;M_UvC*VoLmP8@BMtOrfr201m{$-lU${h9yGEYMkxtLw^GM zE#Rj*5$Q+(;cl!f=*I7P@at?<`mbRUnhZDBK3lVoAc5(?%m-p6vSM1PfmJ=QZt(HW z^4*v=7(Zl4MF6<0*_VOtTu{C0Lfd(^w0V&}D=A?5sauIa2Ej3$Agc~Vc9QpEoq?bc z&f2wYwoTvZnyf;8ZUfF%BNLLB&xcbXb(NT4?Q>LD>j`X9+;m2}Yx8Fp@t{aYP{w+Q zye?nb_Z-Jn5*A27TP}N^wnZC@@m#aApdyYk(vqWT&^g3{vk20o3_IdB6<@zV06u7Y zyiFEeu*siV(6E8055YQt?7|DGnB@*L1e_hd=Lzr0%F~$MY)CqGTQfJ z)PM($)3Cxa|JmT8*?3c|ewtjo*W86BnEXnKbU<|U20kb5M(P%8xilJ=z4^sgvUg``aKE6oqVu;k&H*34y(3nulJP(bW8(a+B zHKs%pVY*qRo&=M~n^Lw!^oueGyh%E^j&M-ys<9K;uzbfl?Ym{4La6JD#Xerw)X%|l zIBjXrd<#TzrK_AoiaPOKmsG`>xMfG6=CPuG*WA;oPf>bqm1e+$@cD-C4#xy-?L1E=I$i= zkXG|DXTx0H4=2cIieWoseXWX5`WB^YTm_rPHQSEm7&;*NNOr4nT-`^hZXw5SJnh}V zLHSx0P{FTtAP6k7H`6l9rtfDbV?vJ^!tAvUmnpjlYlsN-<5%y?P)t5-=&H{Vh&eH4 zjLsvd%1PX4+$&0ITo{wst&|Kl+)3DBR`4L|)?`&EXJ|Lpo$xycv$v=*^Ucr-^8XARFGQ_PK_+Vii2%xo%u)hGr0fQXu+9 zNPyGLa+desx7xwwjfIf{CY(Exj<(&!Q4e(~^eS>{yvtY$D8aikAr*K<_LFp^CB9+0 zXBZnHL-w1dec|rc5P)GnbKRm`?ddHkUe&Ne!)^6UdKOy0?WIoZ!`sTKGa@3mOoNH@ z@T0!I8F+BDr8HB0{-qSIV77UqA_`~4kI)M?yA?W@TFsTG@g}+Y#;l1SJq|jPs-(3z zr`ij4-&~#uM5b!l|+10Fv@N^ImLvD^nI8dOHeU=iUR%t!VNm#6Yl&35T94de>j zSqxn&A4D!4SC`5niO2vG3CVTFh+0_U!knf&xKYLk<+XMpt0s5`w1T|Ll*9g3P3d{s z5oDvj;!*MTcNqVzufHu(kp2>!Rg=$7T|_X@r*G(!z_H!;(+w9a*R;Nr0MMx$FIs#! zNvVGzC=@p6*$>xU*oRg4rZK1ay5nEm*v5J)o{wy5;whgxxBh_ybd%W&_072o1|Ql5(B8gvbcIa6bhn%(cIyL zqQSpczPYb_YuOlP{l=2}avg1=beontvr*Xa1tlCCj(`AW|6rlJRr%JwpBPlMPig;u z+lb2^h8V=^3K!o{70dWVd(qH6tVgkN14l<##7coQ@!VD$<>& z7+WN~gmMUjg9QVdIk9;~W1o5-14fCb`(P5kCdOq(9wyTaZzyjBC%K&@>2mb}`HW$r$y;}3d&hyG`0-M_bVdy}IZFjm zcBUU4R1_DiL}3oS^AZ~6m||`~Qo0^!q-eCFcsF=G2%(CMvlh1AG<4!xBOS-E(OEw8 z$?%KeFsmBPj9^Lxj#YiIWrFPe9RzrG{s`Ndst8z}2|~tnPrOnD*>fAlC{L(wXz+YN z_(o?25PKW!ILQsx19VO~w9_%PThY_TYs>uSuJPlZFQgwQRxzT(_z?--%W^d1X^PEY zftSp)NNJ%yxjit8QOsjj;K4N=c}NII^saI~EbEaYe(u+aqNEc@P3W(VR{jkY9OVoe ztcY%%QBt_(BbB)DxpI-3l|f|c)rX-jqVqBF8^UVf;i@qSFb<4H5Df*21YX$NI`v2k zS6CPjrHrXkne3IU5Fwo?hW0s1*D|GV@B;)Dg?`Of`rxBBp#TUlJZch@wg^Jfs;N4fl=GG^nJYV6XM?= zGDHZYBf-W!(*%RyS;vjaFY=wsT{X7cR9a6@TG(7KNSwcoFpby>L-ueJmBpv}_BG+x z`}jX(72f>9?<}`-` z?^tH_GA*WF*$_qvnF{}di;2USJ#az9DXx23)gWNX(kxyTxvkHKAU+{7xpr)u0YI=` zASQ;?#_Jd-h`ri*b|?r*G3qY)6F5z2mT{nIn4iV!+S&(g*(8lv94joIy(qc|mnA$Z-5iErPQzI9ax z_gd+vWLD8?u(U|dVqu_aT|#lOsrLg3HZHYAw4?K`70kt$dKM}7qD_|o1{35IMeWIS zmhdiM7EFH(o*ilW#>GGcxG!M?RryTcPmhc*&OI{SUR;5bmGy^1`ylnph|y#kDDKYK z2}c-SqQW#u5tvTJIyn}YN=$gEcIjhOAbEcFeO?@K(mnzMt*WNpm4?KX=%DcAGO-WX z+^^g3;fI44>}87!HDach6cMuIyMi7bm-_4+*4R6mcij16nZv9;_AsFzND?Dbdqh@2 zvX%|g#Y-+FqiULP9J@$_==sPkQVJc7^ro5EtPGk47wH{dCG|yq466}+yW%B~tqfA6 zZ_JN#b9>hQ$sl86j- zoWcysZGBsAxeog^)y1{>CtM2*-D<^{JGz9)*pCq+uiE4&Vg|Ojzx7ug4*lJ01&rw% zx1~DXN5_4fHfT`(DE4X8R~jx0!4AZ!KnGG0WXwFoS61Wo?$;)*_+A|1*6Xl)KI(iu zl;vADlw@Xw^am!$<|tS?4ktnb^+ZZ%U8zT=f3@KA3aNBQ(_6G%wQuwy=Rv1UgpR^N zyiiRBATfN-h~BMnR91=W+5>6Qq(t+o-bNn#M~_22w>I9Q|)vWw#=%7 z#G;d6BjgJv2q9+fg4gOC!wT$JZ#V+!00@=gXwYx^)^|0;%BMnvd0r(5N1n*M%q+mH zPKwf-eYK%MbvUU0Cu5{aQQXO-gW=PNm4a{LuQC;{K%`?(QZ=c7mX*#49CV%bJ*x}m zGhu%R`nGJj>$*{O_048%mx>}&t+UvOn%ZNwU5v-P+XVb5#F5RV_WYp@J(KBhpSu0h zv+CBkkasx=6*wBJ6elUu90HBsX})}J5r1|~0@Q>7C&=E~Zx@som#ae)W?wsv-ngtZ zZR=wfsutcj_9uznz4G+%Sww))33C?4i+G7I2Y+09Pvp?>WZI;mwyTddPbuF<`n{k) zOdz;O)$T$3+IvRv3}>o6I$Gj7>#8XQ`yXaO(bneZ_;H;f#-CkbgQwe%VHMParY<)G z@q``BB2o|mHPob&`A&S3PnBB1$#XfN-2p7JsA7aEMhoL@mJ+X>P*P*GI?cd;2HZua3# z8Yk&H31MLqgqCcP-81972uvh5E4Xq@4Kerp1N5Bv!Vv@-^ZSH6mhagdHx44f$NfWa zIqx~J0u7pbFTZi!3xCtKZR?8*z)O1 zpwAq7tB%S5;A&yxs^y~s%j8JdrjQm=VgHc;%5e?T4N!5HQxF8<-Aor=O1>(Sy-x;J z*;jN`2eIC=t}|_3kep=tb$y65hrpAVjl%2^DFO^`8~9$dQ{e(s#G!&HYEg}`-u%w|$a=&r3* zMthQD;~t_;;BjO%-jk05+L$#<9#KeiYrS7o}l1UA}&j<@H?<-gRkHV6wGN|C`y z5n{Ps8Q-Ah?N&9u*+;z#s)EIJY2EAlAb!5U8q;Y)@*r{i+3s>0$ zo6buu_b#RskcbV>1_Wcj0j1(WG+i82riLFvr#qH-M5+O?qvTr^h(-uuL5#BRzv)JWL*S~Pr8CW~c$D+LX`tq4AjT2|5@!WRWO7)uSPML*gaPY4X_Y^M7aGOt{ zXZc`<&p4fYR+Zo1Hrza^94p)a7*NAsjotAt*KtV<+71EtVs=6u5d$-ng%9-P4t@H( z_4@M5TLwl#JVOXOKlJ97Le%1aswpnyAnd$;C(uQYi|2;xmpP3z@Al~Fkr>xMlERZb34bE7cZ@q{?E2udIA;;-o0u2n7vc~e=(UOoQ)_-LOS%B*sd-G&=&dT zIvq1>N6h<5Y$UwWy(Jp)MOa*&tiKDVT*PxD{X7L61QKgl&4ECu!DX{j?T=;Tl!D}w zxOJ0BG~Q>s5sJN;7Al5>5{xt<;5D7K<}hyhJym)8_2sv=3>~(0>OJZM7w_I$Tbw~e zmr1_n5qPk9iD4c@JY8PtNU|%d$h1xfg7;QT*E{Q( z8z%^RO<=l;QvP-rA)vDpFq_JhLaZt`J`eAyNOu{It0uOg#75KE1hElbzD3_M9H1uni0GT6IRH&}Z%6bgJD$kPbm)mSm29 zfsK(wjYN6-i_$CI+3J~qZKAUg@VG{& zDIIVOS4+qTyju$Mz2?g$;4?%2V$2!hrhA?UNO&bAFYnww()1wzl{I8CkCR>7Hyr;u zb;D@#c8)}Mp|hA3(Y5q=u@EJsZeH-RhQj`|`-DgZ?dLMHAKsueZ8SKJS(^rvw^whxkd;X{J~1Lt8|JBW^i` z9EAnZVh#73cd-6yJg74w}wF928ZGtQ>1zc^!=qr#y|us#JW`?qKK{mJ>*3ee>(gU|qzk zfv~;DG#mloh9%=SwpziHjhbpP3#|nBaao|O#D@S1SARs8`e=yvTyvCtc>A;z4$^cW zA!aXHVW_YX=DRX=E16JDc*0}T%vzgD>fG6xyStN}FU_5`G{G%0*u{aAI>jz6 zIFQg39B{z_2OMz0p>A zhXo$E2-HbT^kR(2PS)GsycEN=dW&3kRtV= zJQIaHnqw_k4Vx`LwY40cFrP(vUOt|5U;<>ca6m;>u-}k163U|&E>VQ|OHHiOG_T)D z(T(cE3~b;Qg2+1l1AUJ3WmrYHcOy?zmC@ zXz6zexRig!|G2K+4j9qlC93Z#Z;9J$(MGFLzZF-&=3&R7(M1k!v~4cfmbFsSYVR`7 zsZ4~`Tmz1vvYA3Otr*;b=Bgb_hL7YEbdx+>i4{2~CDeqq&F>8s#~^~y-bBe7v&^dy z)Jy-*YIq-d#ToRlgooA2_x|_48OtEzU!|wq1xjJ|)z*&cm(Vq>KC#gBp`j>pQSvTB z8zXJP)(j?;?bx%0Sxzw&%Ii(L`a~6_QtzHVN!-Kd_0|&-13VI%DV-li_zV+E8aWf7O8182{ScfEC9aSym+x6n4Os2az3cFX#L}%x zh5(OKvX7B9&>kxS`#CAb1Iu2-#wI0|yxjl(vyCIRUP-Ahb8_u6mk9!sZ~_)wog0m% zsYF|DLEmvnr*-6{_KdBFb4A&L&b0{So9@Sj%SQFA zX6hhKv8LmHTC@sUJiqG9)}f=_y^lgsaiiDqC`b~g|3$T}w=2&p`6uPCt(Hh?{Me|K zR4@4y*$Dy*>5{ov>Gc4PMt?UkF1SUbPb)a9gaXT!R_pYy!GIqO1R6TtWZi|}T`tj3 zvRj#0shtlP6!*+t%$WbQWc$3~%nbjh*_cR?f}t6VKpx200|aYLR@oonE}85W?+y<@ z%hfur@1ZQ9OK|YAlw`M-x@U1p&gwfiXdOb}lzV1dt#=5pulCVOy^%}L4;N>6FK;Bj z+18uMbk1Z}e>Ec;xoEx>)Uup~#gImCwap5AnTo!L>`7ghI76mf+Z>!yLT&sDvDnlO zVAPB<64Jsub67KSW-PB?k*Tn}&bcqI@9rTbePui&raxK6(62FQxYQ5QS%`hsRhgQ` zGbH6}MEe3qAtOcDhXR!GvN)Z8;u2SD9pkTVZ*9+Pr{v;d6Rts@{73U+?RI-&HjpyC zphJ%+j}RM~wRJ=1PPb^8aNvy&UYlQ0h8!kS-chN1t!++L3u=Di;NuU?cefzXX8QMN zi`YYGCDYWGCzco9F?bIY6pc+0n~@23bJKm#R23!zW0M9JR1@vUB+(r_fe9=Q_)wS% zO88QXi`c4D*;v(HR(AHbS^wlVef-x}j=3V6-}M#!G4AY|3DPYLPUO$Zg|sJqp{h8* zg;flR_AD$lc!J#n!I!D}s*3Xfki#+N56CpxeL4P~Lfc`=R@#7xe^thruCKHq3Rd|V zR(4sOOc~(kwsr)*vID~B)90lSS81d)^hhnmoJm5QC8Pt8VmtlID1=$u{CHuH265r- zz|-1m&5>B%-40?CJuy*Z8Bz0AoU~$&ejc(^8+mV2L9Ewm{e^UZ!&aTg6VV(YBIT-BN!h-&PG9WvW#xK%q5;$w#AJM)Z(PtBUC%; zD?f-b4);k=3*yi%`y)ZU@a2!Qtkp;PlCC0PA2o?$Jh&X%W+zK950pwsm4pQIzy#nxT%|5W zeyw*jM>dMp1!XIlkmhKgv3Y0vf+|apVy)J{WBPFdGWioVxedR7a+h)%n@)&=H79+z zPDw`&4k~q6sev+{-I{~33p%@+6VaLjw+p>C&I!l-%P$hkGHn@TJTFM6jIV{ z(71J`a7jUtngcL}Ub9+Er=_YD9RQfP|Gv*0jAYX6B?7VHA}qM1JJt~bRkoto=270o|?NAtn4lk;<_Avf9n_CP4C_qFNfSmULi z8-M)3N51wy4-+udrpO!c19=;^|H2|zp?B2<3@oXPXcW=n8WmMskJft2uY8!@&52gd zq!vWe?N^^|T0E(aP{UW4nc!7vCnA|t=q9x5BZXy(Jn<~7nNj7@LtRjxgV7_S^=n9* z)|?aJcJVX0LlZ|yiViWuIE2G4FAPmZ!fq-50W z_C74qj*QJdqh1TrxCE^6vuT$8ZiJwfutyelwJouD;Z>>PB@UQ3l*;Yh_%O|df-JEB zGx`04Tpt!!&!u0Vy(H-i%03i#YHRGY`fcgN(r{1=c@tu`1?lb`LR5^>&yY5 zenqWSJ-r&5$%tu+Tb@9I{oK}$A;EJ!b zFLBclkz1gzt5kLqTl5b`Q+Lz>Yu_({`Tk-N8Wy5yfjf&ip*Eba`{QCCPeW|i+)ozO ztdtX@1y0mkgA)k3=w~T*g`4Q(q@bf>)ij!<1{ea6U~y_=>-y3TVq!_HFV|KZud3^% zl<=47Up7+5{$~4WXJe2Zj2A~z0NqgqEApT7qX`2SQVAOFSf!b2r#M~Cb$7oUk8ytr zgW;FM01=z}G5Y0D+OI!^sYYpe^A8T5n4Fh)@A8cco41>_pN~(@!HUCpwhQAAB|`_? zba041mw07UOEe`-q4I2oBat>IQ)`>I^^2fZRQ{*~)8CRp^-^qk|G~4t{s9?+iSgR~ zmJ&Bsws01z#4zhTc3`G?Pgrb%{7PRaPF+!lL+%b;R09l$Ne@haS6e+8tH8yn&$AL; zx?^Fnt4h>@*_ECEcS{rx&P|z5qP~#+aE>NEDz_3GpKboNM*m!#YU*}IIX90Hd5h=z zT%Hta(*s&<=~iR3FeiR+uz84aBE694M?8Ucp^>s@L#IHqm36fmAz?o^hoHSX^s{1t z-)~z4Q>z4AEd-L>gScB$+X(s@*ZUVW8}A$Yb;ma#8gVxMAUvtg(_iUtRZ#hviy5n{ z21v!}*}OnH+TyCW#hV*Hf4}`q84jb7?HaB?^hBgUen)3P{X(daDJEPx*ou<~8yS9S zWLsnh4#`cVF6ik?D7hL~GTkPlKLmV}wXmi+b9{j+3j5pXky=97g#s2aLwPJaxJ|ST zxd@Cz4avLk-3x7#LDh!00f^wZU*L}aluIO%GqX$Cqs|^i~|gv7qZd^kvJ&Rszz!l+}yvt z6di1!Mk&8R9x&${Dsz(a=%#7{-55i$47xO-Dr<$=H7<%?PK_!RP6WZ?w-WxVKW6wV zI;@z;T~7gSXfXfXoWfNuwc;Fp8cw147w>4kXY9y%ERHbEf+|Ri30-euKV2`W5rOnP zj;1GvmX&3T4XFpkH5oZcJHUP=#@v2Q(`3&E%d!9RlXb<7vqziq{hY}yz+e3 z*2tB8SIm-bp-1(oqQ}xCuuG)6W6*hd@@OmEmuN8OT@_v0gUXDDZ9=MVeylAn#v~`1axVQV%v-zz! z3rm)PzmiDIvsIxy^t&ponGOiQ|7H&ulJ9iz&=akv;T28)y*Mu>(p{a$qtwkxG|YAi zAKsDgwf*rFs(3P*vA=c{O~I?IWSHkWAsT09B7n1V*_{oj5T!TsH^V7?lew0*HN3hw zM*cP0D(f-h3%@!IIKWr6(*;c1ZQZez+7|UMtW%tsr$8fRzsx$9$rlWK?dLvcA)J8J zN=|l=Am3k=GSF##;`Bx*K!F-56$$N%bE-)`nn3S?@1bSj*>5~1Tq}`-AM6b+fbst#aj zlU>o7HG|z>bi3-#Qa%I7nLL{N+QW;gqM@J{HPF!z)VO9XG!GV)n;=>W<#hxj24Zon zZEIc0&QxNq+`gf{GTIvUn@hs=qi}$}>!`6LRhFO$cWh)oOXvC1S3k|-1fvqL`~sU& zBuXDYQtQz4h|el<3B~mFfCWA#T~s`^8*ydbrz5rNvfKt6~1W5 zs_OXIaW)H+U11x#@k~q>Ox3Wo84wa+bWC|BDB6uT)V7u_Pj(9oO`_-9;)d$#taIyq zUEE9B2?Z!99JPQS;1}29RX?cbQqJ44;slsGSDsHoVdelQKf7z{bpta4C*K%JdHI3TQc>C z6ybcz2tyIxgr{ha8e*0FkQLqW!tYzKL6KxRf}ZvCwL>ZWg8^%l3Pl z^1ahB3g!8xba21SN(y41vzM-Bq7WRb5u!<8F{Ab6?c(UuOzqm3GAPzDH%~TH+Z4?1x~~+L=8eWN z0qQkBRP$n+J8Nf-P53QNEVo7U29Im5^Oz-DJ2Vm)KcH)4a>ysM0~041b=*h6!_Z`; z7&ze*zRU#JKOWs{0ufWuh%)^vZYEFCD+xhyI4JO7l)d`Qg==l7lshk8h~t?pML}Ab zV#AM(eUME~z<Z%58# zmSn}m9r_ZzLuMMPf}YY*(F24%VB7V*cXLx?QgpeNQQ|M&lrRN(Y{r8(dH+^&cz1_R zp$fb1(9D8F+N~RZBbm!lrJ8}jVwg)AqJ$&lLYK=s^N`@y=FCj|ORX;#EDwL1C ztVOQcWa!`*XGI3zG#T^_8LuR~1X&r&m%TvhMv@2yo>{m>(&l>i3bireVMBHuxc@?| zv{@P6*8W(D%xK+xEfVX+yD8ktQ46riPZwrzF`vdq%#EJn>f4+`Ok-#o$V|<7Dg~)H zAq1_33oiks#$CNp-yZZvNgDj9o~YI*`yEk?Ip~CPHQHP9F$v~x-Nkg=3dRW^RPtIC z=Tn+pjVB9I-R zixNLdoY1~|8y=RFH832V4VU#Jdfn_pk~(bbOrr8_<@mU8alf?$f#sW^ zkn5G-*}Q^*OR~=rCUcejSxWN>oH;+1T6%ODTlx zyNi^sF7KRr?6@*a11KNrJP5pB#!b$(1re2EJ%C-E8o0d$?Gc+xS5tP%cgPv$mJvXd z68}g;>F)eWBo2k=fQ%aRe&8`>Ksiwqnq44M_}1?3c5mj(2E8taua#P6ZG7 zF}EI-1`-tjeDdfTH=J~Pk3N3l#5B`C%~+||;r$%tkqD-343!dPl#a91OGN{;y31(} zzagX?b+Xf!);4SFkRIDk>ln(Z7#`J^0~i6vryJCTE|Ll+1hk?0Wt=vltVd8TnqTN6!4xe|ZgiP1;I0Dx4Rsg-V*F=MrGT6{Roce! z&?cr%vM6t9J0+^9ViB|Za=)f}g%ndCBU*1V+B?>X7;V?7o;*OVRnJq;UvTR6Sa z80~J{X7L~i9(syZ*5p{JaIsj-3+wYyPmRqVZ#EjIKuG10EA2x+PrNI@mlv3Q*D`%V z#}_4s4@ib+wnJ$|8P08l3ryfQ%d%G8GFTa2N~hKU*`xhpaax4RC#7E9Y8_!6sec(? z4kR9V`YI<+RFGOj>kLCZ^o=GX4bB*R;>FnH2v$&9=VnNOISFqndr?T?nec?p62Pdw zL5q~p=zM5hD-PMPD$`C*(4lln*{^$W|(XpMB&Z_3C|{jwBs zlCK=W;XtW;XzbmigJCPUXTVt~Cad($=9jL`gz8b+Be&8r_w(&e-1@M@tq=4ZN>@Nw z!`D3!*P(aF4l|IO7{tpMcx6}CJAzo|{{v5sN`Uh6B0HH4D|^8H5MqBl=WkZcfR|ygZ9Z>dOdpgl>MCo6w3@$gJ}340}_w}(xm2604*Fe06tQY zEP|?;A4ZOTq`U4)l*FvT2sy?x>mG*YqIQK02$%j%;g~PsfdlDhg&8^HxaGB<|5iY% zHoKIeh^eHo@T`IoS?NHS(wB}#^EYmw)5rSl?ayX(a*PH_BN2Slr5$A;dYH4-R6RAV z12oI)G&F{Jfa4>Og+92m*C`xd>AGyW!kuFipEmB?e?XXXU|e`JJ|?UgyH7ZCUsD)! zf3uCd`%Up>Ii{G3wkLy#QlZH^Iq4El;3#y9F7aeEnv_X08|b9$5#IxYL@AFLrWha6 z6F9))l6G9C>DI7HZOh8TNA7^f)g6)`;7D|-LvBAj>BAD1HY}$TaL_8L8?Pe>GB55T ztPo@p!T$Za{(&s1aPpX<8zRjXys?CfGwsqpM{; zMG|}e={d$uHkIuky7va))eLQOb?fD0o#XGLT!%S{jifMHeZq`jVHb&o=O95uo~Pmz zV7?HXN#yD_4Hp^9j!$cw`=Gjt-6l=+HKMV^5hp6u6mY@)6cYJRIedjoBm2*#HB}F~ zg;qrAvM>5q#9b=pUbBcRY&oCowJVZjxi}j{3=2!a;!J%c zdyTWGc=%Nh1jMWB)%)Mog?iSjvk@Us)79Uq2y%8rMG9-^c+tIB^|`H``D(8nD{72# zsywk+rA(4X9(iu|$}<<9dHRt@W@6f;W|fI9h>{*r8xf&(k-N=AqljMji|x)0c=+ln znI?D@dekQp8Y|_h4<}e2+_$*3|H5_i}Wy053O5KlVdGZp2VO1jMEdu_*Pi2Gd$YfoX-!>}`vQKu+@J zCft%L9d?{8H{@4<-Op6tBy$j4*;BMwNdzHU$3{`ON3E~% zL8p;&!9)a3`sggo*2t`uhMA2IPsfH+LWbj+S@ah}^-wQKb$pxkP|f#DG@lr2{RB#i zE{`m_z>duwI_&hrWg(3vEV&WY5WQmB>Reyqm_H#AjIoCP5EuI)^ZHN3M6ew7;~al= zWV4OXElKGoss9(km`g8&I{>BKh+;EQVaHmv!<#qTKw>M<54pIyv-vL$og~R)V`J~k z8eZD`(*#!^MN$3*Y8wdVM4|jQ0%NU?0M!d;YLrN7P*m~5uY6a8E|Ey_x_ajR#{#8{ z`c7(961&UlF|&xM2N#ACpf~By6jv0@*@@Y< zjPx)(=WcOI)zWg2AC^(x<{EYBzcA7K;MkGV+R_ymor2kAZ-m#{`ba9GKkG;4`mUt#92_+wLklWurYpx9~AwdIq`N*Sn%vS46cR zkWcNUvvP{wZ@FCLf!QjaTyKP1hh-)mu?Lh4xqHIc7zIyUa%Y?Ef0`3jJCkY73smk; zKDBo9tigDdhIavER4;z+bzkDP40+{rQ^ueEtAC4ubPUf%?o@lB@>lA(@`|L%;K7bk zH4~L+$+>S8D&&WypG2=x)lyyGdkt$3Pr;6KYC97#`|LcoGtI`|R2XGBQ>$0JdJdoI z=IWKWF!V~Toj1VM@3DML$v0=rc?P{xitm4Z`dtuSFNmsn;5&2!tNTPvZEUMWGq=Wa%5+h&07#5 zrR9e8XH_FhM`cj1-hqWNemG3A=sbukcCb~fA2_1oWs47PwL1fwo>L)sZ@#MxDck_0 zLMP(n^}OOIxC@dDT0Y^4$ijs{bAc=4%4gBh?>k)nNcTLfe(4ZPg2a+I6_5zWxRU3U zG_oj_+vB!RD17U1sq$fbX@lK)v#`R&m87&>#$`7+UasOM%j+*MbI~ZrpSl}-0BoM3 zU{{I_%+o{>Ws`|FKMio8ro6af{c3`6XyI1ga&1YysaEb6nCc&-mMG{c z3n7O*^I5_9q@Q~z*@n>f8iE7Pm}D%0EXPU(^4DXWL??8J+h!lOar=fzi1Cpv6qD7W zGh)&1S0#`&$9@Oat%_aJ!NU874#Pzoxq3qvz0tf=?)ma+ zj!kroERs?L%KLyZZ*0p^25RlLe-RsA$>CxwHJFs#Wgq*!f?m3|K^H%a==B^~Zk6(% z4!=AGRv;ZcZ$0^r^RfleVD-D74Qi8dpLBxSYGu$advir|ZK63lwzzM5%&FjcInp_* zS*6fGZF5A(D#ya^g&FIn0_-vhlKFU*3s-kXcf?f8X8e<8tU2C4Y7(yXF|Ey_DP&y6 z_4Oa`E3eqfk!E{Bz~ef0l6P1`>Y!Scx+JxuAFUHAKP$x$R3Aj+WIWj;QWZT*u*emN zhgh_02Xi#%hi}*x4b2@-d8y)}mx+SZdSL6h>X#n%jxV*S$EW(AILBwtY8 zBQKG~KvR@V+8Ly!DWcI z8)IOI@W?<6KH+OJ|An+F&3hPCC*=Hrhi`67&Ti3VZ z{mVJNp~+EB;N(*<`)`bw1UFP)3NmHdB2rUIK7T`naL*rBMzSu4&z0$>@x#A@Mxh<-#&^%f# zr?H?rLS)8U3B`VTvB5Y*E&$&$+s98ZMkCuOW7KcH$%-jFjDViB4AXx?mumwSMsmS` z^-Vz3m;_AN7mK`gceLNv+Nc{p1NKZmJxq+Z)IN9kMEZvwAOk|MWw26IfJNP%a*O0l z5RI$clmUb5zJi=pk@}iE*)U!y*C#tkOxGyOLxxpwa=)Jo-)KN0NhzFWyZqQ0bxwW;Jy}dYd8s3T0f@u<0e#P5<~Ie6>1-IOO64EXX6coB%Kl`n>6N3xcw76ttkI< zj+K*e^)n<~ouxvI7FVHY*3YhNl^J@6;F0Uc297*K4yd`VkWfr&OP^wKie#b*;7xX_ z>!SFEWdUGT&Ak*c)UvXo+b!5+Qdw3jf56#Ny48&j%l49?&;m=s?5O=WzOF-y0A2SL zu5%O0_&&2p5Y-NRFzKML<51e{6|eMR5ruD3i?=p2yP0mkI&)A=+-+6zr!`Hr|1e-X zm77Yb(762UZ;X1nqK$88MLu=cTxPSZ@m^d(eXj}ybAh`^4uvaTT*(KU?*!+;v>}*y z?>HlAqxEI*i|&I?_F$=Bp$X@*fB3PrYf!byrF}z|R5Jf6cMLlqUAq&zUQ%ER=Ug~u zJcQf%c8Y8DxLtRr(t9NkN&BqA5y{=2FR#d+*eNxoM<>wAm466-cSqr3(6QD}_gEWu z{(j?u10ikmzMOY3!hdEpOgtLGsvf=|w7vAN(#%60{l6>CKaAu!@vb}2Ej z04*)gDb1j`H2{Ypme|6keETeM7)-Gl5%QaJMbw}-Qxt0J?AH=tiNlizP_bkBz0m#o zyG*Ci+9J#bSu#9q>F!c)MvXVfoaRWfS@@N=?yzMPlzrJfPdWM4#|rKDcBfYGtPy!z zyLl@W^65<7o*Xqab!Gg3--k>h*T;r2?qch%oo!P0DQ)PY&DL|YZL}B7|90pfpP0LK zXY+P1^<`=H|2pwdd`rCNz=7XC^alz{Ao=OcPLDsymYN|YB=k`2$$Ct*J@p|yALZ%# zB}P0}obnI)>b$b26&BDO<4h4=7DczrsV&Ui!o8fMXx^DJUf+Rwuaurcrsrm92UG5K^emGfxbQyQ7xp(GxWeF-KbN~3CX!GBs*cw?B z*~DBBL{3cT8WkXFO;%DMljt#}yg8e3)VLFftlH-w0JvobnPVXCxe>L} zTfN~EDtf}<2N_+KppcujG;$%p2==M>h^jf*0ntD}V}X@~^bMR~R?2E?p*HB8Q?O1% z*ioTyE<}~D^IM1hLLhm~3Tw4h7f@7l^DBOTr}x3arC99l6|E~<Ic~QMRK9eXNmk)` zc8BMqDXTG64ed|>Tq_?mCU|eRc*85L=Bw1zpqNT=2B|_XmhEo3p%rD628!WbP%bDL zC-;x1T+=@z3h6F;7#z3rVyhF+8E}Mj5)DV%kY8JwTjnMWILbLXJ>@K%j#riJXc#{PKpHz*x?0_7i*#&w#l2~@v@%f#H~UH& z?fCa9HR@b`xQ_7A`pVtfHsn@?q3G5T%tHy9lUkX8EKk>+xTfK|hu(SkC=x3Es9}nI zzOf*6m;=<6U=g8N>S=`$kgAe%d_p>a(iqlBVL-CM^}TC-XQu6n7!;KtZ({Rih#}>J zd0hv-d+1$YfU2U)Ym!B@niu}-dcrFA)Dj7`$PtuCxb{d!2SdZkiPu~uxxLHgScDex zJ!#c$%DnBsrI@nT2QM*}VxyVrR1?WTHZE7>Dsg3#J9p;hdUwjm;^jL`Z>u@P&;AHg zoS1bSB+33dy}fSsP%SspS_PCkiD2ck`64S3mT(Uqem4+)D6_=W;Wm#0UHPCZ?4syd zei1c@NOwH&Ox{pdA`J1M_xI9EO%AEJa}B=){ikCcwGxUrp^#S!eQ7+}K&!V;&Mu>0 zEB>yzikX(e48?IP=itRPVLB^)DD;Y*Ks|DGa~oCTbD)yG54j};+V4{Zy4LS|?-kY* zO(wzQ-UFmad0S-I1YnP9R$rkdVFAc2^r4*lQ7yKZgzV_aA`I36fa1RW=ryc%E768! z!fjCw>sFs?2rA{6n$ro#i__04+%wN?Feo*EF>36MwKE|r>N0}M8wru??ir&<78|;y zA_wzLsafg63j7gSDo$0b-*fAZ7k2PI0S5JfrHs_P;}EE`)Fo~~Ns&XtA))csQ7Pf* z83E{w?k$EFcq-{t2M`H@)&u{(?a#DM!OM(Iwsg>b)(Q7E!$(!{q_EKMATpHzA zsJK8y>Xsy#DBtq^RWGoMEUI*}Ss3cNt|N)}Y^3<64|F&`HNRdn9PEweXnomodA~^r zE#Wv35eru_AbwA%>G8z@Qf5-o9yPT2L+*_kSfQ~b;7P{a{5S$NCBr9`R+wB`C`ns`Lu8H_7C!uq_6&HE>aOA;o5fjN43D@76&-qq@}#$0-L+_d zN6P1&-BWk>OGbaKO5p9|o6I+yg_l|CMA1BUc<$jj(oR5(X8z&yv_9!QwRc(BUzm*t z@Q$H!o<`Eh9}BCHeTY}wNLsT&WKxw%pP0Xx=!##egzw7Qj*BY+ZYX?uK$aN!1oBTP zAdI1OXLEgPSxL8+jv5HNat~z%^-~Op64KqBEwx5<1UWtIC{6w0;yCxl&^N(L_g<#p z%dG<(QkPO??&#gaVT7{pB)w?$5i3p>XW4sBrZ%(X%4x?0X1%+_RuBhuwOXPnXlU2#@>5oGFT<>XxoG4j)}{YjF!X_+j-> zzkGwn`T-B`)Qxik&C9t=q&% zdfUqzl4H7=mI-!mQGeKH`zq%O35wWc9#adv^#iRm=6(i_RRBKnJ_kbKL!S z)mXQvX~VnqnS+w84}BHfkEg&#A_;+=@H{;zib!r#t=-1LOAS}gn%EPOqYnb@a>@Dke0&kKAs@lu5fpOW%g_MG? zhHDGsAV-e6a3i`VxpyL;S4 z2V>qvTOCF_(}uWf(!jyhAs8@-5SOPi`9{WPDn12tGqWtW{d|Vq5XQVn!fQs5VEP%D z{g_e12?3xQv3GH~N5@1vaHKleyP#1u8jW<7Ngv)Ft12j`RPmarC~RCC7WYMc*g24J zaVxJo52)G{v0clSq=?t-qiQ$>6V31nG?x0f!6>?`c|A<>Kn zse#00BwBPZhJ4}_irkT$6xWjJ7ps@(`kd#}FiawVmV5s(W?YtT^yri{} zAx^l0A^>{lkhxJA$#15I5Yx=sd4EjPN=pd30U|C{j_j{a0zAmr@5$^_ z0bUtPl{B9E@Rf7=0-J2D_io%zN^4I9j-XCfa57$|vhiXyhb2n?bJB9DFKEnrl&r4I zb!G9MAb#biMSnhBQgYJpC4rwb8K1{7bt!cHtR4&9Wr61Ol9CLQc`>X)yf=4;sdQ{> z#TKvDdV^$hT9OD82h-5NYnymTOPjBk+0KhL6uUd9ak@nwN9C%+n5;1zvwilf>`!%~ zbnyheZ4m2>ir%h9&L}|M*wEBc5k>h?C0y|;+o`b`TYzCy+g`us@2dJyr3w(3zbEqV zF^Zp7L;Ko1SLX%U*(Di#Si5t6X+IiZzeC_ zkeiHQ`@FLw-DE>!yqTBm4T(gdBNN9- z?vcq`^x>|HO!naq$d~(nBM1cnGZg+Y&WDJpVkZ4={{H4JC?Ho=P*H1LqC|0IP4@4= zeIq?Hw?oR@1Ow0sp(>KleIEF;>GVsVX#By%np0jq3ankcFx2#-+fUw;8&!NF^CZMO zfx>%oV<@OGsB2ZGS)zs01QNnD8wO!U*UCyO;k};(Bu=%j45^XTo7ilF)2wYmY=aue zTnZxpD25xkQ1-|U7;b~}_uFj&76hB>32mee*l5n@mXUT(eBBdYZzlx#io{pVGJ1y6 zsw+r`#jQEXgpgpwAUj;B9!j^&j4D7FaYKa(^>`IX?iVAjrbKj=?o84ZQMO4TQ5P?& zn@b6Vdy|SS-N%5fNtjo&)u=dUM}8G5NF`tzC(0~P+;jGJE%8I^5@mca!nhF;^=es8 zUqp1$S?owttxl;qYSnVAe!Hz_z<M7Xz^XA`i`ymC$iHrKRf!eLn@e7JWR^PD0Gr~j&l_O%oD6#Z+rLc? zkUD(r(mgINqjiC)!sIZ%49GYZr%aL1Ap3LO55K&1$E}szmbQJPn>wM{pA?puqC_cl zoGc_5Uzyh5ewmViK^Csn^jcGpHg$D2qg|#~~{XBnO=%hin8UkpeY0r2Vkg>QS~I_XsI>W@?37sg(EQ40~axZzGfn zRtDGAl4WO0=@h((SDrSBn5g%7b16L|8Ko3?!)ieC$o@1~;G{wg8}$QRRK`>dJxnGU zZ8O9ByUa6r;X=;xBA;JQq|~FS(Qr=Q>wV3K%Pg@JB_7BSQ#!}aoy|g>CRJ46G^iML zW@dG3seD`)?ut)VzVkXsDMu!!sfpE#l43&GYHB@I<(x%knS0=>6`Y^WU9+w)(_K9( z;+`Loxb|X!N>uK@%w4(ZBvH8Uh*w}G@DNUnl5!hXkTLuSZyvWiux0a-@6@io@IsXf zh(zhXhJ^@I>BgY+{$Z5k$^N>WQ!1BW=4ytW~#3aY#>25_KLaabUe-bXc;)HY6_&Mzq5%x z&{bXlXIKrH7Oy+!+KQYmKcI@Te=-LBTt zeo>ke{uOw|2DgGZDpd|^(=Qb*AkR(oIage{px`g}4LQA-+_Ol1TY4m#y1E6-J{Z!O zO@TK%YtB(B7E6m(LDuAg%>*LfAUW9!gZS5dtyg!`ov_hs@WLXYD6AB6i&fNclKen* zW#hb7x4OMAITtmT^f|ZHWb!g>tUvh~EsE&f)0r71R8?rCj8Kx6tr@Q;p2$T9 zOvcrGOboBes91cuW;HFM8P2y5+I@^FSgrb{dxgh==se1wLTTxX)7VG$S2a$X8n| zQs5QzSXb0XCdv6BM(_CWRlUn>2(6E-akm(>V&lMQR|TX*GS#v7FDN{oW=l~niYs!5 zTEbyC6<>NQu&IvXq0{Z(ct-^setLk^UNNFKhY#p%sJvw$UGtU)d9}5s3P|r)3x?e# zRWBfmjfzGAl&Bk9lgHi;_^8*25{e=C?F+tFA*aDojXun3wZ1(@r%S?V=a+X>k|nfo zQ89|0_g5X_hzg7WBq|!eVqnJwb22;qj+ae?N_96{>&@UN?;scAf-mcJkLGgNB{-yS zDZcBX=^n9fkr&s6G{%4UBCo|cw4igae2V*33EG{>D!FSWW6aHE?*c#MCg&hZ6hi6(@s`^ZlcZS2wm+*R^trO4{DC;UjIO%m$Av-As26Ggte1OKc>?&V9)#y_9FE>&;t zud~e%-K20GMeBJo{kJbJbSa}=wQO3j8-V*)8q+0|j(ie*U8(o5v4j_rxIMqcDIr;k z?_s215a}{hbRN!3g)ja}UWD}M6g$`L0D4;;u>cdNMq|M@m|*84IG3=_07rhYyae8F zxZSCmsJr;Cdif}o)!hY^E;Jiw50|SReUiMKnIVM7V2Eyl!AVRz7H`+=Qv^f($%0Rx|^5PrlfOEkq{am2}({mxqO(BiOv)bb)^WE|ZS?l;57{^T-nWlGo&!L+2jbh#) z|Hb&Xvuiq%*efv6GgCLfVI_27ZQ(XZD@zMI8QGq+0p62FrJf1*Up+ic+xORV&5gb* zm7(@zHTv-y0FHsVYdgj3aY-<0PgfmHAa^l)JQmdX;#JsvS+{y5GEBV_sU7~kiRLei zO{x~Zw%^cf$3IJ`Y_KzTwFEg)5fPA#bmN4R;Gf}~Mp!RLNCAl^0MQH`7+^R{FL369)H14=F~s75G#+QY9Nr|FwaOi?NBtxU~5x2|G-W zrdC2*f@CmO?+;XH#}4CcN#MEGH`wAWR9Lx>(s+6g_bPy)5vea@}wCXdX1*6p6f1|m9=df3sMFkKKW_ASRWW0p1KS*5BD z7xy`}?E7mo2Ff=^Y+?W?xus?aIp0zppRno2Sfik<<9T-u+B#Do_#}3UMCz{={2?<}o1smtm2Wctj1<4~Q(LyuC z+Fc$>FONIgGLXA!k9Cv#-ig-USf-Ov_V&_>IrnULKLGEq5A^PfCI@oJVPs)MLS1-! z>Fh;}J=#bjp}h~=fT32wV|>w`m2}5_@dD z`uNxQ_{7uj;MpF&dDzS9Z}ABVzPcue-Do|(!$(XK_TBIC%h51m(Ut#z-;U4ruH<_W zzs2t-%00*r_gy|Y#rMeEmEFq-UZNa-$!F*1)cg<{|7YEutpx;ccjvG8__RM>prIPu zmHYp%54TSGv&C1^CqFpcn)WA8cj=wJ)@vR)5l>vA?+1>?-Ms@xPE6+ESGw;!!W#W+ ri1Byv`#cr|muNUzu}GrCo{X~x^+jU5@hD*i^-}(m`~Kv<+2;QR-xzTB 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/35] 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 cabe2e226..601b3b15a 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 4458bb67c..28aa4e8bd 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 3b6874e17..000000000 --- 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 266266109..620b80890 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 13040bc21..8dec1e505 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 95b699310..95077f01d 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 fd11583fd..746d7e7be 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 e1c845d58..3fae0a5e2 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 64bf61538..637e7897a 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 a427e88ab..af353b07c 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 e70be9514..320c560b2 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 34e046428..bd51be99c 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 a9a99b816..7ee4b6e50 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/35] 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 29a071fb6..920f99e48 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 713ed7404..c72b1809f 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 d44ec1daa..7db47cdfe 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 8dec1e505..a1b08ac84 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 60b713809..3d13a0e39 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 c48c2ca37..e76cbaf33 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 6d3603a93..07c944d78 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 c97cfc117..5453fbb85 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 95077f01d..01b2d923c 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 746d7e7be..7b62dfe75 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 3fae0a5e2..da9fedd0e 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 7ee4b6e50..f05ebb558 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/35] 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 3d13a0e39..0b015b5bd 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 35c0292b1..42dca2104 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 01b2d923c..9ec823f34 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 c8ba2611b..75e549a2f 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 1f597ff1d..63d7515d6 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 2708a906f..5a4dde043 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/35] 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 000000000..3b6874e17 --- /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 0b015b5bd..9359cb50d 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 7b62dfe75..92f0764ac 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 da9fedd0e..0c5852e41 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 aa3f3799d..a3074048e 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 158dd12f2..1f47c85bf 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 13075f6bf..ad8769c98 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 813ecef41..9d50a3359 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 f688f5ede..c2b44148f 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/35] 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 6a494b065..b18cab4fe 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 1bdbccea2..7acc3441b 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 26c75aa62..6c30b1780 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/35] 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 e4d097592..25c239f60 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/35] 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 26c75aa62..6c30b1780 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/35] 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 920f99e48..5d0714d71 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 fe16f7485..a98814b93 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 25c239f60..a213f54d7 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 d678f091f..b00c28a45 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/35] 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 6c30b1780..dfbef2875 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/35] 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 dfbef2875..aaf409d21 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 5d0714d71..3a9478791 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 a213f54d7..7cd21724c 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 b00c28a45..dc2291b0f 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/35] 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 3a9478791..6de95ba5f 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 7cd21724c..b6f1ad348 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/35] 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 af0ad7add..3f2e0f005 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 4befc7fe8..f0ef86ee6 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 0e96ef910..ffab348ef 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 a1202e272..bcf0d834a 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 5b23fd4b2..26d5ddd94 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 cf2da454a..21ba6193b 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 a1b08ac84..758c2660f 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 b6f1ad348..2ed3ca6cc 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 a8e721a51..0774f2705 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 42e1df77c..000000000 --- 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 af353b07c..5c700d81a 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 320c560b2..1a37ae5a5 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 f05ebb558..2bdd35a93 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/35] 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 758c2660f..7fc6e68fa 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/35] 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 30c556767..4ba5ec08d 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 1a51a671c..933e23ef6 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 e0c3db605..45164c615 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 ee561f8ae..d1e8ccc54 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 a98814b93..c012751da 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 7e2bca0fe..270dfe1db 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 b6766d168..cd026bd0f 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 434bd5f39..6d27a071f 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 a3074048e..f839ab1a5 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 1f47c85bf..1b33b210b 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 823d0f68b..c9e9e8265 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 25ae3ece0..5839b8e3c 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 2e52f212c..32f510b9b 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 8cf17123e..ec0bc6343 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 69287bcd4..01bf6769b 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 cd1d3aae8..55f53b197 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 60fc685f3..a190984d9 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 ad8769c98..5037f8cb3 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 9d50a3359..3de1003b2 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 c2b44148f..234d9a51c 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 1110155c9..38b16fb7f 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 216cd45f3..b08c5da9c 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 5f78c3af0..7fcbad818 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 a42fc6893..5165ea55a 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/35] 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 9dc237bb0..d19caf60b 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 b18cab4fe..bb360b083 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 4ba5ec08d..656f335cb 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 35f26bc06..b6c65785c 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 3f2e0f005..e5b62cd41 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 000000000..c2f256649 --- /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 7ffde321b..1547c917c 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 000000000..c32ca3e55 --- /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 000000000..bf343c441 --- /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 7921ce413..df33e1796 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 db388e283..82d68b6e1 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 9951a3e04..5eee6a154 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 4789d917b..47e436ccd 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 5eb7c7a61..a7aa13872 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 7560a4b71..000000000 --- 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 000000000..03f9f8912 --- /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 000000000..aef8b144f --- /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 aaf409d21..a2664097b 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 000000000..9c2bcc810 --- /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 000000000..9db0de123 --- /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 000000000..271139ba8 --- /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