diff --git a/Cargo.lock b/Cargo.lock index 82943a36..5cc58275 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", @@ -3668,6 +4432,7 @@ dependencies = [ "serde_json", "subxt-lightclient", "thiserror 2.0.18", + "tokio-util", "tracing", "url", "wasm-bindgen-futures", @@ -3711,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" @@ -3790,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" @@ -3830,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", @@ -3868,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]] @@ -3914,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" @@ -3943,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]] @@ -3951,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]] @@ -3981,6 +4859,40 @@ 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", + "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" @@ -4001,6 +4913,7 @@ dependencies = [ "truapi", "unicode-normalization", "url", + "zeroize", ] [[package]] @@ -4012,6 +4925,7 @@ dependencies = [ "blake2b_simd", "console_error_panic_hook", "derive_more 2.1.1", + "frame-metadata", "futures", "futures-timer", "getrandom 0.2.17", @@ -4048,6 +4962,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" @@ -4056,7 +4996,7 @@ checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ "cfg-if", "digest 0.10.7", - "rand", + "rand 0.8.6", "static_assertions", ] @@ -4105,6 +5045,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" @@ -4145,6 +5102,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" @@ -4157,6 +5120,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" @@ -4206,7 +5175,7 @@ dependencies = [ "ark-serialize", "ark-std", "getrandom_or_panic", - "rand_core", + "rand_core 0.6.4", "rayon", "w3f-pcs", ] @@ -4238,6 +5207,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" @@ -4478,6 +5456,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" @@ -4487,6 +5499,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" @@ -4768,6 +5786,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" @@ -4775,7 +5810,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "serde", "zeroize", ] diff --git a/Makefile b/Makefile index 35f26bc0..890c8d78 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 @@ -55,6 +55,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 everything the headless-host e2e needs (truapi-host CLI + TS 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 diff --git a/README.md b/README.md index af0ad7ad..439a5205 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,13 @@ 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. Its signing-host command provides +a transcript-based terminal UI, slash commands such as `/whoami`, `/deeplink`, +and `/script`, plus 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 diff --git a/docs/issue-drafts/bulletin-preimage-in-core.md b/docs/issue-drafts/bulletin-preimage-in-core.md new file mode 100644 index 00000000..0f841b26 --- /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 + signer-bot, 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/explorer/diagnosis-reports/pairing-host-cli.md b/explorer/diagnosis-reports/pairing-host-cli.md new file mode 100644 index 00000000..c32ca3e5 --- /dev/null +++ b/explorer/diagnosis-reports/pairing-host-cli.md @@ -0,0 +1,68 @@ +## Truapi Pairing Host CLI Diagnosis + +| Method | Status | Details | +| --- | --- | --- | +| `Account/connection_status_subscribe` | ✅ | | +| `Account/get_account` | ✅ | | +| `Account/get_account_alias` | ✅ | | +| `Account/create_account_proof` | ✅ | | +| `Account/get_legacy_accounts` | ✅ | | +| `Account/get_user_id` | ✅ | | +| `Account/request_login` | ✅ | | +| `Chain/follow_head_subscribe` | ✅ | | +| `Chain/get_head_header` | ✅ | | +| `Chain/get_head_body` | ✅ | | +| `Chain/get_head_storage` | ✅ | | +| `Chain/call_head` | ✅ | | +| `Chain/unpin_head` | ✅ | | +| `Chain/continue_head` | ✅ | | +| `Chain/stop_head_operation` | ✅ | | +| `Chain/get_spec_genesis_hash` | ✅ | | +| `Chain/get_spec_chain_name` | ✅ | | +| `Chain/get_spec_properties` | ✅ | | +| `Chain/broadcast_transaction` | ✅ | | +| `Chain/stop_transaction` | ✅ | | +| `Chat/create_room` | ❌ | createRoom failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/register_bot` | ❌ | registerBot failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/list_subscribe` | ❌ | no elements in sequence | +| `Chat/post_message` | ❌ | postMessage failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/action_subscribe` | ❌ | no elements in sequence | +| `Chat/custom_message_render_subscribe` | ❌ | no elements in sequence | +| `Coin Payment/create_purse` | ❌ | createPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/query_purse` | ❌ | queryPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/rebalance_purse` | ❌ | Subscription interrupted | +| `Coin Payment/delete_purse` | ❌ | Subscription interrupted | +| `Coin Payment/create_receivable` | ❌ | createReceivable failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/create_cheque` | ❌ | createCheque failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/deposit` | ❌ | Subscription interrupted | +| `Coin Payment/refund` | ❌ | Subscription interrupted | +| `Coin Payment/listen_for_payment` | ❌ | Subscription interrupted | +| `Entropy/derive` | ✅ | | +| `Local Storage/read` | ✅ | | +| `Local Storage/write` | ✅ | | +| `Local Storage/clear` | ✅ | | +| `Notifications/send_push_notification` | ✅ | | +| `Notifications/cancel_push_notification` | ✅ | | +| `Payment/balance_subscribe` | ❌ | Subscription interrupted | +| `Payment/top_up` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Payment/request` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Payment/status_subscribe` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Permissions/request_device_permission` | ✅ | | +| `Permissions/request_remote_permission` | ✅ | | +| `Preimage/lookup_subscribe` | ✅ | | +| `Preimage/submit` | ✅ | | +| `Resource Allocation/request` | ✅ | | +| `Signing/create_transaction` | ✅ | | +| `Signing/create_transaction_with_legacy_account` | ✅ | | +| `Signing/sign_raw_with_legacy_account` | ✅ | | +| `Signing/sign_payload_with_legacy_account` | ✅ | | +| `Signing/sign_raw` | ✅ | | +| `Signing/sign_payload` | ✅ | | +| `Statement Store/subscribe` | ✅ | | +| `Statement Store/create_proof` | ✅ | | +| `Statement Store/submit` | ✅ | | +| `Statement Store/create_proof_authorized` | ✅ | | +| `System/handshake` | ✅ | | +| `System/feature_supported` | ✅ | | +| `System/navigate_to` | ✅ | | +| `Theme/subscribe` | ✅ | | diff --git a/explorer/diagnosis-reports/signing-host-cli.md b/explorer/diagnosis-reports/signing-host-cli.md new file mode 100644 index 00000000..bf343c44 --- /dev/null +++ b/explorer/diagnosis-reports/signing-host-cli.md @@ -0,0 +1,68 @@ +## Truapi Signing Host CLI Diagnosis + +| Method | Status | Details | +| --- | --- | --- | +| `Account/connection_status_subscribe` | ✅ | | +| `Account/get_account` | ✅ | | +| `Account/get_account_alias` | ✅ | | +| `Account/create_account_proof` | ✅ | | +| `Account/get_legacy_accounts` | ✅ | | +| `Account/get_user_id` | ✅ | | +| `Account/request_login` | ✅ | | +| `Chain/follow_head_subscribe` | ✅ | | +| `Chain/get_head_header` | ✅ | | +| `Chain/get_head_body` | ✅ | | +| `Chain/get_head_storage` | ✅ | | +| `Chain/call_head` | ✅ | | +| `Chain/unpin_head` | ✅ | | +| `Chain/continue_head` | ✅ | | +| `Chain/stop_head_operation` | ✅ | | +| `Chain/get_spec_genesis_hash` | ✅ | | +| `Chain/get_spec_chain_name` | ✅ | | +| `Chain/get_spec_properties` | ✅ | | +| `Chain/broadcast_transaction` | ✅ | | +| `Chain/stop_transaction` | ✅ | | +| `Chat/create_room` | ❌ | createRoom failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/register_bot` | ❌ | registerBot failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/list_subscribe` | ❌ | no elements in sequence | +| `Chat/post_message` | ❌ | postMessage failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/action_subscribe` | ❌ | no elements in sequence | +| `Chat/custom_message_render_subscribe` | ❌ | no elements in sequence | +| `Coin Payment/create_purse` | ❌ | createPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/query_purse` | ❌ | queryPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/rebalance_purse` | ❌ | Subscription interrupted | +| `Coin Payment/delete_purse` | ❌ | Subscription interrupted | +| `Coin Payment/create_receivable` | ❌ | createReceivable failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/create_cheque` | ❌ | createCheque failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/deposit` | ❌ | Subscription interrupted | +| `Coin Payment/refund` | ❌ | Subscription interrupted | +| `Coin Payment/listen_for_payment` | ❌ | Subscription interrupted | +| `Entropy/derive` | ✅ | | +| `Local Storage/read` | ✅ | | +| `Local Storage/write` | ✅ | | +| `Local Storage/clear` | ✅ | | +| `Notifications/send_push_notification` | ✅ | | +| `Notifications/cancel_push_notification` | ✅ | | +| `Payment/balance_subscribe` | ❌ | Subscription interrupted | +| `Payment/top_up` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Payment/request` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Payment/status_subscribe` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Permissions/request_device_permission` | ✅ | | +| `Permissions/request_remote_permission` | ✅ | | +| `Preimage/lookup_subscribe` | ✅ | | +| `Preimage/submit` | ✅ | | +| `Resource Allocation/request` | ✅ | | +| `Signing/create_transaction` | ✅ | | +| `Signing/create_transaction_with_legacy_account` | ✅ | | +| `Signing/sign_raw_with_legacy_account` | ✅ | | +| `Signing/sign_payload_with_legacy_account` | ✅ | | +| `Signing/sign_raw` | ✅ | | +| `Signing/sign_payload` | ✅ | | +| `Statement Store/subscribe` | ✅ | | +| `Statement Store/create_proof` | ✅ | | +| `Statement Store/submit` | ✅ | | +| `Statement Store/create_proof_authorized` | ✅ | | +| `System/handshake` | ✅ | | +| `System/feature_supported` | ✅ | | +| `System/navigate_to` | ✅ | | +| `Theme/subscribe` | ✅ | | diff --git a/playground/src/components/MethodView.tsx b/playground/src/components/MethodView.tsx index 9951a3e0..5eee6a15 100644 --- a/playground/src/components/MethodView.tsx +++ b/playground/src/components/MethodView.tsx @@ -9,7 +9,7 @@ import { methodTestId, revealInRail, serviceTestId } from "@/src/lib/rail"; import { services } from "@/src/lib/services"; import type { MethodInfo, ServiceInfo } from "@/src/lib/services"; -const CALL_TIMEOUT_MS = 30_000; +const CALL_TIMEOUT_MS = 45_000; const CARGO_DOC_BASE = process.env.NEXT_PUBLIC_CARGO_DOC_BASE ?? diff --git a/playground/tests/e2e/dotli/helpers/signer-bot.ts b/playground/tests/e2e/dotli/helpers/signer-bot.ts index 7560a4b7..e5df9105 100644 --- a/playground/tests/e2e/dotli/helpers/signer-bot.ts +++ b/playground/tests/e2e/dotli/helpers/signer-bot.ts @@ -5,11 +5,12 @@ 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. +// Per-attempt request timeout. First-time pair can include user creation, +// People-chain attestation, and V2 pairing-time device allowance finalization. +// 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", + process.env.SIGNER_BOT_PAIR_TIMEOUT_MS ?? "240000", ); const HEALTH_REQUEST_TIMEOUT_MS = 5_000; @@ -115,6 +116,7 @@ async function fetchTextRetry( ); } catch (e) { last = e; + if ((e as Error).name === "AbortError") throw e; if (i === attempts) throw e; console.warn( `[bot] ${init.method ?? "GET"} ${url} threw "${(e as Error).message}" (attempt ${i}/${attempts})`, @@ -160,9 +162,9 @@ export interface PairResult { * * 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. + * People chain so it has Statement Store allowance, (c) submits V2 + * pairing-time device allowance, (d) completes the SSO handshake, and + * (e) starts auto-signing future SignRequests for that session. * * `network` should match dot.li's default network (`paseo-next-v2` at time * of writing). The bot's `/api/networks` endpoint lists supported IDs. diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 26c75aa6..d0d2b54d 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -129,7 +129,11 @@ export type CoreStorageKey = /** * Last processed SSO pairing response statement for the pairing device. */ - | { tag: "LastProcessedPairingStatement"; value?: undefined }; + | { tag: "LastProcessedPairingStatement"; value?: undefined } + /** + * Statement-store allowance targets the signing host keeps renewed. + */ + | { tag: "StatementRenewalTargets"; value?: undefined }; /** * Review shown before a product creates a ring-VRF proof (RFC 0004). @@ -373,6 +377,7 @@ export const CoreStorageKey: S.Codec = S.lazy( sessionId: string; }>, LastProcessedPairingStatement: S._void, + StatementRenewalTargets: S._void, }), ); diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml new file mode 100644 index 00000000..3ef9fe91 --- /dev/null +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -0,0 +1,48 @@ +[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" +shlex = "1" +subxt-rpcs = { version = "0.50.1", default-features = false, features = ["jsonrpsee", "native"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "io-std", "net", "time", "signal", "process"] } +tokio-stream = { version = "0.1", features = ["sync"] } +tokio-tungstenite = { version = "0.24", features = ["connect", "rustls-tls-webpki-roots"] } +# TLS is compiled in for required People/Bulletin host traffic and optional +# `Chain/*` playground methods. Product live-chain routing is opt-in +# (`E2E_LIVE_CHAIN=1`); required host routes remain enabled. See src/chain.rs. +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +unicode-width = "0.2" + +[dev-dependencies] +tempfile = "3" diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md new file mode 100644 index 00000000..e8f45eed --- /dev/null +++ b/rust/crates/truapi-host-cli/README.md @@ -0,0 +1,323 @@ +# 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 implementation contract, completion criteria, +and agent work packages. + +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`, `pairing-host` keeps its line +prompt while `signing-host` opens 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. | + +## Quick start + +```bash +make headless install # build deps + install truapi-host (once) +rust/crates/truapi-host-cli/e2e/run.sh # generate pairing-host-cli.md end-to-end +rust/crates/truapi-host-cli/e2e/run.sh path/to/my-script.ts # or a custom script +``` + +`run.sh` starts a pairing host running the product script, hands the emitted +pairing deeplink to a signing host, and exits with the script's status. The +signing host 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. Override the product with +`PRODUCT_ID=...` and the pairing frame port with `FRAME=...`. + +### 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 | +| --- | --- | +| `/deeplink ` | Validate and answer a `polkadotapp://pair?...` deeplink (signing host). | +| `/script` | Open a new TypeScript scratch script in the terminal editor, then run it. | +| `/script ` | Run an existing JS/TS product script through the public frame endpoint. | +| `/log ` | Change tracing to `error`, `warn`, `info`, `debug`, or `trace`. | +| `/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 signing-host 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. + +Both `pairing-host` and `signing-host` use the same interactive UI and command +bar. It uses a quiet, command-centered transcript: submitted +commands have a cyan rail, script stdout keeps the terminal's normal foreground, +stderr has a small error gutter, and lifecycle work updates sentence-case +status rows in place. Pairing state stays visible in the compact header. A +borderless, subtly backgrounded composer anchors autocomplete, the `›` prompt, +and contextual key hints at the bottom while keeping the native cursor after +the input. 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. + +Bare `/script` 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. + +Named sessions isolate signer accounts, product/core storage, and permissions +under +`//signing-host/sessions/`. The selected name is +remembered per network and shown in the top bar. `default` preserves the +pre-session account and storage locations for backward compatibility. 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 is cached in `session.json` inside the displayed +session path. On restart, an already-provisioned local signer is activated from +disk without an identity-backend or ring-membership round trip, so `/session` +can report `user.id` immediately. A session with no signer yet reports +``; inspecting it never starts network onboarding. + +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 '/deeplink 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 two 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. + +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 +`truapi-playground.dot`) scopes product-owned APIs like `truapi.localStorage.*` +and the accounts `host.productAccount()` returns. + +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.sh` exercises the paired + topology and therefore generates the pairing-host report: + + ```bash + rust/crates/truapi-host-cli/e2e/run.sh + ``` + + Generate the direct signing-host report separately: + + ```bash + target/debug/truapi-host signing-host \ + --product-id truapi-playground.dot \ + --script rust/crates/truapi-host-cli/js/scripts/battery.ts \ + --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. `run.sh` passes `--auto-accept` to both +for unattended runs. Every auto-approved decision is still printed. + +## Logging + +Use the global `--log-level` option (`error`, `warn`, `info`, `debug`, or +`trace`) before or after the subcommand, or `/log ` in the terminal UI. +Every decoded inbound SSO request and every published response is visible +regardless of the selected level. Stable response entries include the request +name, statement and remote message ids, protocol outcome, and elapsed time; +encoded protocol errors include their reason. Response-publication failures +are shown separately. `debug` adds decoded request/response summaries and +`trace` adds complete payload and transport metadata. Undecodable requests are +warnings with the available identifiers so protocol-version mismatches can be +diagnosed. + +```bash +truapi-host signing-host --log-level trace --deeplink '' --auto-accept +``` + +Debug and trace output may contain product signing payloads. `RUST_LOG` takes +precedence at startup and remains available for module-specific filters, except +that the noisy `rustls` and `tungstenite::protocol` tracing targets are always +excluded from CLI log output. Without `RUST_LOG`, `--log-level` and `/log` +apply to TrUAPI targets while other third-party dependencies remain at `warn`. + +## Statement-store allowance + +The real statement store enforces per-account allowance. Before pairing, the +signing host grants it on-chain exactly as a real client does: it proves its +LitePeople ring membership with a bandersnatch ring-VRF and submits an unsigned +General (v5) `Resources.set_statement_store_account` extrinsic for each account +that submits statements — its own `//wallet//sso` account and the pairing host's +per-pairing device key. The shared native implementation lives in +`truapi-server/src/runtime/statement_allowance/` (metadata-driven +signed-extension encoding, ring fetch, slot scan, ring-VRF proof, extrinsic +assembly, submit). The signing account must be an attested LitePeople member, +and may sit in an old ring, so the signing host scans back from the current ring +index (slow, one-time per pairing). Auto-managed accounts are stored in +`accounts.json` under `--base-path`; mnemonics are plaintext local test secrets +and the file is written with `0600` permissions on Unix. `alloc-check` verifies +membership and can submit a test registration. + +## Manual use (two terminals) + +```bash +make headless install + +# Terminal 1 — pairing host runs a product script and prints its pairing link: +truapi-host pairing-host --product-id myapp.dot --script js/scripts/battery.ts --auto-accept + +# Terminal 2 — hand the deeplink to a signing host (registers allowance, signs). +# The wallet mnemonic comes from --mnemonic / $HOST_CLI_SIGNER_MNEMONIC when set; +# otherwise the CLI auto-selects or creates an attested account. +truapi-host signing-host --deeplink '' --auto-accept +HOST_CLI_SIGNER_MNEMONIC="spin battle …" truapi-host signing-host --deeplink '' --auto-accept + +# Inspect on-chain statement-store allowance for a mnemonic: +truapi-host alloc-check --mnemonic "spin battle …" --lookback 100 +``` + +Both hosts take `--network` (default `paseo-next-v2`). The network preset owns +the identity backend URL, People RPC, Bulletin RPC, and genesis hashes; there is +no public `--statement-store` flag. + +## Scope / gaps + +- **Chain methods** route to real `wss://` nodes from the selected `--network` + when `E2E_LIVE_CHAIN=1`; off by default. A rustls crypto provider is + installed at startup for the TLS connections. +- **Ring-VRF product-account aliases and proofs** are implemented by the + signing host via the `verifiable` crate (`get_account_alias` and + `create_account_proof`). +- **`get_user_id`** resolves the signing account's username from People-chain + `Resources.Consumers`. Auto-managed signing accounts register fresh lite + usernames via the identity backend (`src/attestation.rs`); first registration + is backend-async and can take minutes (ring onboarding). `truapi-host + identity-check --mnemonic ` probes which derivation carries a username. +- `set_statement_store_account` and Bulletin long-term-storage resource + allocation are implemented over SSO on native headless hosts. +- Everything else the browser host exercises passes: signing (raw, payload, + create-transaction, and their legacy variants), statement store, entropy, + aliases, preimage, storage, permissions, notifications, theme, system, chain + (with `E2E_LIVE_CHAIN=1`), and user id, subject to live chain availability + and allowance-slot capacity. diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md new file mode 100644 index 00000000..5b9e82d5 --- /dev/null +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -0,0 +1,927 @@ +# Headless Host CLI specification + +Status: implementation contract for the `headless-host` worktree +Target: `truapi-host` v0.1 +Reference implementation: `rust/crates/truapi-host-cli/` +Protocol source of truth: `truapi` and `truapi-server` + +This document defines the product and engineering contract for completing the +native headless TrUAPI host CLI. The crate README is the user guide; this file +is the implementation and acceptance specification. If code, help text, tests, +and this document disagree, an agent must resolve the disagreement explicitly +rather than silently preserving both behaviors. + +The key words **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are normative. + +## 1. Product definition + +`truapi-host` is a native developer tool for running real TrUAPI host roles +without a browser UI or phone automation service. It is intended for local +development, protocol diagnosis, and end-to-end product tests. + +The CLI runs the real `truapi-server` dispatcher, services, signing logic, SSO +logic, and protocol frames. It replaces only the operating-system seam with a +CLI platform and adds process orchestration around it. + +The primary success case is: + +1. A seedless pairing host serves a product and publishes a Polkadot Mobile + pairing deeplink. +2. A wallet-local signing host answers that deeplink over the real SSO + transport. +3. A product script calls the public `@parity/truapi` client exactly as a real + product would. +4. The script exit status is the test result. + +The CLI is experimental test infrastructure. It is not a production wallet, +general-purpose key manager, mock host, or replacement for dot.li. + +## 2. Goals and non-goals + +### 2.1 Goals + +The v0.1 CLI MUST: + +- exercise the real Rust core and SCALE protocol frame path; +- support both paired and direct signing-host product execution; +- pair over the selected network's real Statement Store; +- manage a reusable local signer without requiring an external signing bot; +- expose safe interactive approvals and explicit unattended auto-approval; +- provide persistent, isolated signing-host sessions; +- run JS or TS product scripts and faithfully return their status; +- provide enough stable machine output for shell and CI orchestration; +- terminate predictably under errors, timeouts, cancellation, and signals; +- be installable and runnable without retaining the repository checkout; and +- have deterministic local tests plus a separately identified live-network + acceptance suite. + +### 2.2 Non-goals + +The v0.1 CLI MUST NOT: + +- redefine protocol payloads, wire ids, signing rules, SSO messages, allowance + proofs, or Bulletin submission logic; +- add CLI-only cryptographic implementations when the behavior belongs in + `truapi-server`; +- present itself as secure custody for production funds; +- make Chat, Coin Payment, or Payment services work without their real host + backends; +- accept arbitrary RPC URLs as a normal user-facing configuration surface; +- emulate push delivery or navigation beyond reporting the host action; or +- make live-network tests part of the deterministic unit-test contract. + +### 2.3 Implementation baseline + +This status snapshot is based on worktree commit `c19c22ab`. Agents must update +it when a work package lands. + +Implemented and expected to be preserved: + +- [x] four top-level commands and the current flag-conflict validation; +- [x] real paired and direct signing-host runtimes; +- [x] real People-chain SSO, Statement Store allowance, and Bulletin flows; +- [x] auto-managed signer creation, onboarding, caching, and slot rotation; +- [x] signing-host TUI, slash commands, serialized approvals, and plain `exec`; +- [x] persistent named sessions and runtime replacement on session switch; +- [x] durable scratch scripts opened through the configured editor; +- [x] product scripts with injected `truapi` and `host` globals; and +- [x] SSO request/response outcome reporting. + +Known completion work: + +- [ ] define the documented Make build/install targets; +- [ ] package a relocatable script runner and client dependencies; +- [ ] add named deadlines, setup-time cancellation, and signal-driven cleanup; +- [ ] add session-level locking and atomic restrictive storage writes; +- [ ] bound frame/chain queues and enforce binary product frames; +- [ ] add structured automation output and centralized redaction; +- [ ] expand deterministic process and transport tests; +- [ ] harden the live E2E harness and CI artifact handling; and +- [ ] reconcile stale README/help statements, including removed `/whoami` + references. + +## 3. Ownership boundaries + +### 3.1 `truapi-server` owns + +- protocol dispatch and frame encoding; +- product, pairing, signing, and authorization semantics; +- SSO request and response encoding; +- product-account derivation and signing; +- ring-VRF aliases and proofs; +- Statement Store and Bulletin allowance proof/extrinsic logic; +- Bulletin preimage construction, submission, and lookup; +- subscription lifecycle rules; and +- reusable runtime errors. + +Logic in this list MUST be fixed or extended in `truapi-server`, then consumed +by the CLI. It MUST NOT be duplicated in `truapi-host-cli`. + +### 3.2 `truapi-host-cli` owns + +- command-line parsing and validation; +- network presets; +- process, task, timeout, cancellation, and signal orchestration; +- terminal UI and plain `exec` output; +- approval prompts and `--auto-accept` policy; +- local state paths, locking, persistence, and file permissions; +- auto-managed test signer selection and onboarding orchestration; +- the product-frame WebSocket listener; +- product-script packaging and execution; and +- CLI-specific diagnostics and exit codes. + +### 3.3 Product scripts own + +- the sequence of public TrUAPI calls under test; +- assertions over typed protocol results; +- test-specific output; and +- success or failure through normal process completion or a thrown error. + +A script MUST NOT reach into the Rust runtime, session files, or private CLI +state to make an assertion pass. + +## 4. Runtime topologies + +### 4.1 Paired topology + +```text +product script + │ binary SCALE frames over loopback WebSocket + ▼ +pairing-host ── People-chain Statement Store / SSO ── signing-host + │ │ + └── seedless product/core state └── signer + wallet state +``` + +The pairing host MUST own the product-facing session. The signing host MUST own +wallet entropy and answer SSO requests. Neither process may bypass the real SSO +request/response path for paired tests. + +### 4.2 Direct signing-host topology + +```text +product script + │ binary SCALE frames over loopback WebSocket + ▼ +signing-host ── selected network RPCs +``` + +This topology is for focused signing-host diagnosis. It MUST use the same +signing runtime services as the paired topology. Results that differ between +the direct and paired paths require an explicit test or documented capability +difference. + +## 5. Command surface + +One binary named `truapi-host` MUST expose these commands: + +| Command | Contract | +| --- | --- | +| `pairing-host` | Run the seedless, product-facing host. | +| `signing-host` | Run the wallet-local host and optional product endpoint. | +| `identity-check` | Diagnose the registered identity derived from a mnemonic. | +| `alloc-check` | Diagnose or submit Statement Store allowance registration. | + +Global options MUST be accepted before or after the subcommand. + +### 5.1 Global options + +| Option | Default | Requirement | +| --- | --- | --- | +| `--log-level ` | `info` | Accept `error`, `warn`, `info`, `debug`, `trace`. `TRUAPI_HOST_LOG` supplies the default. | +| `--output ` | `human` | Target completion item: accept `human` or `jsonl`; see section 13. Existing human output remains the default. | + +`RUST_LOG` MAY provide module-specific startup filtering. CLI filters MUST +always suppress protocol-noise targets known to flood ordinary operation, +including `rustls` and `tungstenite::protocol`. `/log` MAY replace the runtime +filter after startup. + +### 5.2 `pairing-host` + +| Option | Default | Requirement | +| --- | --- | --- | +| `--script ` | none | Run one product script and exit with its status. | +| `--product-id ` | `headless-playground.dot` | Scope frames, storage, permissions, and product accounts. Validate at startup. | +| `--frame-listen ` | `127.0.0.1:9955` | Product-frame listener. Port `0` MUST select an available port. | +| `--base-path ` | platform state dir | Root of CLI-managed state. `TRUAPI_HOST_BASE_PATH` supplies the default. | +| `--network ` | `paseo-next-v2` | Select a complete network preset. | +| `--auto-accept` | off | Approve confirmations without prompting and report each decision. | +| `--script-timeout ` | none | Target completion item: bound a one-shot script without changing interactive mode. | + +With `--script`, the command MUST start the frame listener before starting the +runner, keep the listener alive for the script lifetime, and return the script +status. Without `--script`, it MUST remain available for repeated script runs +until EOF, `quit`, `exit`, `q`, or a termination signal. + +The pairing-host interactive surface MAY remain a plain line REPL in v0.1. It +MUST support `script ` and MUST NOT claim to provide the signing-host TUI. + +### 5.3 `signing-host` + +| Option | Default | Requirement | +| --- | --- | --- | +| `--script ` | none | Run one direct product script and return its status. | +| `--product-id ` | `headless-playground.dot` | Product scope for direct scripts. Validate at startup. | +| `--deeplink ` | none | Validate and answer one pairing deeplink after initialization. | +| `--mnemonic ` | environment or none | Use an explicit ephemeral signer. `HOST_CLI_SIGNER_MNEMONIC` supplies it. | +| `--account ` | none | Use a named account in the default-session account store. | +| `--session ` | remembered session | Select or create a persistent session. | +| `--lite-username-prefix ` | session-derived | Prefix new auto-managed Lite usernames. | +| `--base-path ` | platform state dir | Root of accounts, sessions, scripts, and runtime state. | +| `--network ` | `paseo-next-v2` | Select identity, People, Bulletin, and chain configuration together. | +| `--frame-listen ` | `127.0.0.1:9956` | Direct product-frame listener. Port `0` MUST work. | +| `--auto-accept` | off | Auto-approve and report each decision. | +| `--script-timeout ` | none | Target completion item: bound a one-shot script without changing interactive mode. | + +Invocation validation MUST reject ambiguous ownership combinations before +starting network or filesystem onboarding: + +- `--script` with `exec`; +- `--mnemonic` with `--account`, `--session`, or + `--lite-username-prefix`; +- `--account` with `--session` or `--lite-username-prefix`; and +- invalid session names. + +When neither `--script` nor `exec` is present, stdin and stdout MUST both be +TTYs. Otherwise the command MUST exit with invocation status `2` and explain +how to use `exec` or `--script`. + +### 5.4 `signing-host exec` + +`truapi-host signing-host [OPTIONS] exec ''` MUST: + +- avoid raw mode, alternate screens, cursor control, and ANSI output; +- write command results to stdout and diagnostics to stderr; +- execute exactly one slash command; +- never wait indefinitely for an approval on non-TTY stdin; +- exit when that command and its owned cleanup complete; and +- use the same parser and business logic as the interactive TUI. + +### 5.5 `identity-check` + +`identity-check` MUST validate the BIP-39 mnemonic locally, derive the same +identity paths used by the signing runtime, query the selected People chain, +and print enough information to identify the derivation carrying a username. +It MUST NOT persist the supplied mnemonic. + +### 5.6 `alloc-check` + +`alloc-check` MUST report network runtime versions, genesis, ring membership, +the selected slot, and the registration outcome. `--submit` MUST require a +32-byte `--target`; the all-zero default is scan-only and MUST never be +submitted. Failures from the shared allowance implementation MUST retain their +specific reason. + +## 6. Signing-host terminal UI + +The signing-host TUI MUST provide a scrollable transcript and one command bar. +Background logs, SSO events, script output, commands, and approval requests +MUST enter the transcript rather than overwrite user input. + +Required slash commands: + +| Command | Behavior | +| --- | --- | +| `/deeplink ` | Validate and answer a Polkadot Mobile pairing URL. | +| `/script` | Create, edit, save, then run a session-local TS scratch script. | +| `/script ` | Run an existing JS/TS product script. | +| `/log ` | Change runtime tracing level. | +| `/session` | Show session name, path, and user id without provisioning it. | +| `/session ` | Prepare and atomically switch to a persistent session. | +| `/session --list` | List sessions for the active network and mark the current one. | +| `/help` | Show commands and keyboard controls. | +| `/clear` | Clear the visible transcript, not retained process state. | +| `/copy` | Copy the retained transcript. TUI only. | +| `/quit` | Shut down cleanly. | + +Required interaction rules: + +- Typing `/` opens completion. +- Up/Down selects completion; with the menu closed it navigates process-local + history. +- Tab accepts completion. +- `/script` completes filesystem paths; `/session` completes known sessions. +- Ctrl-U/Ctrl-D scroll by half a viewport; End resumes auto-follow. +- Esc closes completion or rejects the active approval. +- Ctrl-C clears input, cancels the active command, or exits when idle. +- Pairing deeplinks MUST NOT be retained in cross-process history. +- Only one operator command runs at once, but SSO traffic and approvals MUST + continue while it runs. +- Concurrent approvals MUST be serialized. +- Suspending the TUI for `$VISUAL`, `$EDITOR`, or the platform editor MUST + restore terminal state even when the editor fails. + +The bare `/script` template MUST use only the public `truapi` and `host` script +API. Scratch files are durable under the active session and MUST be retained +after editor or script failure. + +## 7. Product script contract + +The runner MUST execute a JS or TS ES module with these globals: + +```ts +declare const truapi: TrUApiClient; +declare const host: { + productId: string; + productAccount(index?: number): ProductAccountId; +}; +``` + +`truapi` MUST be the public generated client connected through the product +frame endpoint and scoped to `--product-id`. `host.productAccount()` MUST use +the same product id and default to derivation index `0`. + +The runner MUST: + +- connect before importing the user module; +- execute top-level module code; +- await a default export when it is a function; +- close its provider on success and failure; +- preserve script stdout and stderr; +- return `0` on success and nonzero on a thrown/rejected error; and +- kill the child when its owning CLI operation is cancelled. + +The public CLI artifact MUST contain a bundled runner and its TrUAPI client +dependencies. The default runner MUST NOT import files through +`CARGO_MANIFEST_DIR` or require the source repository to remain at its build +path. `TRUAPI_HOST_RUNNER` MAY remain as an explicit development override. +Using the packaged runner may require a documented Bun runtime, but all missing +runtime/asset errors MUST be detected before starting a live host flow. + +## 8. Pairing and signing lifecycle + +### 8.1 Pairing host states + +```text +starting -> frames-ready -> disconnected + -> pairing -> authenticating -> connected + \-> failed -> disconnected +``` + +Every state transition MUST be observable. A new login MAY replace a failed or +disconnected attempt. Disposal MUST stop subscriptions and release transport +resources. + +### 8.2 Signing host states + +```text +starting -> session-loaded -> signer-ready -> allowance-ready + \-> pairing-response-running -> completed +``` + +Reading or listing a session MUST stop at `session-loaded`; it MUST NOT trigger +identity registration or ring onboarding. A command that needs a signer MUST +resolve and activate it lazily. + +Before answering a deeplink, the signing host MUST register Statement Store +allowance for both submitting accounts: + +- the signing host's `//wallet//sso` account; and +- the pairing host's per-pairing device key. + +Pairing MUST NOT continue if allowance registration fails. If an auto-managed +account has no free slot for the active period, the CLI MUST mark that account +exhausted for the period, choose or create another account, activate it, and +retry within a bounded attempt count. Explicit mnemonic and named-account +modes MUST surface exhaustion without silently changing identity. + +Bulletin long-term storage allocation MUST use the real SSO/runtime path. It +MUST NOT be faked through CLI sentinel storage. + +### 8.3 Deadlines and cancellation + +All external waits MUST have named, testable deadlines and cancellation: + +- frame listener startup; +- script-runner connection; +- identity-backend requests; +- identity record visibility; +- ring-membership visibility; +- RPC connection and request; +- subscription acknowledgement; +- extrinsic submit acknowledgement and finality; +- allowance visibility; +- pairing response; and +- child script completion when a script timeout is configured. + +Cancellation MUST propagate through setup as well as steady-state operation. +Ctrl-C, `/quit`, process termination, session switching, or owner-task failure +MUST stop child processes, responders, subscriptions, frame connections, and +poll loops owned by that operation. Cancellation MUST NOT become effective +only after setup finishes. + +Timeout errors MUST identify the operation, endpoint or network, and elapsed +deadline without printing secrets. + +## 9. Sessions, accounts, and persistence + +### 9.1 Base path + +Default state root: + +1. `$TRUAPI_HOST_BASE_PATH` or `--base-path`; +2. `$XDG_STATE_HOME/truapi-host`; +3. `$HOME/.local/state/truapi-host`; or +4. `.truapi-host` when neither platform location is available. + +Relative explicit paths MUST be resolved to absolute paths once at startup. + +### 9.2 Layout + +The v0.1 layout is: + +```text +/ + accounts.json # default-session account pool + accounts.json.lock + / + pairing-host/ + product-storage.json + core-storage.json + signing-host/ # default session runtime state + current-session + session.json + product-storage.json + core-storage.json + scripts/ + sessions/ + / + accounts.json + accounts.json.lock + session.json + product-storage.json + core-storage.json + scripts/ +``` + +The `default` session preserves the root account pool and signing-host runtime +paths. A mnemonic-backed host is named `ephemeral`, MUST NOT allow session +switching, and MUST NOT write the mnemonic into a managed session. + +### 9.3 Session names and switching + +A persistent session name MUST: + +- contain 1 to 64 ASCII characters; +- begin with a lowercase letter or digit; +- contain only lowercase letters, digits, `.`, `_`, or `-`; and +- not be `.` or `..`. + +Switching MUST be transactional from the operator's perspective: + +1. validate and prepare the target path; +2. load or build the target runtime without disturbing the active one; +3. stop the old pairing responder; +4. atomically replace the runtime; +5. disconnect product sockets bound to the old runtime so clients reconnect; +6. persist `current-session`; and +7. update TUI status and completion data. + +A failed preparation MUST leave the prior session active. + +Only one process MAY mutate a named session at a time. Completion work MUST add +a session-scoped process lock and a clear contention error. Read-only +diagnostics MAY operate without the mutation lock when safe. + +### 9.4 Account resolution + +Signer selection order is normative: + +1. explicit mnemonic; +2. explicit named account; +3. an attested, non-exhausted account in the active session; or +4. a pending or newly generated auto-managed account. + +Auto-managed accounts MUST be network- and session-scoped, registered through +the configured identity backend, and verified for People-chain ring membership +before use. Newly generated Lite username prefixes come from the lowercase +letters in the session name, fall back to `session`, and use `headless` for the +default session unless explicitly overridden. + +### 9.5 Persistence safety + +- Files containing mnemonics or runtime authentication state MUST be `0600` on + Unix and use the closest platform equivalent elsewhere. +- Directories containing them SHOULD be user-only. +- Writes MUST use a temporary file, flush, atomic rename, and parent-directory + sync where supported. +- Account-store mutation MUST hold an inter-process lock. +- Product/core storage mutation MUST not lose updates through concurrent + writes. +- Corrupt state MUST produce a path-specific error; it MUST NOT be silently + replaced with empty authentication state. +- Mnemonics, signing payloads, and private transport material MUST be redacted + from normal logs and JSONL events. A full pairing deeplink may appear only in + its dedicated lifecycle marker/event because the companion process needs it. + +Plaintext mnemonics remain acceptable only because this is explicitly local +test state. The README and first account creation MUST warn about that boundary. + +## 10. Network configuration + +A network preset MUST define one coherent set of: + +- stable network id; +- identity-backend base URL; +- People RPC URL and genesis hash; +- Bulletin RPC URL and genesis hash; and +- allowed live-chain genesis-to-RPC routes. + +`paseo-next-v2` is the required v0.1 preset. Adding another preset MUST include +unit tests for literal genesis values, state namespace isolation, and endpoint +routing. + +There MUST be no ordinary `--statement-store` URL override. This prevents a +mixed configuration where SSO, identity, allowance, and product chain calls +refer to different networks. Test-only endpoint injection belongs in internal +configuration or test fixtures. + +Live product Chain routing SHOULD become an explicit discoverable CLI option. +Until then, `E2E_LIVE_CHAIN=1` is a test-runner switch. Disabled live routing +MUST return a clear unavailable/unsupported result; it MUST NOT silently route +an unknown genesis to the People endpoint. + +## 11. Product-frame transport + +The frame server MUST: + +- bind before advertising readiness; +- default to loopback; +- create one `ProductRuntime` per WebSocket connection; +- associate every connection with the validated configured product id; +- carry exactly one SCALE `ProtocolMessage` in each binary WebSocket message; +- reject text frames rather than treating UTF-8 text as protocol bytes; +- dispose the product runtime when the socket closes; +- disconnect old runtimes during session replacement; +- bound outbound buffering or apply backpressure; and +- stop accepting connections when the owner is cancelled. + +Binding a non-loopback address exposes an unauthenticated host capability and +MUST require an explicit unsafe opt-in plus a prominent warning. This behavior +is a completion item before non-loopback listening is documented. + +## 12. Capability behavior + +The diagnosis reports demonstrate protocol reachability, but a green row may +mean a well-typed unavailable result rather than a native implementation. The +v0.1 behavior contract is: + +| Capability | Headless-host behavior | +| --- | --- | +| Account/session | Real core state, SSO login, product accounts, username resolution, aliases, and proofs. | +| Signing | Real host-owned signing and transaction assembly, including supported legacy calls. | +| Statement Store | Real selected-network subscribe, proof, authorized proof, submit, and allowance. | +| Preimage/Bulletin | Real core construction, Bulletin submit/lookup, and SSO resource allocation. | +| Chain | Real preset RPC routing only when live routing is enabled; otherwise explicit unavailable behavior. | +| Entropy | Real signing-host derivation policy. | +| Product/core storage | Persistent, session-scoped local state. | +| Permissions/confirmation | Interactive deny-by-default prompt or reported `--auto-accept`. | +| Theme | A stable CLI theme value; dark is the v0.1 default. | +| Navigation | Report the requested URL; do not launch it implicitly. | +| Notifications | Return a typed unavailable result; do not pretend delivery occurred. | +| Feature support | Report actual CLI capabilities rather than blanket success. | +| Chat, Coin Payment, Payment | Typed unsupported/unavailable until a real backend exists. | + +The checked-in pairing and signing diagnosis reports MUST remain free of +unexpected failures. Skipped methods MUST correspond to deliberate capability +gaps, not crashes, hangs, undecodable frames, or missing host wiring. + +## 13. Output, events, and logging + +### 13.1 Human mode + +Human mode is the default. Outside the TUI: + +- command results and sentence-case lifecycle events go to stdout; +- diagnostics, warnings, approvals, and tracing go to stderr; +- sensitive values are redacted unless the value is the explicit operator + input or capability required for the workflow; and +- `exec` emits no ANSI control sequences. + +Streaming and TUI modes MUST share the same human wording and status symbols. +For example: + +```text +• Listening for product frames + +• Pairing link + +◌ Authenticating pairing +✓ Paired with +✓ Signing host ready +✓ Script finished +``` + +Events MUST be emitted once per corresponding transition and MUST not be +assembled by scraping tracing prose. Automation MAY extract the explicit +`polkadotapp://pair?...` URL, but MUST NOT depend on a second machine-only copy +of the lifecycle text. + +Inside the interactive TUI, those events use the same copy with richer layout. +Machine markers and repeated source labels such as `HOST ·` and `SCRIPT ·` +MUST NOT appear. Submitted commands MUST be visually grouped with +their output, script stdout MUST use the terminal's default foreground, and +pending pairing/onboarding work MUST update a keyed activity rather than append +one transcript row per poll. The command prompt MUST use the native terminal +cursor after the insertion point, including for wide Unicode and horizontally +scrolled input. Color MUST be supplemental, honor `NO_COLOR`, and remain +understandable through symbols and wording alone. + +### 13.2 JSONL mode + +Before v0.1 is treated as a stable CI tool, `--output jsonl` SHOULD emit one +versioned object per lifecycle event: + +```json +{"version":1,"event":"frames_listening","role":"pairing-host","url":"ws://127.0.0.1:9955"} +``` + +Every object MUST contain `version` and `event`; applicable objects SHOULD +contain `role`, `network`, `session`, `operation_id`, and `elapsed_ms`. Script +stdout/stderr MUST use explicit `script_stdout` and `script_stderr` events or a +documented passthrough mode. Mnemonics, signing payloads, and private transport +material MUST never appear in JSONL. The full deeplink is allowed only in the +dedicated `pairing_deeplink` event and consumers MUST treat captured output as +sensitive. + +### 13.3 SSO transcript + +Decoded inbound request names and response outcomes MUST remain visible at +normal verbosity. Stable response entries SHOULD include request name, +statement id, remote message id, outcome, and elapsed time. Encoded protocol +errors SHOULD include their reason. Full payload and transport metadata belong +only at `trace` and still require secret redaction. + +## 14. Approvals and security + +Without `--auto-accept`, sensitive actions MUST require an explicit affirmative +`y` or `yes`; EOF, invalid input, non-TTY input, Esc, and cancellation reject. +The prompt MUST identify the action and enough review detail to make the +decision meaningful. + +`--auto-accept` MUST: + +- be opt-in on every invocation; +- approve only actions that already pass core permission and scope checks; +- print or emit one decision record per approval; and +- never weaken product-id, session, or cross-product authorization. + +Same-product Ring-VRF behavior MUST match the signing runtime policy. +Cross-product requests MUST retain the appropriate confirmation path. + +The CLI MUST NOT print mnemonics. Tracing and general transcript entries MUST +NOT print complete pairing deeplinks or raw signing payloads. The one dedicated +pairing-deeplink marker/event is required for handoff and MUST be documented as +sensitive output. Help and docs MUST label explicit mnemonic use and +auto-managed account files as local test functionality. + +## 15. Exit codes and shutdown + +| Code | Meaning | +| --- | --- | +| `0` | Command or script completed successfully. | +| `1` | Runtime, network, state, approval, or script failure without a more specific child status. | +| `2` | Invalid invocation, missing required runtime/asset, or usage error. | +| child code | One-shot `--script` SHOULD preserve a normal child exit code when representable. | +| `128 + signal` | MAY be used for conventional signal termination on Unix. | + +Graceful shutdown MUST stop accepting product connections, cancel owned +operations, terminate child scripts, stop pairing responders, dispose product +runtimes, restore the terminal, flush durable state, and then exit. Shutdown +MUST itself have a short deadline; stuck cleanup may be forcefully aborted +after reporting what did not stop. + +`std::process::exit` SHOULD NOT bypass required cleanup or terminal restoration. + +## 16. Installation and packaging + +The supported developer flow MUST be real and tested: + +```sh +make headless +make install-headless +truapi-host --help +``` + +These target names are normative. README, help, CI, and this document MUST +agree. The existing prose `make headless install` is not an acceptable contract +because it names two unrelated Make targets and neither is defined in the +current worktree. + +An installed CLI MUST work after moving or deleting the source checkout. The +packaging test MUST run a product script from a temporary directory using only: + +- the installed `truapi-host` binary; +- documented runtime dependencies such as Bun; and +- the user-provided script. + +It MUST NOT resolve `js/runner.ts` or `@parity/truapi` through repository- +relative imports. Release artifacts SHOULD include checksums and the version +reported through `--version`. + +## 17. Test and acceptance specification + +### 17.1 Required deterministic checks + +Every implementation slice MUST pass the narrow tests it changes. The final +branch MUST pass: + +```sh +cargo +nightly fmt --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-features +cargo build -p truapi-host-cli --release +git diff --check +``` + +No deterministic test may depend on a public RPC, identity backend, real clock +period boundary, phone, signing bot, or pre-existing user state. + +### 17.2 Unit tests + +Unit coverage MUST include: + +- clap conflicts and defaults; +- slash-command and approval parsing; +- session-name validation and username-prefix derivation; +- session layout, current selection, identity cache, and failed-switch rollback; +- account selection, locking, atomic persistence, exhaustion, and permissions; +- network preset genesis and routing; +- frame transport binary-only behavior, disconnect, and reset; +- output marker/JSONL serialization and redaction; +- timeout and cancellation propagation; and +- editor/runner path handling without shell injection. + +### 17.3 Process-boundary tests + +Process tests MUST cover: + +- non-TTY interactive rejection with exit `2`; +- plain `exec /help`, `/session`, `/session --list`, and invalid commands; +- no ANSI in `exec` stdout or stderr; +- session restoration and isolated state; +- two-process session lock contention; +- child script success, thrown error, timeout, and Ctrl-C cleanup; +- missing Bun/runner failure before network onboarding; +- graceful SIGINT/SIGTERM behavior on supported platforms; and +- installation from a temporary prefix followed by source-checkout removal. + +### 17.4 Through-core integration tests + +Tests MUST send real encoded product frames through `ProductRuntime`; mocks may +replace only network/platform boundaries. Required cases: + +- product storage round trip and session isolation; +- permission accept/reject; +- direct signing-host product account flow; +- pairing state transitions and responder failure; +- runtime replacement disconnects stale product sockets; +- SSO request and response transcript outcomes; and +- cancellation during connection, subscription setup, and submit watch. + +### 17.5 Live acceptance tests + +Live tests are opt-in and MUST clearly identify the selected preset. They MUST +cover: + +1. `battery.ts` in paired mode, covering every generated playground example + and writing `explorer/diagnosis-reports/pairing-host-cli.md`; +2. `battery.ts` in direct signing-host mode, covering the same generated + examples and writing `explorer/diagnosis-reports/signing-host-cli.md`; +3. focused direct signing-host product-account flows; +4. focused signing, ring-VRF, and preimage scripts; +5. account onboarding and cached restart; +6. Statement Store slot exhaustion and auto-account rotation; and +7. one cancellation/restart recovery scenario. + +The two reports MUST be measured independently. The paired report exercises +remote SSO allocation and signing, while the direct signing-host report uses +the same native allocation and signing implementations locally. Their supported +method sets should agree; a changed method count MUST be reviewed against the +canonical generated API rather than updating the expected number blindly. + +Live failures MUST preserve logs and reports with secrets redacted. CI MAY mark +a known external network outage separately from a product failure, but it MUST +not convert a protocol assertion failure into a skip. + +## 18. Definition of done for v0.1 + +The CLI is complete when all of the following are true: + +- [ ] CLI help, crate README, root README, Make targets, and this spec describe + the same commands and slash-command surface. +- [ ] Product ids and incompatible flags fail before starting external work. +- [ ] `pairing-host`, `signing-host`, `exec`, and script modes satisfy their + lifecycle and exit-code contracts. +- [ ] The signing TUI restores the terminal under success, failure, + cancellation, editor use, and signals. +- [ ] Persistent sessions are isolated, locked, atomically switched, and safe + from concurrent mutation. +- [ ] Sensitive state uses atomic, restrictive persistence and logs are + redacted. +- [ ] Every external wait is bounded and cancellable during setup. +- [ ] Product-frame queues are bounded and text frames are rejected. +- [ ] The CLI installs with a bundled runner and operates without the source + checkout. +- [ ] Deterministic tests and process-boundary tests pass in CI. +- [ ] Paired and direct live acceptance runs have no unexpected diagnosis + failures. +- [ ] Unsupported capability results are deliberate, typed, and documented. +- [ ] No CLI-only workaround duplicates logic that belongs in `truapi-server`. + +## 19. Suggested agent work packages + +These packages are designed to minimize overlapping edits. Agents MUST rebase +or coordinate before touching a file owned by another active package. + +### A. Distribution and install contract + +Primary files: `Makefile`, `Cargo.toml`, `rust/crates/truapi-host-cli/js/`, +`script_runner.rs`, crate/root READMEs. + +Deliverables: + +- bundle the runner and generated client dependencies; +- add consistent build/install targets and `--version`; +- add the relocation acceptance test; and +- remove source-checkout assumptions from normal execution. + +### B. Lifecycle, deadlines, and cancellation + +Primary files: `main.rs`, `attestation.rs`, `accounts.rs`, `chain.rs`, and +shared runtime APIs only where cancellation must cross into `truapi-server`. + +Deliverables: + +- named deadline policy; +- cancellation tokens/ownership for startup and subscriptions; +- signal-aware graceful shutdown; +- child/responder cleanup; and +- deterministic timeout/cancellation tests. + +### C. State safety and session locking + +Primary files: `sessions.rs`, `accounts.rs`, `platform.rs`. + +Deliverables: + +- session process lock; +- atomic and restrictive product/core/session persistence; +- corruption errors instead of silent auth reset; +- failed-switch rollback tests; and +- concurrent writer tests. + +### D. Product-frame transport hardening + +Primary files: `frame_server.rs`, `chain.rs`, transport-focused tests. + +Deliverables: + +- binary-only frame enforcement; +- bounded queues/backpressure; +- cancellation-aware accept/connect loops; +- safe non-loopback policy; and +- disconnect/reset/lag tests. + +### E. Automation output and TUI conformance + +Primary files: `terminal_ui.rs`, `signing_shell.rs`, output helpers, process +tests. + +Deliverables: + +- centralized lifecycle events; +- optional versioned JSONL renderer; +- stable human markers and stream separation; +- redaction tests; +- cancellation and approval interaction tests; and +- removal of stale `/whoami` documentation. + +### F. Deterministic and live E2E harness + +Primary files: `e2e/`, `tests/`, `.github/workflows/`, diagnosis scripts and +reports. + +Deliverables: + +- replace unbounded polling and process-kill assumptions in `run.sh`; +- preserve artifacts and classify external outages; +- add direct and paired gates; +- add installation/relocation coverage; and +- document exact local and CI commands. + +### G. Capability audit + +Primary files: `platform.rs`, network/runtime integration, diagnosis scripts. + +Deliverables: + +- make unsupported capabilities return deliberate typed results; +- replace blanket feature reporting with actual support; +- expose live chain routing clearly and reject unknown genesis hashes; and +- keep paired/signing diagnosis reports aligned with the generated API. + +Package A should land before the final E2E packaging tests in F. Packages B, C, +D, E, and G can proceed independently when they avoid shared `main.rs` edits; +integration should centralize orchestration changes once their local APIs are +settled. diff --git a/rust/crates/truapi-host-cli/e2e/run.sh b/rust/crates/truapi-host-cli/e2e/run.sh new file mode 100755 index 00000000..f6dec3d4 --- /dev/null +++ b/rust/crates/truapi-host-cli/e2e/run.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Headless end-to-end run: a pairing host drives a product script against a +# signing host, pairing over the real People-chain statement store. +# +# make headless # build once +# e2e/run.sh # generates pairing-host-cli.md (default) +# e2e/run.sh path/to/script.ts # runs a custom product script +# +# Env: +# PRODUCT_ID product id the pairing host serves (default truapi-playground.dot) +# HOST_CLI_SIGNER_MNEMONIC optional wallet mnemonic; when unset, signing-host auto-manages one +# TRUAPI_HOST_BASE_PATH optional root for generated accounts and host state +# TRUAPI_PAIRING_BASE_PATH optional pairing-host state root; defaults to a fresh temporary root +# TRUAPI_E2E_LOAD_ENV set to 0 to ignore the gitignored e2e/.env (default 1) +# FRAME frame-server address (default 127.0.0.1:9955) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +BIN="$ROOT/target/debug/truapi-host" + +# Load HOST_CLI_SIGNER_MNEMONIC / TRUAPI_HOST_BASE_PATH (and any other vars) +# from a gitignored e2e/.env if present. +ENV_FILE="$(dirname "$0")/.env" +if [ "${TRUAPI_E2E_LOAD_ENV:-1}" = 1 ] && [ -f "$ENV_FILE" ]; then + set -a + . "$ENV_FILE" + set +a +fi + +SCRIPT="${1:-$ROOT/rust/crates/truapi-host-cli/js/scripts/battery.ts}" +PRODUCT_ID="${PRODUCT_ID:-truapi-playground.dot}" +FRAME="${FRAME:-127.0.0.1:9955}" + +PAIRING_BASE_PATH_OWNED=0 +if [ -n "${TRUAPI_PAIRING_BASE_PATH:-}" ]; then + PAIRING_BASE_PATH="$TRUAPI_PAIRING_BASE_PATH" +else + PAIRING_BASE_PATH="$(mktemp -d /tmp/truapi-e2e-pairing.XXXXXX)" + PAIRING_BASE_PATH_OWNED=1 +fi + +[ -x "$BIN" ] || { echo "missing $BIN — run: make headless" >&2; exit 2; } + +LOG="$(mktemp)" +SIGNER_PID="" +PAIR_PID="" +stop_pairing_host() { + [ -n "$PAIR_PID" ] || return 0 + pkill -TERM -P "$PAIR_PID" 2>/dev/null || true + kill -TERM "$PAIR_PID" 2>/dev/null || true + sleep 0.5 + pkill -KILL -P "$PAIR_PID" 2>/dev/null || true + kill -KILL "$PAIR_PID" 2>/dev/null || true +} +cleanup() { + [ -n "$SIGNER_PID" ] && kill "$SIGNER_PID" 2>/dev/null || true + stop_pairing_host + rm -f "$LOG" + if [ "$PAIRING_BASE_PATH_OWNED" -eq 1 ]; then + rm -rf -- "$PAIRING_BASE_PATH" + fi +} +trap cleanup EXIT + +# The pairing host runs the product script; the script's +# `truapi.account.requestLogin` makes the host emit a pairing deeplink, which we +# hand to a signing host. The pairing host exits with the script's status. +"$BIN" pairing-host --product-id "$PRODUCT_ID" --script "$SCRIPT" \ + --frame-listen "$FRAME" --base-path "$PAIRING_BASE_PATH" \ + --auto-accept > >(tee "$LOG") 2>&1 & +PAIR_PID=$! + +deeplink="" +for _ in $(seq 1 600); do + deeplink="$(grep -m1 -oE 'polkadotapp://pair\?handshake=[[:xdigit:]]+' "$LOG" || true)" + [ -n "$deeplink" ] && break + kill -0 "$PAIR_PID" 2>/dev/null || break + sleep 0.5 +done +[ -n "$deeplink" ] || { echo "pairing host did not emit a deeplink" >&2; exit 1; } + +# The signing host reads HOST_CLI_SIGNER_MNEMONIC from the env when set. +# Otherwise it auto-selects or creates an attested account under its base path. +"$BIN" signing-host --auto-accept exec "/deeplink $deeplink" & +SIGNER_PID=$! + +pid_running() { + local stat + stat="$(ps -p "$1" -o stat= 2>/dev/null || true)" + [ -n "$stat" ] && [ "${stat#Z}" = "$stat" ] +} + +while :; do + if ! pid_running "$PAIR_PID"; then + wait "$PAIR_PID" + exit $? + fi + if ! pid_running "$SIGNER_PID"; then + stop_pairing_host + exit 1 + fi + sleep 0.5 +done diff --git a/rust/crates/truapi-host-cli/js/battery-reporter.ts b/rust/crates/truapi-host-cli/js/battery-reporter.ts new file mode 100644 index 00000000..187e4d11 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/battery-reporter.ts @@ -0,0 +1,135 @@ +import type { DiagnosisCase, DiagnosisRow } from "./diagnosis.ts"; + +type WriteLine = (line: string) => void; + +export class BatteryReporter { + readonly #write: WriteLine; + readonly #color: boolean; + #service: string | undefined; + + constructor( + write: WriteLine = (line) => console.log(line), + color = Boolean(process.stdout.isTTY && !process.env.NO_COLOR), + ) { + this.#write = write; + this.#color = color; + } + + start(plan: DiagnosisCase[]): void { + const services = new Set(plan.map((test) => test.serviceName)).size; + const serviceLabel = services === 1 ? "service" : "services"; + this.#write(""); + this.#write(this.#bold("TrUAPI generated-example battery")); + this.#write( + this.#dim( + `${plan.length} examples · ${services} ${serviceLabel} · generated manifest`, + ), + ); + } + + paired(value: unknown): void { + this.#write( + `${this.#green("✓")} Pairing ready ${this.#dim(`· ${String(value)}`)}`, + ); + } + + waitingForHost(milliseconds: number): void { + this.#write( + this.#dim( + `• Waiting ${formatDuration(milliseconds)} for signing-host readiness`, + ), + ); + } + + reportSaved(path: string): void { + this.#write(`${this.#green("✓")} Report saved ${this.#dim(`· ${path}`)}`); + } + + result(row: DiagnosisRow): void { + if (row.serviceName !== this.#service) { + this.#service = row.serviceName; + this.#write(""); + this.#write(this.#bold(row.serviceName)); + } + const duration = this.#dim(formatDuration(row.durationMs).padStart(8)); + const method = row.methodName.padEnd(42); + if (row.status === "pass") { + this.#write(` ${this.#green("✓")} ${method} ${duration}`); + return; + } + if (row.status === "skipped") { + this.#write(` ${this.#yellow("–")} ${method} ${duration}`); + this.#write(` ${this.#dim(cleanDetail(row.output))}`); + return; + } + this.#write(` ${this.#red("×")} ${method} ${duration}`); + this.#write(` ${this.#dim(cleanDetail(row.output))}`); + } + + finish(rows: DiagnosisRow[], elapsedMs: number): void { + const passed = rows.filter((row) => row.status === "pass").length; + const failed = rows.filter((row) => row.status === "fail"); + const skipped = rows.filter((row) => row.status === "skipped").length; + + this.#write(""); + this.#write(this.#bold("Summary")); + this.#write( + `${this.#green(`${passed} passed`)} · ${ + failed.length === 0 + ? this.#green("0 failed") + : this.#red(`${failed.length} failed`) + } · ${this.#yellow(`${skipped} skipped`)} · ${rows.length} total · ${formatDuration( + elapsedMs, + )}`, + ); + if (failed.length > 0) { + this.#write(""); + this.#write(this.#red(`Failed examples (${failed.length})`)); + for (const row of failed) this.#write(` - ${row.id}`); + } + } + + #bold(value: string): string { + return this.#style("1", value); + } + + #dim(value: string): string { + return this.#style("2", value); + } + + #green(value: string): string { + return this.#style("32", value); + } + + #yellow(value: string): string { + return this.#style("33", value); + } + + #red(value: string): string { + return this.#style("31", value); + } + + #style(code: string, value: string): string { + return this.#color ? `\u001b[${code}m${value}\u001b[0m` : value; + } +} + +export function formatDuration(milliseconds: number): string { + if (milliseconds < 1_000) return `${milliseconds} ms`; + if (milliseconds < 60_000) return `${(milliseconds / 1_000).toFixed(1)} s`; + const minutes = Math.floor(milliseconds / 60_000); + const seconds = Math.round((milliseconds % 60_000) / 1_000); + return `${minutes}m ${seconds}s`; +} + +export function cleanDetail(output: string): string { + const collapsed = output + .replace(/\u001b\[[0-9;]*m/g, "") + .replace(/\s+/g, " ") + .trim(); + if (collapsed.length === 0) return "no failure detail"; + if (collapsed.length <= 280) return collapsed; + const tailLength = Math.ceil(280 * 0.6); + const headLength = 280 - tailLength - 3; + return `${collapsed.slice(0, headLength)}...${collapsed.slice(-tailLength)}`; +} diff --git a/rust/crates/truapi-host-cli/js/diagnosis-report.ts b/rust/crates/truapi-host-cli/js/diagnosis-report.ts new file mode 100644 index 00000000..455effa5 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/diagnosis-report.ts @@ -0,0 +1,75 @@ +import type { DiagnosisRow } from "./diagnosis.ts"; + +export type CliHostRole = "pairing-host" | "signing-host"; + +export interface CliDiagnosisReportMetadata { + filename: "pairing-host-cli.md" | "signing-host-cli.md"; + title: + | "Truapi Pairing Host CLI Diagnosis" + | "Truapi Signing Host CLI Diagnosis"; +} + +/** Select the committed report identity from the host that serves the script. */ +export function cliDiagnosisReportMetadata( + role: string | undefined, +): CliDiagnosisReportMetadata { + switch (role) { + case "pairing-host": + return { + filename: "pairing-host-cli.md", + title: "Truapi Pairing Host CLI Diagnosis", + }; + case "signing-host": + return { + filename: "signing-host-cli.md", + title: "Truapi Signing Host CLI Diagnosis", + }; + default: + throw new Error( + `TRUAPI_CLI_HOST_ROLE must be pairing-host or signing-host; received ${JSON.stringify(role)}`, + ); + } +} + +/** Render the same Markdown matrix used by the playground diagnosis reports. */ +export function renderDiagnosisReport( + title: string, + rows: DiagnosisRow[], +): string { + return ( + [ + `## ${title}`, + "", + "| Method | Status | Details |", + "| --- | --- | --- |", + ...rows.map( + (row) => + `| \`${row.id}\` | ${statusIcon(row.status)} | ${ + row.status === "pass" ? "" : cleanMarkdownDetail(row.output) + } |`, + ), + ].join("\n") + "\n" + ); +} + +function statusIcon(status: DiagnosisRow["status"]): string { + if (status === "pass") return "✅"; + if (status === "skipped") return "⏭️"; + return "❌"; +} + +export function cleanMarkdownDetail(output: string): string { + const collapsed = output + .replace(/\u001b\[[0-9;]*m/g, "") + .replace(/\s+/g, " ") + .trim(); + const concise = truncateMiddle(collapsed, 300); + return concise.replaceAll("|", "\\|"); +} + +function truncateMiddle(value: string, limit: number): string { + if (value.length <= limit) return value; + const tailLength = Math.ceil(limit * 0.6); + const headLength = limit - tailLength - 3; + return `${value.slice(0, headLength)}...${value.slice(-tailLength)}`; +} diff --git a/rust/crates/truapi-host-cli/js/diagnosis.test.ts b/rust/crates/truapi-host-cli/js/diagnosis.test.ts new file mode 100644 index 00000000..ba08866f --- /dev/null +++ b/rust/crates/truapi-host-cli/js/diagnosis.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from "bun:test"; +import { services } from "../../../../js/packages/truapi/src/playground/codegen/services.ts"; +import { BatteryReporter } from "./battery-reporter.ts"; +import { + cliDiagnosisReportMetadata, + renderDiagnosisReport, +} from "./diagnosis-report.ts"; +import { + createDiagnosisPlan, + type DiagnosisCase, + type DiagnosisRow, +} from "./diagnosis.ts"; + +describe("generated-example battery", () => { + test("uses a distinct committed report for each CLI host role", () => { + expect(cliDiagnosisReportMetadata("pairing-host")).toEqual({ + filename: "pairing-host-cli.md", + title: "Truapi Pairing Host CLI Diagnosis", + }); + expect(cliDiagnosisReportMetadata("signing-host")).toEqual({ + filename: "signing-host-cli.md", + title: "Truapi Signing Host CLI Diagnosis", + }); + expect(() => cliDiagnosisReportMetadata(undefined)).toThrow( + "TRUAPI_CLI_HOST_ROLE must be pairing-host or signing-host", + ); + }); + + test("derives every case from the generated playground manifest", () => { + const generatedIds = services.flatMap((service) => + service.methods.map((method) => `${service.name}/${method.name}`), + ); + const plan = createDiagnosisPlan({ runKnownUnsupported: true }); + + expect(plan.map((testCase) => testCase.id)).toEqual(generatedIds); + expect(plan.every((testCase) => testCase.exampleSource)).toBe(true); + expect(plan.every((testCase) => testCase.skipReason === undefined)).toBe( + true, + ); + }); + + test("prints failures as concise test-reporter rows", () => { + const output: string[] = []; + const reporter = new BatteryReporter((line) => output.push(line), false); + const plan: DiagnosisCase[] = [ + { + id: "Account/get_user_id", + serviceName: "Account", + methodName: "get_user_id", + exampleSource: "example", + }, + { + id: "Account/get_account_alias", + serviceName: "Account", + methodName: "get_account_alias", + exampleSource: "example", + }, + ]; + const rows: DiagnosisRow[] = [ + row(plan[0], "pass", "ok", 12), + row( + plan[1], + "fail", + "account alias: partial output\ngetAccountAlias failed: PermissionDenied", + 901, + ), + ]; + + reporter.start(plan); + reporter.paired("Success"); + reporter.waitingForHost(3_000); + rows.forEach((result) => reporter.result(result)); + reporter.finish(rows, 1_200); + reporter.reportSaved("/tmp/cli.md"); + + const report = output.join("\n"); + expect(report).toContain("2 examples · 1 service · generated manifest"); + expect(report).toContain("✓ get_user_id"); + expect(report).toContain("× get_account_alias"); + expect(report).toContain("Waiting 3.0 s for signing-host readiness"); + expect(report).toContain("getAccountAlias failed: PermissionDenied"); + expect(report).toContain( + "1 passed · 1 failed · 0 skipped · 2 total · 1.2 s", + ); + expect(report).toContain("- Account/get_account_alias"); + expect(report).toContain("Report saved · /tmp/cli.md"); + expect(report).not.toContain("\u001b["); + }); + + test("renders the browser diagnosis Markdown shape", () => { + const report = renderDiagnosisReport("Truapi CLI Diagnosis", [ + { + id: "Account/get_user_id", + serviceName: "Account", + methodName: "get_user_id", + status: "pass", + output: "ok", + durationMs: 12, + }, + { + id: "Chain/stop_transaction", + serviceName: "Chain", + methodName: "stop_transaction", + status: "fail", + output: "\u001b[31mInvalid operation | -32602\u001b[0m", + durationMs: 901, + }, + ]); + + expect(report).toBe( + "## Truapi CLI Diagnosis\n\n" + + "| Method | Status | Details |\n" + + "| --- | --- | --- |\n" + + "| `Account/get_user_id` | ✅ | |\n" + + "| `Chain/stop_transaction` | ❌ | Invalid operation \\| -32602 |\n", + ); + }); + + test("keeps the root cause when failure setup output is long", () => { + const setup = `submitting statement: ${"a".repeat(400)}`; + const rootCause = "subscription failed: statement expired"; + const output = `${setup}\n${rootCause}`; + const lines: string[] = []; + + new BatteryReporter((line) => lines.push(line), false).result({ + id: "Statement Store/subscribe", + serviceName: "Statement Store", + methodName: "subscribe", + status: "fail", + output, + durationMs: 10, + }); + const report = renderDiagnosisReport("Truapi CLI Diagnosis", [ + { + id: "Statement Store/subscribe", + serviceName: "Statement Store", + methodName: "subscribe", + status: "fail", + output, + durationMs: 10, + }, + ]); + + expect(lines.join("\n")).toContain(rootCause); + expect(report).toContain(rootCause); + }); +}); + +function row( + testCase: DiagnosisCase, + status: DiagnosisRow["status"], + output: string, + durationMs: number, +): DiagnosisRow { + return { + id: testCase.id, + serviceName: testCase.serviceName, + methodName: testCase.methodName, + status, + output, + durationMs, + }; +} diff --git a/rust/crates/truapi-host-cli/js/diagnosis.ts b/rust/crates/truapi-host-cli/js/diagnosis.ts new file mode 100644 index 00000000..cd9d10b0 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/diagnosis.ts @@ -0,0 +1,173 @@ +// Runs the playground's own generated example sources against a headless +// pairing host, using the playground's `runExample` so these are literally the +// tests the playground diagnosis runs. Pass/fail is decided by the example +// body (it resolves on success, throws via `assert` on failure), exactly as in +// `playground/src/lib/auto-test.ts`. +import { + runExample, + type LogEntry, +} from "../../../../playground/src/lib/example-runner.ts"; +import { services } from "../../../../js/packages/truapi/src/playground/codegen/services.ts"; +import type { TrUApiClient } from "../../../../js/packages/truapi/src/index.ts"; + +// Starts from the playground diagnosis policy. The headless transport handles +// product calls serially, so prompt-backed calls must outlive the runtime's +// 180s remote-response timeout. Otherwise a client timeout leaves the transport +// occupied and makes every following example look like a failure. +const UNARY_TIMEOUT_MS = 10_000; +const REMOTE_RESPONSE_TIMEOUT_MS = 190_000; +const LIVE_ALLOCATION_TIMEOUT_MS = 420_000; +const SKIPPED_SERVICES = new Set(["Chat", "Coin Payment", "Payment"]); +const SKIPPED_METHODS = new Set(["Account/create_account_proof"]); +const LONG_TIMEOUT_METHODS = new Set([ + "Account/get_account", + "Account/get_account_alias", + "Account/create_account_proof", + "Resource Allocation/request", + "Signing/sign_payload", + "Signing/sign_raw", + "Signing/sign_raw_with_legacy_account", + "Signing/sign_payload_with_legacy_account", + "Signing/create_transaction", + "Signing/create_transaction_with_legacy_account", + "Preimage/submit", +]); +const METHOD_TIMEOUT_MS = new Map([ + ["Account/get_account_alias", REMOTE_RESPONSE_TIMEOUT_MS], + ["Resource Allocation/request", LIVE_ALLOCATION_TIMEOUT_MS], + ["Preimage/lookup_subscribe", LIVE_ALLOCATION_TIMEOUT_MS], + ["Preimage/submit", LIVE_ALLOCATION_TIMEOUT_MS], + ["Signing/create_transaction", REMOTE_RESPONSE_TIMEOUT_MS], + ["Statement Store/create_proof_authorized", LIVE_ALLOCATION_TIMEOUT_MS], + ["Statement Store/submit", LIVE_ALLOCATION_TIMEOUT_MS], + ["Statement Store/subscribe", LIVE_ALLOCATION_TIMEOUT_MS], +]); + +export type DiagnosisStatus = "pass" | "fail" | "skipped"; +export interface DiagnosisRow { + id: string; + serviceName: string; + methodName: string; + status: DiagnosisStatus; + output: string; + durationMs: number; +} + +export interface DiagnosisCase { + id: string; + serviceName: string; + methodName: string; + exampleSource?: string; + skipReason?: string; +} + +export interface DiagnosisOptions { + /** Attempt examples the playground normally labels as intentionally unsupported. */ + runKnownUnsupported?: boolean; + onStart?: (test: DiagnosisCase, index: number, total: number) => void; + onResult?: (row: DiagnosisRow, index: number, total: number) => void; +} + +/** Build the battery directly from the generated playground service manifest. */ +export function createDiagnosisPlan( + options: Pick = {}, +): DiagnosisCase[] { + return services.flatMap((service) => + service.methods.map((method) => { + const id = `${service.name}/${method.name}`; + return { + id, + serviceName: service.name, + methodName: method.name, + exampleSource: method.exampleSource, + skipReason: options.runKnownUnsupported + ? undefined + : knownSkipReason(service.name, id), + }; + }), + ); +} + +function knownSkipReason(serviceName: string, id: string): string | undefined { + if (SKIPPED_SERVICES.has(serviceName)) { + return `${serviceName} service not yet wired up by hosts`; + } + if (SKIPPED_METHODS.has(id)) return "host surface intentionally deferred"; + return undefined; +} + +async function runOne( + client: TrUApiClient, + test: DiagnosisCase, +): Promise { + const startedAt = performance.now(); + const finish = (status: DiagnosisStatus, output: string): DiagnosisRow => ({ + id: test.id, + serviceName: test.serviceName, + methodName: test.methodName, + status, + output, + durationMs: Math.round(performance.now() - startedAt), + }); + + if (test.skipReason) { + return finish("skipped", test.skipReason); + } + if (!test.exampleSource) { + return finish("fail", "no runnable example"); + } + const timeoutMs = + METHOD_TIMEOUT_MS.get(test.id) ?? + (LONG_TIMEOUT_METHODS.has(test.id) + ? REMOTE_RESPONSE_TIMEOUT_MS + : UNARY_TIMEOUT_MS); + + const logs: LogEntry[] = []; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`timed out after ${timeoutMs / 1000}s`)), + timeoutMs, + ); + }); + let run: Awaited> | undefined; + try { + run = await Promise.race([ + runExample({ + source: test.exampleSource, + client, + onLog: (event) => logs.push(event), + }), + timeout, + ]); + await Promise.race([run.promise, timeout]); + return finish("pass", joinLogs(logs) ?? "ok"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const log = joinLogs(logs); + return finish("fail", log ? `${log}\n${message}` : message); + } finally { + if (timer !== undefined) clearTimeout(timer); + run?.cancel(); + } +} + +function joinLogs(logs: LogEntry[]): string | undefined { + return logs.length === 0 ? undefined : logs.map((l) => l.text).join("\n"); +} + +/** Run every generated example sequentially, like the playground diagnosis. */ +export async function runDiagnosis( + client: TrUApiClient, + options: DiagnosisOptions = {}, +): Promise { + const plan = createDiagnosisPlan(options); + const rows: DiagnosisRow[] = []; + for (const [index, test] of plan.entries()) { + options.onStart?.(test, index, plan.length); + const row = await runOne(client, test); + rows.push(row); + options.onResult?.(row, index, plan.length); + } + return rows; +} diff --git a/rust/crates/truapi-host-cli/js/runner.ts b/rust/crates/truapi-host-cli/js/runner.ts new file mode 100644 index 00000000..1ea1bb53 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/runner.ts @@ -0,0 +1,107 @@ +// Host-script runner: the Rust CLI spawns this to drive a headless host from a +// user-provided JavaScript/TypeScript file. +// +// The pairing host serves the product frame protocol on a WebSocket; this +// runner connects the real `@parity/truapi` client to it, injects it as the +// global `truapi` (scoped to the host's product id), and evaluates the user +// script. The script is the product: it calls `truapi.account.requestLogin()`, +// `truapi.signing.*`, `truapi.localStorage.*`, etc. A thrown error or rejected +// 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_PRODUCT_ID product id the host serves (scopes storage etc.) +// TRUAPI_SCRIPT absolute path to the user script +import { pathToFileURL } from "node:url"; +import { inspect } from "node:util"; +import { + createClient, + createTransport, + type ProductAccountId, + type TrUApiClient, +} from "../../../../js/packages/truapi/src/index.ts"; +import { wsProvider } from "./ws-provider.ts"; + +/// The host context injected alongside `truapi`. It only exposes what a script +/// can't get from `truapi` alone: the product id the host serves, so product +/// accounts stay in sync with `--product-id` (hardcoding a mismatched id fails +/// signing with `PermissionDenied`). Use `console.log` / `throw` for the rest. +export interface HostContext { + /** The product id this host serves (its `--product-id`). */ + productId: string; + /** A product account id for `derivationIndex` (default 0) under this product. */ + productAccount(index?: number): ProductAccountId; +} + +declare global { + // eslint-disable-next-line no-var + var truapi: TrUApiClient; + // eslint-disable-next-line no-var + var host: HostContext; + // Playground examples receive this helper from `runExample`; expose the + // same contract to directly imported CLI scripts. + // eslint-disable-next-line no-var + var assert: ( + condition: unknown, + ...message: unknown[] + ) => asserts condition; +} + +const OPEN_TIMEOUT_MS = 15_000; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} must be set`); + return value; +} + +async function main() { + const frameUrl = requireEnv("TRUAPI_FRAME_URL"); + const productId = requireEnv("TRUAPI_PRODUCT_ID"); + const scriptPath = requireEnv("TRUAPI_SCRIPT"); + + const provider = wsProvider(frameUrl); + const client = createClient(createTransport(provider)); + + const context: HostContext = { + productId, + productAccount: (index = 0) => ({ dotNsIdentifier: productId, derivationIndex: index }), + }; + globalThis.truapi = client; + globalThis.host = context; + globalThis.assert = (condition: unknown, ...message: unknown[]) => { + if (condition) return; + const detail = message + .map((value) => + typeof value === "string" + ? value + : inspect(value, { colors: false, depth: 5 }), + ) + .join(" "); + throw new Error(detail || "assertion failed"); + }; + + const timer = setTimeout(() => { + console.error(`[runner] timed out connecting to ${frameUrl}`); + process.exit(2); + }, OPEN_TIMEOUT_MS); + await provider.opened; + clearTimeout(timer); + + try { + const module = await import(pathToFileURL(scriptPath).href); + if (typeof module.default === "function") { + await module.default(context); + } + } finally { + provider.dispose(); + } +} + +main().then( + () => process.exit(0), + (error) => { + console.error(`[script error] ${error instanceof Error ? error.stack : String(error)}`); + process.exit(1); + }, +); diff --git a/rust/crates/truapi-host-cli/js/scripts/battery.ts b/rust/crates/truapi-host-cli/js/scripts/battery.ts new file mode 100644 index 00000000..1241ea02 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/battery.ts @@ -0,0 +1,67 @@ +/// +// Runs every example emitted by TrUAPI codegen for the playground. The battery +// contains no hand-maintained method list: adding an exposed API example to the +// generated service manifest automatically adds it to this suite. +// +// Run via: +// truapi-host pairing-host --product-id truapi-playground.dot \ +// --auto-accept --script js/scripts/battery.ts + +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { BatteryReporter } from "../battery-reporter.ts"; +import { + cliDiagnosisReportMetadata, + renderDiagnosisReport, +} from "../diagnosis-report.ts"; +import { createDiagnosisPlan, runDiagnosis } from "../diagnosis.ts"; + +const report = cliDiagnosisReportMetadata(process.env.TRUAPI_CLI_HOST_ROLE); +const DEFAULT_REPORT_PATH = fileURLToPath( + new URL( + `../../../../../explorer/diagnosis-reports/${report.filename}`, + import.meta.url, + ), +); +const REPORT_PATH = + process.env.TRUAPI_BATTERY_REPORT_PATH || DEFAULT_REPORT_PATH; +const options = { runKnownUnsupported: true } as const; +const plan = createDiagnosisPlan(options); +const reporter = new BatteryReporter(); +reporter.start(plan); + +const login = await truapi.account.requestLogin({ reason: undefined }); +if ( + !login.isOk() || + !["Success", "AlreadyConnected"].includes(String(login.value)) +) { + throw new Error( + `battery pairing failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`, + ); +} +reporter.paired(login.value); + +// Pairing completes before the signing host has necessarily finished preparing +// its wallet/ring state and started its SSO responder. Give that responder a +// small readiness window so the first remote example cannot race startup. +const HOST_READINESS_DELAY_MS = 3_000; +reporter.waitingForHost(HOST_READINESS_DELAY_MS); +await new Promise((resolve) => setTimeout(resolve, HOST_READINESS_DELAY_MS)); + +const startedAt = performance.now(); +const rows = await runDiagnosis(truapi, { + ...options, + onResult: (row) => reporter.result(row), +}); +reporter.finish(rows, Math.round(performance.now() - startedAt)); +mkdirSync(dirname(REPORT_PATH), { recursive: true }); +writeFileSync(REPORT_PATH, renderDiagnosisReport(report.title, rows)); +reporter.reportSaved(REPORT_PATH); + +const failures = rows.filter((row) => row.status === "fail"); +if (failures.length > 0) { + throw new Error( + `TrUAPI battery failed: ${failures.length} of ${rows.length} generated examples failed`, + ); +} diff --git a/rust/crates/truapi-host-cli/js/scripts/preimage-smoke.ts b/rust/crates/truapi-host-cli/js/scripts/preimage-smoke.ts new file mode 100644 index 00000000..7ad5c39a --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/preimage-smoke.ts @@ -0,0 +1,31 @@ +/// +// Focused live-chain preimage smoke for debugging Bulletin submission. + +const login = await truapi.account.requestLogin({ reason: undefined }); +if (!login.isOk() || !["Success", "AlreadyConnected"].includes(String(login.value))) { + throw new Error(`requestLogin failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`); +} + +const bytes = crypto.getRandomValues(new Uint8Array(4)); +const value = `0x${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}` as `0x${string}`; +console.log(`PREIMAGE_VALUE ${value}`); + +const submitted = await truapi.preimage.submit(value); +console.log(`PREIMAGE_SUBMIT ${JSON.stringify(submitted)}`); +if (!submitted.isOk()) { + throw new Error(`preimage submit failed: ${JSON.stringify(submitted.error)}`); +} + +const item = await new Promise((resolve, reject) => { + let sub: { unsubscribe: () => void } | undefined; + sub = truapi.preimage.lookupSubscribe({ request: { key: submitted.value } }).subscribe({ + next(value: unknown) { + sub?.unsubscribe(); + resolve(value); + }, + error(error: unknown) { + reject(error); + }, + }); +}); +console.log(`PREIMAGE_LOOKUP ${JSON.stringify(item)}`); diff --git a/rust/crates/truapi-host-cli/js/scripts/ring-vrf-smoke.ts b/rust/crates/truapi-host-cli/js/scripts/ring-vrf-smoke.ts new file mode 100644 index 00000000..fab72a3b --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/ring-vrf-smoke.ts @@ -0,0 +1,53 @@ +/// +export {}; + +const PEOPLE_COLLECTION_ID = + "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; +const PEOPLE_GENESIS = + "0xc5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5"; +const context = { productId: host.productId, suffix: "0x00" }; +const ringLocation = { + chainId: PEOPLE_GENESIS, + junctions: [ + { tag: "PalletInstance" as const, value: 67 }, + { tag: "CollectionId" as const, value: PEOPLE_COLLECTION_ID }, + ], +}; + +const login = await truapi.account.requestLogin({ reason: undefined }); +if ( + !login.isOk() || + (login.value !== "Success" && login.value !== "AlreadyConnected") +) { + throw new Error( + `requestLogin failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`, + ); +} + +const aliasResult = await truapi.account.getAccountAlias({ context, ringLocation }); +if (!aliasResult.isOk()) { + throw new Error(`getAccountAlias failed: ${JSON.stringify(aliasResult.error)}`); +} + +const proofResult = await truapi.account.createAccountProof({ + context, + ringLocation, + message: "0x48656c6c6f", +}); +if (!proofResult.isOk()) { + throw new Error(`createAccountProof failed: ${JSON.stringify(proofResult.error)}`); +} + +if (proofResult.value.contextualAlias.alias !== aliasResult.value.alias) { + throw new Error("alias and proof selected different ring members"); +} +if (proofResult.value.contextualAlias.context !== aliasResult.value.context) { + throw new Error("alias and proof used different context hashes"); +} +if (proofResult.value.proof.length <= 2) { + throw new Error("createAccountProof returned an empty proof"); +} + +console.log( + `RING_VRF_OK ring=${proofResult.value.ringIndex} revision=${proofResult.value.ringRevision} proofBytes=${(proofResult.value.proof.length - 2) / 2}`, +); diff --git a/rust/crates/truapi-host-cli/js/scripts/signing-smoke.ts b/rust/crates/truapi-host-cli/js/scripts/signing-smoke.ts new file mode 100644 index 00000000..a8a89b04 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/signing-smoke.ts @@ -0,0 +1,29 @@ +/// +export {}; + +const login = await truapi.account.requestLogin({ reason: undefined }); +if (!login.isOk() || login.value !== "AlreadyConnected") { + throw new Error(`requestLogin failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`); +} + +const account = host.productAccount(); +const accountResult = await truapi.account.getAccount({ productAccountId: account }); +accountResult.match( + (value) => console.log(`ACCOUNT ${value.account.publicKey.slice(0, 18)}`), + (error) => { + throw new Error(`getAccount failed: ${JSON.stringify(error)}`); + }, +); + +const signatureResult = await truapi.signing.signRaw({ + account, + payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, +}); +signatureResult.match( + (value) => console.log(`SIGNATURE ${value.signature.slice(0, 18)}`), + (error) => { + throw new Error(`signRaw failed: ${JSON.stringify(error)}`); + }, +); + +console.log("SIGNING_SMOKE_OK"); diff --git a/rust/crates/truapi-host-cli/js/scripts/whoami.ts b/rust/crates/truapi-host-cli/js/scripts/whoami.ts new file mode 100644 index 00000000..d03af434 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/whoami.ts @@ -0,0 +1,9 @@ +/// +export {}; + +const result = await truapi.account.getUserId(); +if (!result.isOk()) { + throw new Error(`getUserId failed: ${JSON.stringify(result.error)}`); +} + +console.log(`WHOAMI ${result.value.primaryUsername}`); diff --git a/rust/crates/truapi-host-cli/js/ws-provider.ts b/rust/crates/truapi-host-cli/js/ws-provider.ts new file mode 100644 index 00000000..7cd7a5e4 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/ws-provider.ts @@ -0,0 +1,57 @@ +// WebSocket `WireProvider` for @parity/truapi: one binary WS message per +// SCALE protocol frame, sends buffered until the socket opens. +import type { WireProvider } from "../../../../js/packages/truapi/src/index.ts"; + +export function wsProvider(url: string): WireProvider & { opened: Promise } { + const ws = new WebSocket(url); + ws.binaryType = "arraybuffer"; + const listeners = new Set<(message: Uint8Array) => void>(); + const closeListeners = new Set<(error: Error) => void>(); + const pending: Uint8Array[] = []; + let open = false; + + let resolveOpened: () => void; + let rejectOpened: (error: Error) => void; + const opened = new Promise((resolve, reject) => { + resolveOpened = resolve; + rejectOpened = reject; + }); + + ws.addEventListener("open", () => { + open = true; + for (const frame of pending.splice(0)) ws.send(frame); + resolveOpened(); + }); + ws.addEventListener("message", (event) => { + const bytes = new Uint8Array(event.data as ArrayBuffer); + for (const listener of listeners) listener(bytes); + }); + ws.addEventListener("close", () => { + const error = new Error("websocket closed"); + for (const listener of closeListeners) listener(error); + }); + ws.addEventListener("error", () => { + const error = new Error("websocket error"); + rejectOpened(error); + for (const listener of closeListeners) listener(error); + }); + + return { + opened, + postMessage(message: Uint8Array) { + if (open) ws.send(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() { + ws.close(); + }, + }; +} diff --git a/rust/crates/truapi-host-cli/src/accounts.rs b/rust/crates/truapi-host-cli/src/accounts.rs new file mode 100644 index 00000000..de960255 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/accounts.rs @@ -0,0 +1,684 @@ +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 update_attested(&mut self, network_id: &str, name: &str) { + if let Some(record) = self + .data + .accounts + .iter_mut() + .find(|record| record.network == network_id && record.name == name) + { + record.attested = true; + } + } + + 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))?; + 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) { + 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) { + ensure_record_ready(&mut store, config.network, &record).await?; + let refreshed = store + .get(config.network.id, &record.name) + .cloned() + .unwrap_or(record); + 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) 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" + ); + + 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)?; + if !record.attested { + attest_record(network, record).await?; + store.update_attested(network.id, &record.name); + store.save()?; + } + wait_for_ring_membership(network.people_ws, &identity.entropy).await +} + +async fn attest_record(network: NetworkConfig, record: &AccountRecord) -> Result<()> { + let entropy = mnemonic_entropy(&record.mnemonic)?; + let registered = 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, + lite_username = %record.lite_username, + registered, + "signer account attested" + ); + Ok(()) +} + +async fn wait_for_ring_membership(people_ws: &str, entropy: &[u8]) -> Result<()> { + const MAX_ATTEMPTS: usize = 90; + 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"), + 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")); + assert!(signer.auto_managed); + Ok(()) + } +} diff --git a/rust/crates/truapi-host-cli/src/attestation.rs b/rust/crates/truapi-host-cli/src/attestation.rs new file mode 100644 index 00000000..f64763a9 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/attestation.rs @@ -0,0 +1,245 @@ +//! 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 candidate +/// account's SS58 address. +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 + )) + ); + wait_for_consumer_record(&config.people_ws, &storage_key).await?; + debug!("lite username registered and confirmed on-chain"); + Ok(registration.candidate_account_id) +} + +/// 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 take minutes + // (ring onboarding). The record is permanent once written, so later runs + // resolve on the first poll. + const MAX_ATTEMPTS: usize = 90; + for attempt in 1..=MAX_ATTEMPTS { + match query_storage(people_ws, storage_key).await { + Ok(Some(_)) => { + crate::terminal_ui::update_activity( + "signer", + "Setting up signer", + Some("People-chain identity ready".to_string()), + crate::terminal_ui::ActivityState::Running, + ); + return Ok(()); + } + 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") +} + +/// One `state_getStorage` request over a fresh RPC connection; returns the value +/// hex when present. +async fn query_storage(people_ws: &str, storage_key: &str) -> Result> { + let rpc = RpcClient::from_insecure_url(people_ws) + .await + .with_context(|| format!("connect {people_ws}"))?; + let value = rpc + .request::("state_getStorage", rpc_params![storage_key]) + .await + .context("rpc state_getStorage")?; + Ok(value.as_str().map(str::to_string)) +} diff --git a/rust/crates/truapi-host-cli/src/chain.rs b/rust/crates/truapi-host-cli/src/chain.rs new file mode 100644 index 00000000..fc01e4f0 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/chain.rs @@ -0,0 +1,209 @@ +//! 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::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +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, + 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, _inbound_rx) = 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, + 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> { + BroadcastStream::new(self.inbound.subscribe()) + .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 required_bulletin_route_is_enabled_without_optional_live_chains() { + let network = Network::PaseoNextV2.config(); + let provider = WsChainProvider::with_live_chain_routing( + network.people_ws, + network.live_chain_endpoints, + false, + ); + + assert_eq!( + provider.url_for(&network.bulletin_genesis), + network.bulletin_ws + ); + assert_eq!(provider.url_for(&network.people_genesis), network.people_ws); + assert_eq!( + provider.url_for(&network.live_chain_endpoints[0].genesis), + network.people_ws + ); + } + + #[test] + fn optional_live_chain_routes_are_enabled_by_the_test_switch() { + let network = Network::PaseoNextV2.config(); + let provider = WsChainProvider::with_live_chain_routing( + network.people_ws, + network.live_chain_endpoints, + true, + ); + + assert_eq!( + provider.url_for(&network.live_chain_endpoints[0].genesis), + network.live_chain_endpoints[0].ws + ); + } +} diff --git a/rust/crates/truapi-host-cli/src/frame_server.rs b/rust/crates/truapi-host-cli/src/frame_server.rs new file mode 100644 index 00000000..e1e361a9 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/frame_server.rs @@ -0,0 +1,197 @@ +//! 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, +}; + +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. +/// +/// The product dispatch future is `!Send` (matching the single-threaded wasm +/// runtime), so connections are driven with `spawn_local`; callers must run +/// this inside a `tokio::task::LocalSet`. The runtime's own subscription work +/// is `Send` and still runs on the multi-thread pool via the tokio spawner. +pub async fn accept_loop( + runtime: Arc, + product_id: String, + listener: TcpListener, +) -> Result<()> { + let bound = listener.local_addr()?; + 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_id = product_id.clone(); + tokio::task::spawn_local(async move { + if let Err(err) = serve_connection(runtime, product_id, stream).await { + debug!(%peer, %err, "frame connection ended"); + } + }); + } +} + +async fn serve_connection( + runtime: Arc, + product_id: String, + 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 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 = ProductContext::new(product_id) + .map_err(|err| anyhow::anyhow!("invalid product id: {err}"))?; + 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, + 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, + } +} diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs new file mode 100644 index 00000000..23a90374 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -0,0 +1,1864 @@ +//! 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, + StatementRenewalTarget, +}; + +use crate::accounts::{ResolveSignerConfig, ResolvedSigner}; +use crate::network::{Network, NetworkConfig}; +use crate::platform::{ApprovalPolicy, CliPlatform}; +use crate::sessions::{DEFAULT_SESSION_NAME, SessionCatalog, SessionProfile}; +use crate::signing_shell::{ + HELP_TEXT, PAIRING_HELP_TEXT, 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, &[]) + .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, + &target, + period, + &ring, + ) + .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_id = args.product_id; + let pairing_state_path = role_state_path(&base_path, network, "pairing-host"); + let scratch_script_directory = pairing_state_path.join("scripts"); + 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(pairing_state_path), + 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 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: Arc = runtime; + + 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, product_id, 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, product_id.clone(), listener, async move { + pairing_interactive_loop( + frame_url, + product_id, + scratch_script_directory, + 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 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, + 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 = args.product_id.clone(); + 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_id, 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 product_id = args.product_id.clone(); + let initial_deeplink = args.deeplink.clone(); + if let Some(command) = exec_command { + return with_frame_server( + runtime_for_frames, + product_id.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_id, + 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_id.clone(), + listener, + async move { + signing_interactive_loop( + &mut session, + frame_url, + product_id, + initial_deeplink, + terminal_ui, + log_controller, + ) + .await + }, + ) + .await +} + +struct SigningHostSession { + runtime: Arc, + runtime_factory: Arc, + responder: Option>, + signer: Option, + cached_user_id: 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 profile = if mnemonic.is_some() { + None + } else { + Some(catalog.ensure_profile(&session_name)?) + }; + let storage_path = profile + .as_ref() + .map(|profile| profile.path.clone()) + .unwrap_or_else(|| { + catalog + .profile(DEFAULT_SESSION_NAME) + .expect("default session profile is valid") + .path + }); + let approval = approval_policy(args.auto_accept); + let runtime = build_signing_runtime(network, storage_path, approval, ui.clone())?; + let runtime_factory = frame_server::SwitchableSigningRuntime::new(runtime.clone()); + let default_account = normalized(args.account.clone()); + let mut cached_user_id = profile + .as_ref() + .map(|profile| catalog.cached_user_id(profile)) + .transpose()? + .flatten(); + let mut signer = None; + if let Some(profile) = &profile + && let Some(cached_signer) = accounts::resolve_cached_signer( + &profile.account_base_path, + network.id, + (profile.name == DEFAULT_SESSION_NAME) + .then_some(default_account.as_deref()) + .flatten(), + )? + { + 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(user_id) = &cached_signer.lite_username { + catalog.store_user_id(profile, user_id)?; + cached_user_id = Some(user_id.clone()); + } + terminal_ui::output_event(SystemEvent::SigningHostReady); + signer = Some(cached_signer); + } + + Ok(SigningHostSession { + runtime, + runtime_factory, + responder: None, + signer, + cached_user_id, + 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, + approval: ApprovalPolicy, + ui: Option, +) -> Result> { + let platform = CliPlatform::new( + network.people_ws, + network.live_chain_endpoints, + Some(storage_path), + approval, + ui, + ); + let config = SigningHostConfig::new( + host_info("Headless Signing Host"), + platform_info(), + network.people_genesis, + network.bulletin_genesis, + ) + .context("invalid signing host config")?; + let runtime = Arc::new(SigningHostRuntime::new(platform, config, tokio_spawner())); + runtime.start_statement_allowance_renewal(); + Ok(runtime) +} + +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_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_id: String, + listener: tokio::net::TcpListener, + body: Fut, +) -> Result +where + Fut: Future>, +{ + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + let server = + tokio::task::spawn_local(frame_server::accept_loop(runtime, product_id, listener)); + let result = body.await; + server.abort(); + result + }) + .await +} + +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?, + ); + activate_current_signer(session).await +} + +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()); + } + 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(device) => { + track_pairing_renewal_targets(session, device).await; + 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") +} + +/// Best-effort: record the pairing allowance accounts in the renewal ledger so +/// the background renewer keeps them allowed across periods. +async fn track_pairing_renewal_targets(session: &SigningHostSession, device: [u8; 32]) { + let result = session + .runtime + .track_statement_renewal_targets(vec![ + StatementRenewalTarget::WalletSso, + StatementRenewalTarget::Account { + account_id: device, + label: "device".to_string(), + }, + ]) + .await; + if let Err(err) = result { + tracing::warn!(reason = %err.reason, "failed to record pairing renewal targets"); + } +} + +/// Renew tracked statement-store allowances now, reporting each target. On +/// slot exhaustion an auto-managed signer account is marked exhausted so the +/// next pairing rotates to a fresh one. +async fn run_renew(session: &mut SigningHostSession) -> Result<()> { + use truapi_server::statement_allowance::renewal::TargetRenewalStatus; + + ensure_signer(session).await?; + let report = session + .runtime + .renew_statement_allowances() + .await + .map_err(|err| anyhow::anyhow!("allowance renewal failed: {}", err.reason))?; + + let (mut renewed, mut fresh, mut failed, mut skipped) = (0usize, 0usize, 0usize, 0usize); + for (target, status) in &report.outcomes { + match status { + TargetRenewalStatus::Registered { seq, block_hash } => { + renewed += 1; + terminal_ui::output_event(SystemEvent::AllowanceReady { + target: target.clone(), + sequence: *seq, + block_hash: Some(block_hash.clone()), + already_allocated: false, + }); + } + TargetRenewalStatus::AlreadyAllocated { seq } => { + fresh += 1; + terminal_ui::output_event(SystemEvent::AllowanceReady { + target: target.clone(), + sequence: *seq, + block_hash: None, + already_allocated: true, + }); + } + TargetRenewalStatus::Failed { reason } => { + failed += 1; + terminal_ui::output_event(SystemEvent::AllowanceRenewalFailed { + target: target.clone(), + reason: reason.clone(), + }); + } + TargetRenewalStatus::SkippedExhausted => skipped += 1, + } + } + terminal_ui::output_event(SystemEvent::AllowanceRenewalReport { + period: report.period, + renewed, + fresh, + failed, + skipped, + }); + + if report.slots_exhausted { + mark_current_account_exhausted(session)?; + } + Ok(()) +} + +fn mark_current_account_exhausted(session: &SigningHostSession) -> Result<()> { + let Some(signer) = session.signer.as_ref() else { + return Ok(()); + }; + if !signer.auto_managed { + return Ok(()); + } + let Some(name) = signer.account_name.clone() else { + return Ok(()); + }; + 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, period }); + Ok(()) +} + +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 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_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 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: &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?; + if let Some(user_id) = &signer.lite_username { + session.catalog.store_user_id(&profile, user_id)?; + } + let runtime = build_signing_runtime( + session.network, + profile.path.clone(), + session.approval, + session.ui.clone(), + )?; + let available_sessions = session.catalog.list()?; + + session.catalog.set_current(&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.profile = Some(profile); + if let Some(ui) = &session.ui { + ui.session(name, available_sessions); + } + terminal_ui::output_event(SystemEvent::ProductConnectionsReset); + 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. +/// Returns the device statement account id so the caller can track it for +/// renewal (`//wallet//sso` is tracked as a derivation recipe instead). +async fn register_pairing_allowances( + statement_store_url: &str, + entropy: &[u8], + deeplink: &str, +) -> Result<[u8; 32]> { + 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, + &target, + period, + &ring, + ) + .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(device) +} + +async fn pairing_interactive_loop( + frame_url: String, + product_id: String, + scratch_script_directory: PathBuf, + mut ui: ActiveTerminalUi, + log_controller: LogController, +) -> Result<()> { + 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::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::Quit => return Ok(()), + ShellCommand::Script(script) => { + let script = match script { + Some(script) => Ok(script), + None => edit_new_script_in(&scratch_script_directory, &mut ui).await, + }; + match script { + Ok(script) => { + run_pairing_script(&frame_url, &product_id, &script, input, &mut ui) + .await?; + } + Err(error) => ui.error(error.to_string()), + } + } + ShellCommand::Deeplink(_) | ShellCommand::Session(_) | ShellCommand::Renew => { + ui.error("command is only available on the signing host"); + } + } + } +} + +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_id: String, + initial_deeplink: Option, + mut ui: ActiveTerminalUi, + log_controller: LogController, +) -> Result<()> { + if let Some(deeplink) = initial_deeplink { + let input = format!("/deeplink {deeplink}"); + ui.command(input.clone()); + run_interactive_operation( + session, + &frame_url, + &product_id, + ShellCommand::Deeplink(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::Session(SessionCommand::Current) => { + ui.event(session_status_event(session)); + } + 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_new_script(session, &mut ui).await { + Ok(script) => { + run_interactive_operation( + session, + &frame_url, + &product_id, + ShellCommand::Script(Some(script)), + input, + &mut ui, + ) + .await?; + } + Err(error) => ui.error(error.to_string()), + }, + command => { + 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::Deeplink(deeplink) => start_deeplink_responder(session, deeplink).await?, + ShellCommand::Script(Some(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::Renew => run_renew(session).await?, + 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_id: &str, + command: ShellCommand, + log_controller: &LogController, +) -> Result<()> { + match command { + ShellCommand::Deeplink(deeplink) => respond_to_deeplink(session, deeplink).await?, + ShellCommand::Script(script) => { + let script = match script { + Some(script) => script, + None => edit_new_script_plain(session).await?, + }; + ensure_signer(session).await?; + 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::Log(level) => { + log_controller.set(level)?; + terminal_ui::output_event(SystemEvent::LogLevelChanged { level }); + } + ShellCommand::Session(SessionCommand::Current) => { + println!("{}", session_status(session)); + } + ShellCommand::Session(SessionCommand::List) => { + println!("{}", session_list(session)?); + } + ShellCommand::Session(SessionCommand::Switch(name)) => { + switch_session(session, name).await?; + } + ShellCommand::Renew => run_renew(session).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"), + ) +} + +async fn edit_new_script( + session: &SigningHostSession, + ui: &mut ActiveTerminalUi, +) -> Result { + edit_new_script_in(&scratch_script_directory(session), ui).await +} + +async fn edit_new_script_in( + scratch_script_directory: &std::path::Path, + ui: &mut ActiveTerminalUi, +) -> Result { + let script = script_runner::create_scratch_script(scratch_script_directory)?; + 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_new_script_plain(session: &SigningHostSession) -> Result { + if !terminal_ui::is_interactive_terminal() { + bail!("/script without a path requires an interactive terminal"); + } + let script = script_runner::create_scratch_script(&scratch_script_directory(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") +} + +fn role_state_path(base_path: &std::path::Path, network: NetworkConfig, role: &str) -> PathBuf { + base_path.join(network.id).join(role) +} + +#[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 signing_host_rejects_managed_session_with_mnemonic() { + let cli = Cli::try_parse_from([ + "truapi-host", + "signing-host", + "--mnemonic", + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "--session", + "alice", + "exec", + "/session", + ]) + .expect("clap should parse conflicting signer options"); + let Command::SigningHost(args) = cli.command else { + panic!("expected signing-host command"); + }; + + assert!( + validate_signing_args(&args) + .unwrap_err() + .to_string() + .contains("--session cannot be used") + ); + } +} diff --git a/rust/crates/truapi-host-cli/src/network.rs b/rust/crates/truapi-host-cli/src/network.rs new file mode 100644 index 00000000..5d8a40d0 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/network.rs @@ -0,0 +1,95 @@ +use clap::ValueEnum; + +/// Supported live network presets for the headless hosts. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] +pub enum Network { + #[value(name = "paseo-next-v2")] + #[default] + PaseoNextV2, +} + +impl Network { + pub fn config(self) -> NetworkConfig { + match self { + Self::PaseoNextV2 => NetworkConfig { + id: "paseo-next-v2", + identity_backend_base: "https://identity-backend-next.parity-testnet.parity.io/api/v1", + people_ws: "wss://paseo-people-next-system-rpc.polkadot.io", + bulletin_ws: "wss://paseo-bulletin-next-rpc.polkadot.io", + people_genesis: hex_literal_genesis( + "c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5", + ), + bulletin_genesis: hex_literal_genesis( + "8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22", + ), + live_chain_endpoints: PASEO_NEXT_V2_CHAIN_ENDPOINTS, + }, + } + } +} + +const PASEO_NEXT_V2_CHAIN_ENDPOINTS: &[ChainEndpoint] = &[ + ChainEndpoint { + genesis: hex_literal_genesis( + "bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f", + ), + ws: "wss://paseo-asset-hub-next-rpc.polkadot.io", + required_for_host: false, + }, + ChainEndpoint { + genesis: hex_literal_genesis( + "c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5", + ), + ws: "wss://paseo-people-next-system-rpc.polkadot.io", + required_for_host: true, + }, + ChainEndpoint { + genesis: hex_literal_genesis( + "8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22", + ), + ws: "wss://paseo-bulletin-next-rpc.polkadot.io", + required_for_host: true, + }, +]; + +/// Resolved RPC/backend/genesis values for one network preset. +#[derive(Debug, Clone, Copy)] +pub struct NetworkConfig { + pub id: &'static str, + pub identity_backend_base: &'static str, + pub people_ws: &'static str, + #[allow(dead_code)] + pub bulletin_ws: &'static str, + pub people_genesis: [u8; 32], + pub bulletin_genesis: [u8; 32], + pub live_chain_endpoints: &'static [ChainEndpoint], +} + +#[derive(Debug, Clone, Copy)] +pub struct ChainEndpoint { + pub genesis: [u8; 32], + pub ws: &'static str, + /// Whether host internals require this route even when optional product + /// Chain calls are disabled. + pub required_for_host: bool, +} + +/// Decode a 64-char hex genesis at compile time. +const fn hex_literal_genesis(hex: &str) -> [u8; 32] { + let bytes = hex.as_bytes(); + let mut out = [0u8; 32]; + let mut i = 0; + while i < 32 { + out[i] = hex_nibble(bytes[i * 2]) << 4 | hex_nibble(bytes[i * 2 + 1]); + i += 1; + } + out +} + +const fn hex_nibble(c: u8) -> u8 { + match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + _ => panic!("invalid hex digit in genesis literal"), + } +} diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs new file mode 100644 index 00000000..8bf9128d --- /dev/null +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -0,0 +1,671 @@ +//! `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; +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 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, SessionUiInfo, ThemeHost, + UserConfirmation, UserConfirmationReview, +}; + +use crate::chain::WsChainProvider; +use crate::terminal_ui::{SystemEvent, UiHandle}; + +/// 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, +} + +/// Headless-host platform shared by both roles. +pub struct CliPlatform { + chain: WsChainProvider, + product_storage: Mutex>>, + core_storage: Mutex, Vec>>, + product_storage_path: Option, + core_storage_path: 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_dir: Option, + approval: ApprovalPolicy, + ui: Option, + ) -> Arc { + let (product_storage_path, core_storage_path) = storage_dir + .as_ref() + .map(|dir| { + if let Err(err) = fs::create_dir_all(dir) { + tracing::warn!(path = %dir.display(), %err, "could not create CLI storage dir"); + } + ( + Some(dir.join("product-storage.json")), + Some(dir.join("core-storage.json")), + ) + }) + .unwrap_or((None, None)); + let product_storage = product_storage_path + .as_deref() + .map(load_string_map) + .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_path, + core_storage_path, + 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) -> Result<(), String> { + let Some(path) = &self.product_storage_path else { + return Ok(()); + }; + let storage = self + .product_storage + .lock() + .expect("product storage mutex poisoned"); + save_string_map(path, &storage) + } + + fn persist_core_storage(&self) -> Result<(), String> { + let Some(path) = &self.core_storage_path else { + return Ok(()); + }; + let storage = self + .core_storage + .lock() + .expect("core storage mutex poisoned"); + save_hex_key_map(path, &storage) + } + + /// 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> { + Ok(self + .product_storage + .lock() + .expect("product storage mutex poisoned") + .get(&key) + .cloned()) + } + + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), api::HostLocalStorageReadError> { + { + self.product_storage + .lock() + .expect("product storage mutex poisoned") + .insert(key, value); + } + self.persist_product_storage() + .map_err(|reason| api::HostLocalStorageReadError::Unknown { reason }) + } + + async fn clear(&self, key: String) -> Result<(), api::HostLocalStorageReadError> { + { + self.product_storage + .lock() + .expect("product storage mutex poisoned") + .remove(&key); + } + self.persist_product_storage() + .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::info!(%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) { + 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_label(info), + 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_label(info: &SessionUiInfo) -> String { + connected_user_id(info).map_or_else( + || "connected".to_string(), + |user_id| format!("connected · {user_id}"), + ) +} + +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()) + }) +} + +#[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::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, +} + +fn load_string_map(path: &Path) -> HashMap> { + let text = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return HashMap::new(), + Err(err) => { + tracing::warn!(path = %path.display(), %err, "could not read CLI storage"); + return HashMap::new(); + } + }; + match serde_json::from_str::(&text) { + Ok(json) => json + .values + .into_iter() + .filter_map(|(key, value)| hex::decode(value).ok().map(|bytes| (key, bytes))) + .collect(), + Err(err) => { + tracing::warn!(path = %path.display(), %err, "could not decode CLI storage"); + HashMap::new() + } + } +} + +fn save_string_map(path: &Path, values: &HashMap>) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| format!("create storage dir: {err}"))?; + } + 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())?; + fs::write(path, text).map_err(|err| format!("write {}: {err}", path.display())) +} + +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() +} + +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::*; + + #[test] + fn connected_label_includes_the_primary_user_id() { + let info = SessionUiInfo { + lite_username: Some("alice.dot".to_string()), + full_username: Some("Alice".to_string()), + ..SessionUiInfo::default() + }; + assert_eq!(connected_label(&info), "connected · Alice"); + + let info = SessionUiInfo { + lite_username: Some("alice.dot".to_string()), + ..SessionUiInfo::default() + }; + assert_eq!(connected_label(&info), "connected · alice.dot"); + assert_eq!(connected_label(&SessionUiInfo::default()), "connected"); + } + + #[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 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"); + } +} diff --git a/rust/crates/truapi-host-cli/src/script_runner.rs b/rust/crates/truapi-host-cli/src/script_runner.rs new file mode 100644 index 00000000..badc6f92 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/script_runner.rs @@ -0,0 +1,275 @@ +//! 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::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 { + command(frame_url, product_id, script, host_role)? + .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)?; + command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = command + .spawn() + .context("failed to spawn `bun` for the host script (is bun installed?)")?; + let stdout = child.stdout.take().context("capture script stdout")?; + let stderr = child.stderr.take().context("capture script stderr")?; + let stdout_ui = ui.clone(); + let stdout_task = async move { + let mut lines = BufReader::new(stdout).lines(); + while let Some(line) = lines.next_line().await? { + stdout_ui.script_stdout(line); + } + Ok::<(), std::io::Error>(()) + }; + let stderr_task = async move { + let mut lines = BufReader::new(stderr).lines(); + while let Some(line) = lines.next_line().await? { + ui.script_stderr(line); + } + Ok::<(), std::io::Error>(()) + }; + let (status, stdout, stderr) = tokio::join!(child.wait(), stdout_task, stderr_task); + stdout.context("read script stdout")?; + stderr.context("read script stderr")?; + status.context("wait for host script") +} + +fn command( + frame_url: &str, + product_id: &str, + script: &Path, + host_role: ScriptHostRole, +) -> Result { + let runner = runner_path(); + if !runner.exists() { + anyhow::bail!( + "host-script runner not found at {}; set TRUAPI_HOST_RUNNER", + runner.display() + ); + } + let script = script + .canonicalize() + .with_context(|| format!("script not found: {}", script.display()))?; + + let mut command = Command::new("bun"); + command + .arg("run") + .arg(&runner) + .env("TRUAPI_FRAME_URL", frame_url) + .env("TRUAPI_PRODUCT_ID", product_id) + .env("TRUAPI_SCRIPT", &script) + .env("TRUAPI_CLI_HOST_ROLE", host_role.as_env_value()); + Ok(command) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scratch_script_starts_as_a_bun_script_with_dependency_examples() -> Result<()> { + let temporary = tempfile::tempdir()?; + + let script = create_scratch_script(temporary.path())?; + let contents = fs::read_to_string(script)?; + + assert!(contents.starts_with("#!/usr/bin/env bun\n")); + assert!(contents.contains("import chalk from \"chalk\";")); + assert!(contents.contains("truapi.account.getUserId()")); + assert_eq!(contents, SCRATCH_TEMPLATE); + Ok(()) + } + + #[test] + fn host_scripts_are_run_by_bun() -> Result<()> { + let temporary = tempfile::tempdir()?; + let script = temporary.path().join("script.ts"); + fs::write(&script, "console.log('hello');\n")?; + + let command = command( + "ws://127.0.0.1:1234", + "example.dot", + &script, + ScriptHostRole::SigningHost, + )?; + let command = command.as_std(); + let arguments = command.get_args().collect::>(); + + assert_eq!(command.get_program(), std::ffi::OsStr::new("bun")); + assert_eq!(arguments[0], std::ffi::OsStr::new("run")); + assert_eq!(arguments[1], runner_path()); + assert_eq!( + command + .get_envs() + .find_map(|(key, value)| { (key == "TRUAPI_CLI_HOST_ROLE").then_some(value) }), + Some(Some(std::ffi::OsStr::new("signing-host"))) + ); + Ok(()) + } + + #[test] + fn editor_command_accepts_quoted_arguments_without_a_shell() -> Result<()> { + let (program, arguments) = parse_editor("code --wait \"profile one\"")?; + + assert_eq!(program, "code"); + assert_eq!(arguments, ["--wait", "profile one"]); + Ok(()) + } +} diff --git a/rust/crates/truapi-host-cli/src/sessions.rs b/rust/crates/truapi-host-cli/src/sessions.rs new file mode 100644 index 00000000..1a00b236 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/sessions.rs @@ -0,0 +1,308 @@ +//! Network-scoped signing-host session directories and current selection. + +use std::fs; +use std::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, + user_id: String, +} + +/// Filesystem locations owned by one managed signing-host session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionProfile { + pub name: String, + /// Product/core storage directory shown by `/session`. + pub path: 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, + role_path: PathBuf, +} + +impl SessionCatalog { + pub fn new(base_path: PathBuf, network_id: &str) -> Result { + let base_path = absolute_path(base_path)?; + let role_path = base_path.join(network_id).join("signing-host"); + fs::create_dir_all(&role_path) + .with_context(|| format!("create session root {}", role_path.display()))?; + Ok(Self { + base_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(), + // Preserve the pre-session account store for compatibility. + account_base_path: self.base_path.clone(), + }); + } + let path = self.role_path.join("sessions").join(name); + Ok(SessionProfile { + name: name.to_string(), + path: path.clone(), + 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![DEFAULT_SESSION_NAME.to_string()]; + let sessions_path = self.role_path.join("sessions"); + let entries = match fs::read_dir(&sessions_path) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(names), + Err(error) => { + return Err(error) + .with_context(|| format!("list sessions {}", sessions_path.display())); + } + }; + 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); + } + } + names.sort(); + Ok(names) + } + + pub fn cached_user_id(&self, profile: &SessionProfile) -> Result> { + let path = profile.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(None), + Err(error) => { + return Err(error) + .with_context(|| format!("read session identity {}", path.display())); + } + }; + let info: SessionInfo = serde_json::from_str(&text) + .with_context(|| format!("decode session identity {}", path.display()))?; + Ok((!info.user_id.is_empty()).then_some(info.user_id)) + } + + pub fn store_user_id(&self, profile: &SessionProfile, user_id: &str) -> Result<()> { + if user_id.is_empty() { + return Ok(()); + } + fs::create_dir_all(&profile.path) + .with_context(|| format!("create session {}", profile.path.display()))?; + let path = profile.path.join(SESSION_INFO_FILE); + let temporary = profile + .path + .join(format!(".{SESSION_INFO_FILE}.{}.tmp", std::process::id())); + let text = serde_json::to_string_pretty(&SessionInfo { + version: 1, + user_id: user_id.to_string(), + })?; + fs::write(&temporary, format!("{text}\n")) + .with_context(|| format!("write session identity {}", temporary.display()))?; + fs::rename(&temporary, &path) + .with_context(|| format!("persist session identity {}", 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(()) +} + +/// 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 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", "default"]); + let profile = catalog.profile("alice")?; + assert!( + profile + .path + .ends_with("testnet/signing-host/sessions/alice") + ); + assert_eq!(profile.path, profile.account_base_path); + 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")); + Ok(()) + } + + #[test] + fn session_user_id_round_trips_in_the_profile_path() -> Result<()> { + let temporary = tempdir()?; + let catalog = SessionCatalog::new(temporary.path().to_path_buf(), "testnet")?; + let profile = catalog.ensure_profile("alice")?; + + assert_eq!(catalog.cached_user_id(&profile)?, None); + catalog.store_user_id(&profile, "alice.dot")?; + + assert_eq!( + catalog.cached_user_id(&profile)?.as_deref(), + Some("alice.dot") + ); + assert!(profile.path.join(SESSION_INFO_FILE).is_file()); + Ok(()) + } +} diff --git a/rust/crates/truapi-host-cli/src/signing_shell.rs b/rust/crates/truapi-host-cli/src/signing_shell.rs new file mode 100644 index 00000000..7703ed19 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/signing_shell.rs @@ -0,0 +1,643 @@ +//! Slash-command parsing and command-bar editing for the signing-host UI. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::LogLevel; +use crate::sessions; + +/// 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. + Deeplink(String), + /// Edit a new product script, or run an existing 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, + /// Replace the active tracing filter with a log level. + Log(LogLevel), + /// Inspect, list, or switch the active persistent session. + Session(SessionCommand), + /// Renew tracked statement-store allowances for the current period. + Renew, + /// 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 { + "/deeplink" => { + if argument.is_empty() { + return Err("usage: /deeplink ".to_string()); + } + if !argument.starts_with("polkadotapp://pair?") { + return Err("/deeplink expects a polkadotapp://pair?... URL".to_string()); + } + Ok(ShellCommand::Deeplink(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), + "/log" => { + if argument.is_empty() { + return Err("usage: /log ".to_string()); + } + Ok(ShellCommand::Log(argument.parse::()?)) + } + "/session" => { + if argument.is_empty() { + return Ok(ShellCommand::Session(SessionCommand::Current)); + } + if argument == "--list" { + return Ok(ShellCommand::Session(SessionCommand::List)); + } + sessions::validate_name(argument)?; + Ok(ShellCommand::Session(SessionCommand::Switch( + argument.to_string(), + ))) + } + "/renew" => no_argument(name, argument, ShellCommand::Renew), + "/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)] = &[ + ("/deeplink ", "answer a Polkadot Mobile pairing URL"), + ("/script", "edit a new or run an existing product script"), + ("/log ", "set error, warn, info, debug, or trace"), + ("/session", "show or switch the active session"), + ("/renew", "renew statement-store allowances now"), + ("/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 a new or run an existing product script"), + ("/log ", "set error, warn, info, debug, or trace"), + ("/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 = "\ +/deeplink answer a Polkadot Mobile pairing URL +/script edit and run a new Bun TypeScript product script +/script run an existing JS/TS product script with Bun +/log set error, warn, info, debug, or trace +/session show the current session and path +/session switch to or create a session +/session --list list sessions for this network +/renew renew statement-store allowances now +/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 a new Bun TypeScript product script +/script run an existing JS/TS product script with Bun +/log set error, warn, info, debug, or trace +/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("/deeplink polkadotapp://pair?handshake=01"), + Ok(ShellCommand::Deeplink( + "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("/log trace"), + Ok(ShellCommand::Log(LogLevel::Trace)) + ); + assert_eq!(parse_command("/copy"), Ok(ShellCommand::Copy)); + assert_eq!(parse_command("/renew"), Ok(ShellCommand::Renew)); + 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("/renew now").is_err()); + assert!(parse_command("/deeplink https://example.com").is_err()); + assert!(parse_command("/log noisy").is_err()); + assert!(parse_command("/session ../escape").is_err()); + } + + #[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(&"/copy".to_string())); + assert!( + !commands + .iter() + .any(|command| command.starts_with("/deeplink")) + ); + assert!( + !commands + .iter() + .any(|command| command.starts_with("/session")) + ); + } +} diff --git a/rust/crates/truapi-host-cli/src/terminal_ui.rs b/rust/crates/truapi-host-cli/src/terminal_ui.rs new file mode 100644 index 00000000..62f6cfe7 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/terminal_ui.rs @@ -0,0 +1,2905 @@ +//! 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; + +/// 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, + SigningHostAccountExhausted { + name: String, + period: u32, + }, + SigningHostExit { + outcome: String, + }, + SigningHostError { + reason: String, + }, + SigningHostResponderStarted, + ProductConnectionsReset, + RingInfo { + ring_index: u32, + members: usize, + }, + AllowanceChecking { + target: String, + }, + AllowanceReady { + target: String, + sequence: u32, + block_hash: Option, + already_allocated: bool, + }, + AllowanceRenewalFailed { + target: String, + reason: String, + }, + AllowanceRenewalReport { + period: u32, + renewed: usize, + fresh: usize, + failed: usize, + skipped: usize, + }, + 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, + }, + 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 = styled_output(io::stdout().is_terminal()); + 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 styled_output(stderr.is_terminal()) { + 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 = styled_output(io::stderr().is_terminal()); + 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"); + } + } +} + +fn styled_output(is_terminal: bool) -> bool { + let no_color = std::env::var_os("NO_COLOR").is_some(); + let force_color = + std::env::var_os("FORCE_COLOR").is_some_and(|value| !value.is_empty() && value != "0"); + should_style_output(is_terminal, no_color, force_color) +} + +fn should_style_output(is_terminal: bool, no_color: bool, force_color: bool) -> bool { + !no_color && (is_terminal || force_color) +} + +#[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 connection label shown in the transcript panel title. + 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, + 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(), 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, + }) + } +} + +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, +} + +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(); + 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("copy transcript to system clipboard")?; + Ok(entries) + } + + /// Update the displayed log level after `/log` succeeds. + pub fn set_log_level(&mut self, level: LogLevel) { + self.app.log_level = level; + } + + /// 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.app.handle_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.app.handle_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); + } + } + } + } + } + + 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.app.handle_event(event); + } + } +} + +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, + 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, + session: String, + session_names: Vec, + log_level: LogLevel, + ) -> Self { + Self::with_role( + HostRole::Signing, + network, + 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, + Vec::new(), + log_level, + ) + } + + fn with_role( + role: HostRole, + network: 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, + 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::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::ProductConnectionsReset => self.notice( + NoticeTone::Warning, + "Session changed".to_string(), + Some("Reconnect product clients to continue.".to_string()), + ), + 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::AllowanceRenewalFailed { target, reason } => self.activity( + format!("allowance:{target}"), + format!("{} renewal failed", allowance_name(&target)), + Some(reason), + ActivityState::Failed, + ), + SystemEvent::AllowanceRenewalReport { + period, + renewed, + fresh, + failed, + skipped, + } => { + if renewed + fresh + failed + skipped == 0 { + self.notice( + NoticeTone::Info, + "No tracked allowance targets".to_string(), + Some(format!("Statement period {period}")), + ); + } else { + let tone = if failed + skipped > 0 { + NoticeTone::Warning + } else { + NoticeTone::Success + }; + self.notice( + tone, + "Allowance renewal finished".to_string(), + Some(format!( + "Period {period} · {renewed} renewed · {fresh} fresh · {failed} failed · {skipped} skipped" + )), + ); + } + } + 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::ScriptExit { code: 0 } => { + self.notice(NoticeTone::Success, "Script finished".to_string(), None) + } + SystemEvent::ScriptExit { code } => self.notice( + NoticeTone::Error, + "Script failed".to_string(), + Some(format!("Exit code {code}")), + ), + 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_padding = height >= 7; + let show_footer = height >= 5; + let base_composer_height = 1 + u16::from(show_padding) + 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::Length(1), + Constraint::Min(1), + Constraint::Length(composer_height), + ]) + .split(frame.area()); + app.transcript_height = areas[1].height as usize; + + frame.render_widget(Paragraph::new(header_line(app, areas[0].width)), areas[0]); + + let transcript_lines = app + .entries + .iter() + .flat_map(feed_item_lines) + .collect::>(); + let transcript = Paragraph::new(transcript_lines).wrap(Wrap { trim: false }); + let content_height = transcript.line_count(areas[1].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[1]); + + let surface_area = areas[2]; + let surface_style = input_surface_style(); + frame.render_widget(Block::default().style(surface_style), surface_area); + + 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 surface_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( + surface_area.x, + surface_area.y.saturating_add(u16::from(show_padding)), + surface_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( + surface_area.x, + surface_area + .bottom() + .saturating_sub(1 + u16::from(show_footer)), + surface_area.width, + 1, + ); + let viewport = input_viewport( + &input, + app.editor.cursor(), + prompt_area.width.saturating_sub(2), + ); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + "› ", + semantic_style(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::styled( + viewport.text, + if approval { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }, + ), + ])) + .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 = footer_text(app, approval, completion_height > 0, surface_area.width); + let footer_area = Rect::new( + surface_area.x, + surface_area.bottom().saturating_sub(1), + surface_area.width, + 1, + ); + frame.render_widget( + Paragraph::new(format!(" {footer}")).style(surface_style.add_modifier(Modifier::DIM)), + footer_area, + ); + } +} + +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(); + } + match width { + 0..40 => "/ commands".to_string(), + 40..70 => "/ commands · Tab complete".to_string(), + _ => "Type / for commands · ↑↓ history · Tab complete".to_string(), + } +} + +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 connection_style = if app.connection.starts_with("connected") { + semantic_style(Color::Green) + } else if app.connection == "failed" { + semantic_style(Color::Red) + } else { + Style::default().add_modifier(Modifier::DIM) + }; + let mut spans = vec![ + Span::styled( + " TrUAPI ", + semantic_style(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::raw("· "), + Span::styled(app.connection.clone(), connection_style), + ]; + if width >= 55 { + spans.push(Span::raw(format!(" · {}", app.network))); + } + if width >= 80 { + let context = match app.role { + HostRole::Pairing => "product", + HostRole::Signing => "session", + }; + spans.push(Span::raw(format!(" · {context} {}", app.session))); + spans.push(Span::styled( + format!(" · log {}", app.log_level), + Style::default().add_modifier(Modifier::DIM), + )); + } + Line::from(spans) +} + +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) -> 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(), + Line::from(vec![ + Span::styled( + "┃ ", + semantic_style(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::styled( + command.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + ]), + 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 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) + .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("/deeplink ") { + "/deeplink ".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(), + "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("/deeplink 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("┃ /deeplink ")); + assert!(transcript.contains(" user id: alice.dot")); + assert!(!transcript.contains("handshake=secret")); + assert!(!transcript.contains("SCRIPT ·")); + } + + #[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 a new")) + .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_header_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_uses_product_context_in_the_shared_renderer() -> 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)?; + + assert!(screen.contains("product playground.dot")); + assert!(screen.contains("/script")); + assert!(!screen.contains("/deeplink")); + assert!(!screen.contains("/session")); + 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 prompt_cursor_is_after_ascii_and_wide_input() -> Result<()> { + let mut app = test_app(); + app.editor.set_text("/d"); + let (_, cursor) = render_app(&mut app, 80, 12)?; + assert_eq!(cursor.x, 4); + + app.editor.set_text("/界"); + let (_, cursor) = render_app(&mut app, 80, 12)?; + assert_eq!(cursor.x, 5); + 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.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)?; + assert!(screen.contains("┃ /script demo.ts")); + assert!(screen.contains(" head follow event {")); + assert!(screen.contains("✓ Script finished")); + assert!(!screen.contains("SCRIPT ·")); + assert!(!screen.contains("┌ command")); + 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("TrUAPI")); + assert!(screen.contains("disconnected")); + assert!(cursor.x < width); + } + Ok(()) + } + + #[test] + fn composer_preserves_input_before_optional_padding_and_footer() -> Result<()> { + let mut app = test_app(); + + let (short, short_cursor) = render_app(&mut app, 80, 4)?; + assert_eq!(short_cursor.y, 3); + assert!(!short.contains("Type / for commands")); + + let (medium, medium_cursor) = render_app(&mut app, 80, 5)?; + assert_eq!(medium_cursor.y, 3); + assert!(medium.contains("Type / for commands")); + + let (normal, normal_cursor) = render_app(&mut app, 80, 7)?; + assert_eq!(normal_cursor.y, 5); + assert!(normal.contains("Type / for commands")); + 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); + } + + #[test] + fn forced_color_styles_redirected_output_but_never_overrides_no_color() { + assert!(should_style_output(true, false, false)); + assert!(should_style_output(false, false, true)); + assert!(!should_style_output(false, false, false)); + assert!(!should_style_output(true, true, true)); + } + + 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)) + } +} diff --git a/rust/crates/truapi-host-cli/tests/signing_host_cli.rs b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs new file mode 100644 index 00000000..c58cbde2 --- /dev/null +++ b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs @@ -0,0 +1,144 @@ +//! 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("/session")); + 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("signing-host/sessions/alice")); + assert!(first_stdout.contains("User ")); + + 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 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", + "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")); + let metadata = + std::fs::read_to_string(base_path.join("paseo-next-v2/signing-host/session.json")) + .expect("read persisted session identity"); + assert!(metadata.contains("cachedalice")); +} diff --git a/rust/crates/truapi-platform/Cargo.toml b/rust/crates/truapi-platform/Cargo.toml index cfbd0fc4..a61ea4c4 100644 --- a/rust/crates/truapi-platform/Cargo.toml +++ b/rust/crates/truapi-platform/Cargo.toml @@ -13,6 +13,7 @@ futures = "0.3" parity-scale-codec = { version = "3", features = ["derive"] } unicode-normalization = "0.1" url = "2" +zeroize = { version = "1", default-features = false, features = ["derive"] } [lints.rust] unsafe_code = "forbid" diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 29a071fb..49066875 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -453,6 +453,8 @@ pub enum CoreStorageKey { }, /// Last processed SSO pairing response statement for the pairing device. LastProcessedPairingStatement, + /// Statement-store allowance targets the signing host keeps renewed. + StatementRenewalTargets, } /// Host-private persistence for core-owned state. diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 1d23a276..63cffb69 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -65,7 +65,9 @@ verifiable = { git = "https://github.com/paritytech/verifiable.git", rev = "19b0 [target.'cfg(not(target_arch = "wasm32"))'.dependencies] subxt = { version = "0.50.2", default-features = false, features = ["native"] } -subxt-rpcs = { version = "0.50.1", default-features = false, features = ["native"] } +subxt-rpcs = { version = "0.50.1", default-features = false, features = ["jsonrpsee", "native"] } +frame-metadata = { version = "23", default-features = false, features = ["std", "current", "decode"] } +scale-info = { version = "2.11", default-features = false, features = ["decode"] } [target.'cfg(target_arch = "wasm32")'.dependencies] futures-timer = { version = "3", features = ["wasm-bindgen"] } diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 73028111..4458bb67 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -229,6 +229,14 @@ pub struct ChainRuntime { spawner: Spawner, connections: Arc>>>, connection_setups: Arc>>, + transaction_operations: Arc>>, + next_transaction_operation_id: Arc, +} + +#[derive(Clone)] +struct TransactionOperation { + genesis_hash: Vec, + provider_operation_id: String, } impl ChainRuntime { @@ -240,6 +248,8 @@ impl ChainRuntime { spawner, connections: Arc::new(Mutex::new(HashMap::new())), connection_setups: Arc::new(Mutex::new(HashMap::new())), + transaction_operations: Arc::new(Mutex::new(HashMap::new())), + next_transaction_operation_id: Arc::new(AtomicU64::new(1)), } } @@ -503,11 +513,29 @@ impl ChainRuntime { ) -> Result { let method = "remote_chain_transaction_broadcast"; let connection = self.connection_for(method, &request.genesis_hash).await?; - let operation_id = connection + let provider_operation_id = connection .methods .transaction_v1_broadcast(&request.transaction) .await .map_err(|err| rpc_failure(method, err))?; + let operation_id = provider_operation_id.map(|provider_operation_id| { + let operation_id = format!( + "truapi-tx-{}", + self.next_transaction_operation_id + .fetch_add(1, Ordering::Relaxed) + ); + self.transaction_operations + .lock() + .expect("transaction operation mutex poisoned") + .insert( + operation_id.clone(), + TransactionOperation { + genesis_hash: request.genesis_hash, + provider_operation_id, + }, + ); + operation_id + }); Ok(RemoteChainTransactionBroadcastResponse { operation_id }) } @@ -518,12 +546,49 @@ impl ChainRuntime { request: RemoteChainTransactionStopRequest, ) -> Result<(), RuntimeFailure> { let method = "remote_chain_transaction_stop"; - let connection = self.connection_for(method, &request.genesis_hash).await?; - connection + let operation = self + .transaction_operations + .lock() + .expect("transaction operation mutex poisoned") + .remove(&request.operation_id) + .ok_or_else(|| { + RuntimeFailure::host_failure(method, "unknown transaction operation id") + })?; + if operation.genesis_hash != request.genesis_hash { + self.transaction_operations + .lock() + .expect("transaction operation mutex poisoned") + .insert(request.operation_id, operation); + return Err(RuntimeFailure::host_failure( + method, + "transaction operation belongs to a different chain", + )); + } + let connection = match self.connection_for(method, &request.genesis_hash).await { + Ok(connection) => connection, + Err(err) => { + self.transaction_operations + .lock() + .expect("transaction operation mutex poisoned") + .insert(request.operation_id, operation); + return Err(err); + } + }; + match connection .methods - .transaction_v1_stop(&request.operation_id) + .transaction_v1_stop(&operation.provider_operation_id) .await - .map_err(|err| rpc_failure(method, err)) + { + Ok(()) => Ok(()), + Err(err) if transaction_operation_already_finished(&err) => Ok(()), + Err(err) => { + self.transaction_operations + .lock() + .expect("transaction operation mutex poisoned") + .insert(request.operation_id, operation); + Err(rpc_failure(method, err)) + } + } } /// Genesis-pinned Subxt client for the chain identified by `genesis_hash`. @@ -537,6 +602,20 @@ impl ChainRuntime { Ok(self.subxt_connection(genesis_hash).await?.client) } + /// Raw JSON-RPC client for the chain identified by `genesis_hash`. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn rpc_client( + &self, + method: &'static str, + genesis_hash: &[u8], + ) -> Result { + Ok(self + .connection_for(method, genesis_hash) + .await? + .rpc_client + .clone()) + } + async fn subxt_connection( &self, genesis_hash: &[u8], @@ -1270,6 +1349,11 @@ fn hash_to_bytes(hash: H256) -> Vec { hash.as_bytes().to_vec() } +fn transaction_operation_already_finished(error: &SubxtRpcError) -> bool { + let reason = error.to_string(); + reason.contains("Invalid operation id") && reason.contains("-32602") +} + fn rpc_failure(method: &'static str, error: SubxtRpcError) -> RuntimeFailure { match error { SubxtRpcError::Client(_) | SubxtRpcError::DisconnectedWillReconnect(_) => { @@ -1722,6 +1806,54 @@ mod tests { assert!(sent[1].contains("chainHead_v1_header")); } + #[test] + fn transaction_stop_uses_host_handle_and_accepts_finished_provider_operation() { + let provider = Arc::new(ScriptedProvider::new(|request| { + let id = extract_id(request).unwrap(); + if request.contains("transaction_v1_broadcast") { + Some(format!( + r#"{{"jsonrpc":"2.0","id":"{id}","result":"REMOTE-OP"}}"# + )) + } else if request.contains("transaction_v1_stop") { + Some(format!( + r#"{{"jsonrpc":"2.0","id":"{id}","error":{{"code":-32602,"message":"Invalid operation id"}}}}"# + )) + } else { + None + } + })); + let runtime = ChainRuntime::new(provider.clone(), spawner_for_tests()); + let genesis_hash = vec![0x11; 32]; + + let broadcast = futures::executor::block_on(runtime.remote_chain_transaction_broadcast( + RemoteChainTransactionBroadcastRequest { + genesis_hash: genesis_hash.clone(), + transaction: vec![0], + }, + )) + .expect("broadcast succeeds"); + let local_id = broadcast.operation_id.expect("operation id"); + assert!(local_id.starts_with("truapi-tx-")); + assert_ne!(local_id, "REMOTE-OP"); + + futures::executor::block_on(runtime.remote_chain_transaction_stop( + RemoteChainTransactionStopRequest { + genesis_hash, + operation_id: local_id, + }, + )) + .expect("an already-finished provider operation is stopped idempotently"); + + let sent = provider.sent.lock().unwrap().clone(); + let stop: Value = serde_json::from_str( + sent.iter() + .find(|request| request.contains("transaction_v1_stop")) + .expect("stop request"), + ) + .unwrap(); + assert_eq!(stop["params"][0].as_str(), Some("REMOTE-OP")); + } + #[test] fn unpin_uses_typed_subxt_method_for_each_hash() { let provider = Arc::new(ScriptedProvider::new(|request| { diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 69da527f..9e396d80 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,82 @@ 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 }) + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl SigningHostRuntime { + /// Record statement-store accounts the host must keep renewed across + /// allowance periods. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.track_statement_renewal_targets"))] + pub async fn track_statement_renewal_targets( + &self, + targets: Vec, + ) -> Result<(), v01::GenericError> { + self.signing_host + .track_statement_renewal_targets(targets) + .await + .map_err(|reason| v01::GenericError { reason }) + } + + /// Run one statement-store renewal pass now and return per-target + /// outcomes. This is the primary entry point; hosts whose process cannot + /// stay alive (mobile) call it from an OS scheduler instead of + /// [`Self::start_statement_allowance_renewal`]. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.renew_statement_allowances"))] + pub async fn renew_statement_allowances( + &self, + ) -> Result + { + self.signing_host + .renew_statement_allowances() + .await + .map_err(|reason| v01::GenericError { reason }) + } + + /// Start the periodic statement-store renewal loop (hourly, plus a tick + /// just after each period boundary). Idempotent; the loop stops when this + /// runtime is dropped. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.start_statement_allowance_renewal"))] + pub fn start_statement_allowance_renewal(&self) { + self.signing_host.start_statement_allowance_renewal(); + } + + /// Delay until the next renewal pass is due, for hosts that schedule + /// wake-ups through an OS scheduler instead of the in-process loop. + pub fn next_statement_renewal_delay(&self) -> std::time::Duration { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|now| crate::statement_allowance::renewal::next_tick_delay(now.as_secs())) + .unwrap_or(std::time::Duration::from_secs(3_600)) + } } /// Product-scoped administration handle for host UI. diff --git a/rust/crates/truapi-server/src/host_logic.rs b/rust/crates/truapi-server/src/host_logic.rs index 12a1c516..e687fafa 100644 --- a/rust/crates/truapi-server/src/host_logic.rs +++ b/rust/crates/truapi-server/src/host_logic.rs @@ -4,6 +4,7 @@ //! storage, URL handler, notification center). Everything else lives here so //! iOS, Android, and web hosts share one canonical implementation. +pub mod attestation; pub mod bulletin; pub mod dotns; pub mod entropy; @@ -16,3 +17,4 @@ pub mod session; pub mod session_store; pub mod sso; pub mod statement_store; +pub mod transaction; diff --git a/rust/crates/truapi-server/src/host_logic/alias.rs b/rust/crates/truapi-server/src/host_logic/alias.rs new file mode 100644 index 00000000..3b6874e1 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/alias.rs @@ -0,0 +1,81 @@ +//! Bandersnatch ring-VRF product-account aliases (signing host). +//! +//! Mirrors the mobile app's `ProductAccountHolder.deriveAlias`: the alias is a +//! thin bandersnatch VRF output over a per-product context, using a +//! bandersnatch secret derived from the wallet's BIP-39 entropy. No ring +//! commitment or SRS is involved (that machinery is only for membership +//! proofs, which this path does not use). +//! +//! Reference: polkadot-app-ios-v2 `Packages/Products/.../ProductAccountHolder.swift` +//! and `verifiable-swift` over `paritytech/verifiable`. + +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +/// A product-account contextual alias. +pub struct ProductAlias { + /// 32-byte context identifier (blake2b-256 of the derivation path). + pub context: [u8; 32], + /// 32-byte ring-VRF alias output. + pub alias: [u8; 32], +} + +/// Derive the contextual alias for a product account from the wallet entropy. +/// +/// - `context = blake2b_256("/product/{product_id}/{derivation_index}")` +/// - `bandersnatch_entropy = blake2b_256(root_entropy)` +/// - `alias = BandersnatchVrf::alias_in_context(new_secret(bandersnatch_entropy), context)` +pub fn derive_product_alias( + root_entropy: &[u8], + product_id: &str, + derivation_index: u32, +) -> Result { + let derivation_path = format!("/product/{product_id}/{derivation_index}"); + let context = blake2b256(derivation_path.as_bytes()); + let bandersnatch_entropy = blake2b256(root_entropy); + let secret = BandersnatchVrfVerifiable::new_secret(bandersnatch_entropy); + let alias = BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("ring-VRF alias derivation failed: {err:?}"))?; + Ok(ProductAlias { context, alias }) +} + +fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .hash(message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The alias is deterministic in the entropy, product id, and index, and + /// the context is the blake2b-256 of the derivation path. + #[test] + fn alias_is_deterministic_with_expected_context() { + let entropy = [0xABu8; 16]; + let first = derive_product_alias(&entropy, "truapi-playground.dot", 0).unwrap(); + let again = derive_product_alias(&entropy, "truapi-playground.dot", 0).unwrap(); + + assert_eq!(first.context, again.context); + assert_eq!(first.alias, again.alias); + assert_eq!( + first.context, + blake2b256(b"/product/truapi-playground.dot/0") + ); + } + + #[test] + fn alias_varies_by_product_and_index() { + let entropy = [0xABu8; 16]; + let base = derive_product_alias(&entropy, "a.dot", 0).unwrap(); + let other_product = derive_product_alias(&entropy, "b.dot", 0).unwrap(); + let other_index = derive_product_alias(&entropy, "a.dot", 1).unwrap(); + + assert_ne!(base.alias, other_product.alias); + assert_ne!(base.alias, other_index.alias); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/attestation.rs b/rust/crates/truapi-server/src/host_logic/attestation.rs new file mode 100644 index 00000000..4befc7fe --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/attestation.rs @@ -0,0 +1,167 @@ +//! Lite-person username registration parameters (signing host, native only). +//! +//! Builds the client-side proofs the People-chain identity backend needs to +//! attest a lite username for an account: an sr25519 proof-of-ownership, a +//! bandersnatch ring-VRF member key + plain-VRF proof, and an sr25519 +//! consumer-registration signature. The backend submits the on-chain +//! `register_lite_person` extrinsic; the host never signs a chain extrinsic. +//! +//! Byte layout mirrors signing-bot `src/core/attestation.ts` for backend +//! parity. The registered account is the account whose secret signs here; the +//! paired host resolves the username from `Resources.Consumers[that account]`. + +use parity_scale_codec::Encode; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use crate::host_logic::product_account::{ + SR25519_SIGNING_CONTEXT, derive_sr25519_hard_path, product_public_key_to_address, +}; +use crate::host_logic::sso::pairing::derive_p256_keypair_from_entropy; + +/// sr25519 proof-of-ownership message prefix (exact bytes; one space). +const REGISTER_PREFIX: &[u8] = b"pop:people-lite:register using"; +/// Domain label for the P-256 identifier key advertised to the backend. +const IDENTIFIER_KEY_LABEL: &[u8] = b"chat-encryption"; + +/// Client-computed parameters for `POST /usernames`. +pub struct LiteRegistration { + /// SS58 (prefix 42) of the candidate account. + pub candidate_account_id: String, + /// Raw 32-byte candidate public key (the future `Resources.Consumers` key). + pub candidate_public_key: [u8; 32], + /// sr25519 signature over `prefix ‖ candidate_pub ‖ ring_vrf_key`. + pub candidate_signature: [u8; 64], + /// Bandersnatch ring-VRF member key. + pub ring_vrf_key: [u8; 32], + /// Plain bandersnatch VRF proof over the same proof message. + pub proof_of_ownership: [u8; 64], + /// 65-byte uncompressed P-256 identifier key. + pub identifier_key: [u8; 65], + /// sr25519 signature over the SCALE consumer-registration tuple. + pub consumer_registration_signature: [u8; 64], +} + +/// Build the lite-person registration parameters for `username_base` +/// (6+ lowercase letters, no digit suffix) against the backend `verifier`. +pub fn build_lite_registration( + entropy: &[u8], + verifier_account_id: [u8; 32], + username_base: &str, +) -> Result { + // The candidate is the `//wallet//sso` statement account, matching the + // account the SSO responder presents as `identity_account_id`. + let candidate = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) + .map_err(|err| format!("//wallet//sso derivation failed: {err}"))?; + let candidate_public_key = candidate.public.to_bytes(); + + let vrf_entropy = blake2b256(entropy); + let vrf_secret = BandersnatchVrfVerifiable::new_secret(vrf_entropy); + let ring_vrf_key = BandersnatchVrfVerifiable::member_from_secret(&vrf_secret); + + let mut proof_message = Vec::with_capacity(REGISTER_PREFIX.len() + 64); + proof_message.extend_from_slice(REGISTER_PREFIX); + proof_message.extend_from_slice(&candidate_public_key); + proof_message.extend_from_slice(&ring_vrf_key); + + let candidate_signature = candidate + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &proof_message, &candidate.public) + .to_bytes(); + let proof_of_ownership = BandersnatchVrfVerifiable::sign(&vrf_secret, &proof_message) + .map_err(|err| format!("ring-VRF proof-of-ownership failed: {err:?}"))?; + + let (_identifier_secret, identifier_key) = + derive_p256_keypair_from_entropy(entropy, IDENTIFIER_KEY_LABEL) + .map_err(|err| format!("identifier key derivation failed: {err}"))?; + + // SCALE Tuple(Bytes(32), Bytes(32), Bytes(65), str, Option(Bytes())=None). + let mut consumer_message = Vec::new(); + consumer_message.extend_from_slice(&candidate_public_key); + consumer_message.extend_from_slice(&verifier_account_id); + consumer_message.extend_from_slice(&identifier_key); + consumer_message.extend_from_slice(&username_base.encode()); + consumer_message.push(0u8); + let consumer_registration_signature = candidate + .secret + .sign_simple( + SR25519_SIGNING_CONTEXT, + &consumer_message, + &candidate.public, + ) + .to_bytes(); + + Ok(LiteRegistration { + candidate_account_id: product_public_key_to_address(candidate_public_key), + candidate_public_key, + candidate_signature, + ring_vrf_key, + proof_of_ownership, + identifier_key, + consumer_registration_signature, + }) +} + +fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .hash(message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + use schnorrkel::{PublicKey, Signature}; + + const ENTROPY: [u8; 16] = [0xAB; 16]; + + #[test] + fn registration_params_have_expected_shapes_and_verify() { + let verifier = [0x11u8; 32]; + let reg = build_lite_registration(&ENTROPY, verifier, "headlesstester").unwrap(); + + assert_eq!(reg.identifier_key[0], 0x04, "P-256 uncompressed prefix"); + assert!( + reg.candidate_account_id + .chars() + .all(|c| c.is_alphanumeric()) + ); + + // candidateSignature verifies over prefix ‖ candidate_pub ‖ ring_vrf_key. + let mut proof_message = Vec::new(); + proof_message.extend_from_slice(REGISTER_PREFIX); + proof_message.extend_from_slice(®.candidate_public_key); + proof_message.extend_from_slice(®.ring_vrf_key); + let public = PublicKey::from_bytes(®.candidate_public_key).unwrap(); + let sig = Signature::from_bytes(®.candidate_signature).unwrap(); + assert!( + public + .verify_simple(SR25519_SIGNING_CONTEXT, &proof_message, &sig) + .is_ok(), + "candidate signature verifies" + ); + + // proofOfOwnership verifies as a plain VRF signature for the member key. + assert!( + BandersnatchVrfVerifiable::verify_signature( + ®.proof_of_ownership, + &proof_message, + ®.ring_vrf_key + ), + "ring-VRF proof-of-ownership validates against the member key" + ); + } + + #[test] + fn registration_is_deterministic_per_entropy_and_username() { + let verifier = [0x22u8; 32]; + let first = build_lite_registration(&ENTROPY, verifier, "aliceheadless").unwrap(); + let again = build_lite_registration(&ENTROPY, verifier, "aliceheadless").unwrap(); + assert_eq!(first.candidate_public_key, again.candidate_public_key); + assert_eq!(first.ring_vrf_key, again.ring_vrf_key); + assert_eq!(first.candidate_account_id, again.candidate_account_id); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/entropy.rs b/rust/crates/truapi-server/src/host_logic/entropy.rs index 652a880c..47176f2f 100644 --- a/rust/crates/truapi-server/src/host_logic/entropy.rs +++ b/rust/crates/truapi-server/src/host_logic/entropy.rs @@ -23,8 +23,14 @@ pub fn derive_product_entropy( product_id: &str, key: &[u8], ) -> Result<[u8; 32], ProductEntropyError> { - let root_entropy_source = blake2b256_keyed(entropy_secret, DOMAIN_SEPARATOR); - derive_product_entropy_from_source(&root_entropy_source, product_id, key) + derive_product_entropy_from_source(&root_entropy_source(entropy_secret), product_id, key) +} + +/// Pre-hashed root entropy source (RFC-0007 layer 1). Signing hosts share this +/// value with paired hosts during the SSO handshake so both sides derive the +/// same product entropy. +pub fn root_entropy_source(entropy_secret: &[u8]) -> [u8; 32] { + blake2b256_keyed(entropy_secret, DOMAIN_SEPARATOR) } /// Derive product-scoped entropy when the session already stores the diff --git a/rust/crates/truapi-server/src/host_logic/product_account.rs b/rust/crates/truapi-server/src/host_logic/product_account.rs index 2a660db7..f040841d 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -97,6 +97,23 @@ pub fn public_key_from_address(address: &str) -> Option<[u8; 32]> { Some(subxt::utils::AccountId32::from_str(address).ok()?.0) } +/// Derive an sr25519 keypair down a path of hard string junctions from the +/// canonical BIP-39 root key. +pub fn derive_sr25519_hard_path( + entropy: &[u8], + junctions: &[&str], +) -> Result { + let mut keypair = derive_root_keypair_from_entropy(entropy)?; + for junction in junctions { + let chain_code = ChainCode(create_chain_code(junction)?); + let (mini_secret, _) = keypair + .secret + .hard_derive_mini_secret_key(Some(chain_code), b""); + keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + } + Ok(keypair) +} + /// Create a Substrate soft-derivation chain code for one junction. fn create_chain_code(code: &str) -> Result<[u8; 32], ProductAccountError> { let encoded = if !code.is_empty() && code.bytes().all(|byte| byte.is_ascii_digit()) { diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index b46d4fe8..26626610 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -13,27 +13,30 @@ //! //! Deployed extension variants are tracked as a host-spec divergence: //! -//! Field order and enum variant order are kept wire-compatible with host-papp: -//! -//! -//! -//! +//! Field order and enum variant order are kept wire-compatible with +//! `@novasamatech/host-papp` 0.8.11: +//! +//! +//! +//! +//! use parity_scale_codec::{Decode, Encode, OptionBool}; use truapi::latest::{ AccountId, AllocatableResource, HostAccountCreateProofResponse, HostAccountGetAliasResponse, - HostSignPayloadRequest, HostSignRawRequest, LegacyAccountTxPayload, ProductAccountId, - ProductAccountTxPayload, ProductProofContext, RawPayload, RingLocation, + LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, ProductProofContext, + RawPayload, RingLocation, }; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::pairing::{ AES_GCM_NONCE_LEN, SsoStatementData, decrypt_session_statement_data, encrypt_session_statement_data, encrypt_session_statement_data_with_nonce, + peer_response_channel, }; use crate::host_logic::statement_store::{ - build_signed_session_request_statement, current_unix_secs, decode_verified_statement_data, - statement_expiry_elapsed, + build_signed_session_request_statement, build_signed_statement, current_unix_secs, + decode_verified_statement_data, statement_expiry_elapsed, }; pub mod v1; @@ -89,7 +92,7 @@ pub enum SigningRequest { /// Request sent when a product asks the paired signing host to sign a Substrate /// payload with a product-derived account. /// -/// Built from [`HostSignPayloadRequest`] but kept as a dedicated wire type +/// Built from [`truapi::v01::HostSignPayloadRequest`] but kept as a dedicated wire type /// because the host-papp SSO dialect flattens the public request payload and /// encodes `with_signed_transaction` as `OptionBool`. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -113,7 +116,7 @@ pub struct SigningPayloadRequest { } impl SigningPayloadRequest { - fn from_host_request(value: HostSignPayloadRequest) -> Self { + fn from_host_request(value: truapi::v01::HostSignPayloadRequest) -> Self { let payload = value.payload; Self { product_account_id: value.account, @@ -136,10 +139,35 @@ impl SigningPayloadRequest { } } +impl From for truapi::v01::HostSignPayloadRequest { + fn from(value: SigningPayloadRequest) -> Self { + Self { + account: value.product_account_id, + payload: truapi::v01::HostSignPayloadData { + block_hash: value.block_hash, + block_number: value.block_number, + era: value.era, + genesis_hash: value.genesis_hash, + method: value.method, + nonce: value.nonce, + spec_version: value.spec_version, + tip: value.tip, + transaction_version: value.transaction_version, + signed_extensions: value.signed_extensions, + version: value.version, + asset_id: value.asset_id, + metadata_hash: value.metadata_hash, + mode: value.mode, + with_signed_transaction: value.with_signed_transaction.0, + }, + } + } +} + /// Request sent when a product asks the paired signing host to sign raw bytes or a /// string message with a product-derived account. /// -/// Built from [`HostSignRawRequest`] and wrapped in +/// Built from [`truapi::v01::HostSignRawRequest`] and wrapped in /// [`v1::RemoteMessage::SignRequest`] before being encrypted into an SSO session /// statement. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -149,7 +177,7 @@ pub struct SigningRawRequest { } impl SigningRawRequest { - fn from_host_request(value: HostSignRawRequest) -> Self { + fn from_host_request(value: truapi::v01::HostSignRawRequest) -> Self { Self { product_account_id: value.account, data: value.payload.into(), @@ -188,6 +216,24 @@ impl From for SigningRawPayload { } } +impl From for RawPayload { + fn from(value: SigningRawPayload) -> Self { + match value { + SigningRawPayload::Bytes(bytes) => Self::Bytes { bytes }, + SigningRawPayload::Payload(payload) => Self::Payload { payload }, + } + } +} + +impl From for truapi::v01::HostSignRawRequest { + fn from(value: SigningRawRequest) -> Self { + Self { + account: value.product_account_id, + payload: value.data.into(), + } + } +} + /// Response returned by the signing host for a product-account signing request. /// /// Decoded from [`v1::RemoteMessage::SignResponse`] while the runtime is waiting @@ -215,6 +261,24 @@ pub struct SignRawLegacyResponse { pub signature: Result, String>, } +/// Request exact statement-store proof signing with a product-derived account. +/// +/// Raw signing cannot be reused because it applies the public +/// `...` payload convention, while statement proofs sign the +/// unsigned statement payload bytes directly. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct StatementStoreProductSignRequest { + pub product_account_id: ProductAccountId, + pub payload: Vec, +} + +/// Response returned for exact statement-store proof signing. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct StatementStoreProductSignResponse { + pub responding_to: String, + pub signature: Result, String>, +} + /// Failure returned by the Account Holder for a ring-VRF proof or alias request. /// /// Mirrors the identical error sets of `Account::create_account_proof` and @@ -391,6 +455,7 @@ pub enum SsoSessionStatement { pub enum SsoRemoteResponse { Sign(SigningResponse), SignRawLegacy(SignRawLegacyResponse), + StatementStoreProductSign(StatementStoreProductSignResponse), RingVrfAlias(RingVrfAliasResponse), RingVrfProof(RingVrfProofResponse), ResourceAllocation(ResourceAllocationResponse), @@ -496,6 +561,11 @@ fn remote_response_for_message( { Some(SsoRemoteResponse::SignRawLegacy(response)) } + v1::RemoteMessage::StatementStoreProductSignResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::StatementStoreProductSign(response)) + } v1::RemoteMessage::ResourceAllocationResponse(response) if response.responding_to == expected_remote_message_id => { @@ -510,8 +580,28 @@ fn remote_response_for_message( } } +/// Build an exact statement-store proof signing request. +pub fn statement_store_product_sign_message( + message_id: String, + product_account_id: ProductAccountId, + payload: Vec, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(v1::RemoteMessage::StatementStoreProductSignRequest( + StatementStoreProductSignRequest { + product_account_id, + payload, + }, + )), + } +} + /// Build a signing-host payload-signing request message. -pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) -> RemoteMessage { +pub fn sign_payload_message( + message_id: String, + request: truapi::v01::HostSignPayloadRequest, +) -> RemoteMessage { RemoteMessage { message_id, data: RemoteMessageData::V1(v1::RemoteMessage::SignRequest(Box::new( @@ -521,7 +611,10 @@ pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) } /// Build a signing-host raw-signing request message. -pub fn sign_raw_message(message_id: String, request: HostSignRawRequest) -> RemoteMessage { +pub fn sign_raw_message( + message_id: String, + request: truapi::v01::HostSignRawRequest, +) -> RemoteMessage { RemoteMessage { message_id, data: RemoteMessageData::V1(v1::RemoteMessage::SignRequest(Box::new( @@ -636,6 +729,77 @@ pub fn create_transaction_legacy_message( } } +/// Inbound request decoded from a peer-signed session statement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IncomingSsoRequest { + /// Statement-level request id used by the transport acknowledgement. + pub request_id: String, + /// Application messages batched into the request. + pub messages: Vec, +} + +/// Decode a peer request for a signing-host responder. +/// +/// Own echoes, response acknowledgements, and expired statements are ignored. +pub fn decode_incoming_sso_request( + session: &SsoSessionInfo, + statement: &[u8], +) -> Result, String> { + let verified = + decode_verified_statement_data(statement, None).map_err(|err| err.to_string())?; + if verified.signer == session.ss_public_key { + return Ok(None); + } + if verified.signer != session.identity_account_id { + return Err("statement proof signer does not match expected peer".to_string()); + } + if verified + .expiry + .is_some_and(|expiry| statement_expiry_elapsed(expiry, current_unix_secs())) + { + return Ok(None); + } + match decrypt_session_statement_data(session, &verified.data)? { + SsoStatementData::Response { .. } => Ok(None), + SsoStatementData::Request { request_id, data } => { + let messages = data + .iter() + .map(|message| { + RemoteMessage::decode(&mut message.as_slice()) + .map_err(|err| format!("invalid SSO remote message: {err}")) + }) + .collect::, _>>()?; + Ok(Some(IncomingSsoRequest { + request_id, + messages, + })) + } + } +} + +/// Build the signed transport acknowledgement for a peer-initiated request. +pub fn build_signed_session_response_statement( + session: &SsoSessionInfo, + request_id: String, + response_code: u8, + expiry: u64, +) -> Result, String> { + let encrypted = encrypt_session_statement_data( + session, + &SsoStatementData::Response { + request_id, + response_code, + }, + )?; + build_signed_statement( + session, + peer_response_channel(session), + session.session_id_peer, + encrypted, + expiry, + ) +} + /// Build a signed outbound SSO request statement with a random nonce. pub fn build_outgoing_request_statement( session: &SsoSessionInfo, @@ -708,6 +872,7 @@ mod tests { use p256::elliptic_curve::sec1::ToEncodedPoint; use schnorrkel::{ExpansionMode, MiniSecretKey}; use truapi::latest::{HostSignPayloadData, TxPayloadExtension}; + use truapi::v01::RingLocationJunction; fn account() -> ProductAccountId { ProductAccountId { @@ -762,7 +927,7 @@ mod tests { fn raw_sign_request_uses_remote_message_variant_indices() { let message = sign_raw_message( "m1".to_string(), - HostSignRawRequest { + truapi::v01::HostSignRawRequest { account: account(), payload: RawPayload::Bytes { bytes: vec![0xde, 0xad], @@ -798,11 +963,84 @@ mod tests { assert_eq!(legacy_raw[..3], [0, 0, 10]); } + #[test] + fn ring_vrf_messages_match_host_papp_0_8_11_fixtures() { + let context = ProductProofContext { + product_id: "voting.dot".to_string(), + suffix: vec![0, 1, 2, 3], + }; + let ring_location = RingLocation { + chain_id: [0x11; 32], + junctions: vec![ + RingLocationJunction::PalletInstance(67), + RingLocationJunction::CollectionId(b"pop".to_vec()), + ], + }; + + let alias = alias_request_message( + "m-alias".to_string(), + "caller.dot".to_string(), + context.clone(), + ring_location.clone(), + ); + let proof = proof_request_message( + "m-proof".to_string(), + "caller.dot".to_string(), + context, + ring_location, + b"vote".to_vec(), + ); + let contextual_alias = HostAccountGetAliasResponse { + context: [0x22; 32], + alias: vec![0x33, 0x44], + }; + let alias_response = RemoteMessage { + message_id: "r-alias".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfAliasResponse( + RingVrfAliasResponse { + responding_to: "m-alias".to_string(), + payload: Ok(contextual_alias.clone()), + }, + )), + }; + let proof_response = RemoteMessage { + message_id: "r-proof".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfProofResponse( + RingVrfProofResponse { + responding_to: "m-proof".to_string(), + payload: Ok(HostAccountCreateProofResponse { + proof: vec![0x55, 0x66], + contextual_alias, + ring_index: 7, + ring_revision: 9, + }), + }, + )), + }; + + assert_host_papp_0_8_11_fixture( + alias, + "0x1c6d2d616c69617300032863616c6c65722e646f7428766f74696e672e646f7410000102031111111111111111111111111111111111111111111111111111111111111111080043010c706f70", + ); + assert_host_papp_0_8_11_fixture( + proof, + "0x1c6d2d70726f6f66000c2863616c6c65722e646f7428766f74696e672e646f7410000102031111111111111111111111111111111111111111111111111111111111111111080043010c706f7010766f7465", + ); + assert_host_papp_0_8_11_fixture( + alias_response, + "0x1c722d616c69617300041c6d2d616c696173002222222222222222222222222222222222222222222222222222222222222222083344", + ); + assert_host_papp_0_8_11_fixture( + proof_response, + "0x1c722d70726f6f66000d1c6d2d70726f6f660008556622222222222222222222222222222222222222222222222222222222222222220833440700000009000000", + ); + } + fn sequential_bytes(start: u8) -> [u8; N] { std::array::from_fn(|index| start.wrapping_add(index as u8)) } - fn assert_host_papp_0_8_8_fixture(message: RemoteMessage, expected: &str) { + fn assert_host_papp_0_8_11_fixture(message: RemoteMessage, expected: &str) { assert_eq!( hex::encode(message.encode()), expected.trim_start_matches("0x") @@ -810,7 +1048,7 @@ mod tests { } #[test] - fn resource_allocation_message_matches_host_papp_0_8_8_fixture() { + fn resource_allocation_message_matches_host_papp_0_8_11_fixture() { let message = resource_allocation_message( "m-resource".to_string(), "truapi-playground.dot".to_string(), @@ -823,14 +1061,14 @@ mod tests { OnExistingAllowancePolicy::Increase, ); - assert_host_papp_0_8_8_fixture( + assert_host_papp_0_8_11_fixture( message, "0x286d2d7265736f757263650005547472756170692d706c617967726f756e642e646f7410000102090000000301", ); } #[test] - fn create_transaction_message_matches_host_papp_0_8_8_fixture() { + fn create_transaction_message_matches_host_papp_0_8_11_fixture() { let message = create_transaction_message( "m-product-tx".to_string(), ProductAccountTxPayload { @@ -849,14 +1087,14 @@ mod tests { }, ); - assert_host_papp_0_8_8_fixture( + assert_host_papp_0_8_11_fixture( message, "0x306d2d70726f647563742d7478000700547472756170692d706c617967726f756e642e646f7400000000202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f0800000428436865636b4e6f6e6365040108020300", ); } #[test] - fn playground_create_transaction_message_matches_host_papp_0_8_8_fixture() { + fn playground_create_transaction_message_matches_host_papp_0_8_11_fixture() { let message = create_transaction_message( "create-transaction-1".to_string(), ProductAccountTxPayload { @@ -875,14 +1113,14 @@ mod tests { }, ); - assert_host_papp_0_8_8_fixture( + assert_host_papp_0_8_11_fixture( message, "0x506372656174652d7472616e73616374696f6e2d31000700547472756170692d706c617967726f756e642e646f7400000000bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f0800000000", ); } #[test] - fn create_transaction_legacy_message_matches_host_papp_0_8_8_fixture() { + fn create_transaction_legacy_message_matches_host_papp_0_8_11_fixture() { let message = create_transaction_legacy_message( "m-legacy-tx".to_string(), LegacyAccountTxPayload { @@ -898,15 +1136,15 @@ mod tests { }, ); - assert_host_papp_0_8_8_fixture( + assert_host_papp_0_8_11_fixture( message, "0x2c6d2d6c65676163792d7478000900000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f0800000428436865636b4e6f6e6365040108020300", ); } #[test] - fn sign_raw_legacy_messages_match_host_papp_0_8_8_fixtures() { - assert_host_papp_0_8_8_fixture( + fn sign_raw_legacy_messages_match_host_papp_0_8_11_fixtures() { + assert_host_papp_0_8_11_fixture( sign_raw_legacy_message( "m-legacy-raw".to_string(), sequential_bytes(0), @@ -916,7 +1154,7 @@ mod tests { ), "0x306d2d6c65676163792d726177000a000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f00084869", ); - assert_host_papp_0_8_8_fixture( + assert_host_papp_0_8_11_fixture( sign_raw_legacy_message( "m-legacy-raw-payload".to_string(), sequential_bytes(0), @@ -930,7 +1168,7 @@ mod tests { #[test] fn option_bool_matches_host_papp_option_bool_encoding() { - let mut request = HostSignPayloadRequest { + let mut request = truapi::v01::HostSignPayloadRequest { account: account(), payload: HostSignPayloadData { block_hash: vec![], @@ -997,7 +1235,7 @@ mod tests { let session = session(); let remote_message = sign_raw_message( "remote-1".to_string(), - HostSignRawRequest { + truapi::v01::HostSignRawRequest { account: account(), payload: RawPayload::Payload { payload: "hello".to_string(), @@ -1037,7 +1275,7 @@ mod tests { let session = session(); let remote_message = sign_raw_message( "remote-1".to_string(), - HostSignRawRequest { + truapi::v01::HostSignRawRequest { account: account(), payload: RawPayload::Payload { payload: "hello".to_string(), @@ -1059,6 +1297,186 @@ mod tests { assert_eq!(decoded, None); } + fn host_and_responder_sessions() -> (SsoSessionInfo, SsoSessionInfo) { + use crate::host_logic::sso::pairing::{ + ResponderIdentity, create_pairing_bootstrap, derive_p256_keypair_from_entropy, + establish_responder_session_info, establish_sso_session_info, + }; + use truapi_platform::{HostInfo, PairingHostConfig, PlatformInfo}; + + let config = PairingHostConfig::new( + HostInfo { + name: "Test Host".to_string(), + icon: None, + version: None, + }, + PlatformInfo::default(), + [0; 32], + [0xbb; 32], + "polkadotapp".to_string(), + ) + .expect("test pairing config is valid"); + let bootstrap = create_pairing_bootstrap(&config).unwrap(); + let statement_keypair = MiniSecretKey::from_bytes(&[7; 32]) + .unwrap() + .expand_to_keypair(ExpansionMode::Ed25519); + let (encryption_secret_key, encryption_public_key) = + derive_p256_keypair_from_entropy(&[0xAB; 16], b"sso-encryption").unwrap(); + let responder = ResponderIdentity { + statement_secret: statement_keypair.secret.to_bytes(), + statement_public_key: statement_keypair.public.to_bytes(), + encryption_secret_key, + encryption_public_key, + }; + let responder_session = establish_responder_session_info( + &responder, + bootstrap.statement_store_public_key, + bootstrap.encryption_public_key, + ) + .unwrap(); + let host_session = establish_sso_session_info( + &bootstrap, + responder.statement_public_key, + responder.encryption_public_key, + ) + .unwrap(); + (host_session, responder_session) + } + + /// A host-built request statement decodes on the responder side into the + /// batched remote messages, and the responder's ack plus response + /// statements resolve the host's pending wait. + #[test] + fn host_request_round_trips_through_responder_statements() { + let (host_session, responder_session) = host_and_responder_sessions(); + let request = sign_raw_message( + "remote-1".to_string(), + truapi::v01::HostSignRawRequest { + account: account(), + payload: RawPayload::Payload { + payload: "hello".to_string(), + }, + }, + ); + let host_statement = build_outgoing_request_statement( + &host_session, + "statement-1".to_string(), + vec![request.clone()], + fresh_expiry(), + ) + .unwrap(); + + let incoming = decode_incoming_sso_request(&responder_session, &host_statement) + .unwrap() + .expect("responder should surface the host request"); + assert_eq!( + incoming, + IncomingSsoRequest { + request_id: "statement-1".to_string(), + messages: vec![request], + } + ); + + let ack = build_signed_session_response_statement( + &responder_session, + incoming.request_id.clone(), + 0, + fresh_expiry(), + ) + .unwrap(); + assert_eq!( + decode_sso_session_statement(&host_session, &ack, "statement-1", "remote-1").unwrap(), + Some(SsoSessionStatement::RequestAccepted) + ); + + let response = RemoteMessage { + message_id: "resp-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::SignResponse(SigningResponse { + responding_to: "remote-1".to_string(), + payload: Ok(SigningPayloadResponseData { + signature: vec![9; 64], + signed_transaction: None, + }), + })), + }; + let response_statement = build_outgoing_request_statement( + &responder_session, + "resp-statement-1".to_string(), + vec![response], + fresh_expiry(), + ) + .unwrap(); + let decoded = decode_sso_session_statement( + &host_session, + &response_statement, + "statement-1", + "remote-1", + ) + .unwrap(); + assert_eq!( + decoded, + Some(SsoSessionStatement::RemoteResponse( + SsoRemoteResponse::Sign(SigningResponse { + responding_to: "remote-1".to_string(), + payload: Ok(SigningPayloadResponseData { + signature: vec![9; 64], + signed_transaction: None, + }), + }) + )) + ); + } + + #[test] + fn responder_ignores_own_echo_and_transport_acks() { + let (host_session, responder_session) = host_and_responder_sessions(); + let own_statement = build_outgoing_request_statement( + &responder_session, + "resp-statement-1".to_string(), + vec![RemoteMessage { + message_id: "resp-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }], + fresh_expiry(), + ) + .unwrap(); + assert_eq!( + decode_incoming_sso_request(&responder_session, &own_statement).unwrap(), + None + ); + + let host_ack = build_signed_session_response_statement( + &host_session, + "resp-statement-1".to_string(), + 0, + fresh_expiry(), + ) + .unwrap(); + assert_eq!( + decode_incoming_sso_request(&responder_session, &host_ack).unwrap(), + None + ); + } + + #[test] + fn responder_ignores_expired_host_request() { + let (host_session, responder_session) = host_and_responder_sessions(); + let stale_statement = build_outgoing_request_statement( + &host_session, + "statement-1".to_string(), + vec![RemoteMessage { + message_id: "remote-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }], + elapsed_expiry(), + ) + .unwrap(); + + assert_eq!( + decode_incoming_sso_request(&responder_session, &stale_statement).unwrap(), + None + ); + } fn response_ack_statement(session: &SsoSessionInfo, expiry: u64) -> Vec { let encrypted = encrypt_session_statement_data_with_nonce( session, diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs index 9d83d055..6f989b06 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs @@ -11,7 +11,8 @@ use super::{ CreateTransactionLegacyRequest, CreateTransactionRequest, CreateTransactionResponse, ResourceAllocationRequest, ResourceAllocationResponse, RingVrfAliasRequest, RingVrfAliasResponse, RingVrfProofRequest, RingVrfProofResponse, SignRawLegacyRequest, - SignRawLegacyResponse, SigningRequest, SigningResponse, + SignRawLegacyResponse, SigningRequest, SigningResponse, StatementStoreProductSignRequest, + StatementStoreProductSignResponse, }; /// v1 messages exchanged with the paired signing host over the encrypted SSO channel. @@ -34,4 +35,6 @@ pub enum RemoteMessage { SignRawLegacyResponse(SignRawLegacyResponse), RingVrfProofRequest(RingVrfProofRequest), RingVrfProofResponse(RingVrfProofResponse), + StatementStoreProductSignRequest(StatementStoreProductSignRequest), + StatementStoreProductSignResponse(StatementStoreProductSignResponse), } diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs index 4c09adb5..47af4ea0 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -10,7 +10,7 @@ //! //! The SCALE handshake codecs are kept wire-compatible with host-papp's v2 //! handshake codec: -//! +//! use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Nonce}; @@ -98,7 +98,7 @@ pub mod v2; /// /// /// Mirrors `@novasamatech/statement-store` session statement data: -/// +/// #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SsoStatementData { Request { @@ -111,6 +111,53 @@ pub enum SsoStatementData { }, } +/// Decode a pairing deeplink (or its bare handshake hex) into the advertised +/// handshake proposal. Inverse of [`build_pairing_deeplink`]. +pub fn decode_pairing_deeplink(deeplink: &str) -> Result { + let hex_payload = match deeplink.split_once("?handshake=") { + Some((_, hex_payload)) => hex_payload, + None => deeplink, + }; + let encoded = hex::decode(hex_payload.trim()) + .map_err(|err| format!("invalid pairing deeplink hex: {err}"))?; + let mut input = encoded.as_slice(); + let proposal = VersionedHandshakeProposal::decode(&mut input) + .map_err(|err| format!("invalid pairing handshake proposal: {err}"))?; + if !input.is_empty() { + return Err("invalid pairing handshake proposal: trailing bytes".to_string()); + } + Ok(proposal) +} + +/// Encrypt a v2 handshake response for the host that advertised +/// `host_encryption_public_key`. Inverse of [`decrypt_v2_handshake_response`]: +/// a fresh ephemeral P-256 key is used per response so each Pending/Success +/// statement carries an independent ciphertext. +pub fn encrypt_v2_handshake_response( + host_encryption_public_key: [u8; 65], + response: &v2::EncryptedResponse, +) -> Result { + let (ephemeral_secret, ephemeral_public) = + generate_p256_keypair().map_err(|err| err.to_string())?; + let shared_secret = shared_secret(ephemeral_secret, host_encryption_public_key)?; + let aes_key = aes_key_from_shared_secret(&shared_secret)?; + let mut nonce = [0u8; AES_GCM_NONCE_LEN]; + getrandom::getrandom(&mut nonce) + .map_err(|err| format!("failed to generate AES-GCM nonce: {err}"))?; + let cipher = Aes256Gcm::new_from_slice(&aes_key) + .map_err(|err| format!("failed to initialize AES-GCM: {err}"))?; + let mut encrypted_message = nonce.to_vec(); + encrypted_message.extend( + cipher + .encrypt(Nonce::from_slice(&nonce), response.encode().as_slice()) + .map_err(|err| format!("failed to encrypt SSO handshake response: {err}"))?, + ); + Ok(VersionedHandshakeResponse::V2 { + encrypted_message, + public_key: ephemeral_public, + }) +} + /// Decode wallet-posted pairing handshake data from SCALE bytes. pub fn decode_app_handshake_data(blob: &[u8]) -> Result { let mut input = blob; @@ -175,6 +222,93 @@ pub fn establish_sso_session_info( }) } +/// Signing-host key material answering one pairing proposal. +/// +/// The statement keypair signs every session statement (its public key is the +/// `identityAccountId` the pairing host binds the session to), and the P-256 +/// secret is the persistent `sso_enc` key both sides feed into the session +/// ECDH. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponderIdentity { + pub statement_secret: [u8; 64], + pub statement_public_key: [u8; 32], + pub encryption_secret_key: [u8; 32], + pub encryption_public_key: [u8; 65], +} + +/// Derive the SSO session channels from the responder (signing host) +/// perspective after answering the handshake advertised by +/// `host_statement_account_id` / `host_encryption_public_key`. +/// +/// Mirror of [`establish_sso_session_info`]: the responder's `session_id_own` +/// equals the pairing host's `session_id_peer` and vice versa, so statements +/// built from either side land on the topics the other side subscribes to. +pub fn establish_responder_session_info( + identity: &ResponderIdentity, + host_statement_account_id: [u8; 32], + host_encryption_public_key: [u8; 65], +) -> Result { + let shared_secret = shared_secret(identity.encryption_secret_key, host_encryption_public_key)?; + let shared_secret_bytes: [u8; 32] = (*shared_secret.raw_secret_bytes()).into(); + let session_id_own = create_session_id( + shared_secret_bytes, + identity.statement_public_key, + host_statement_account_id, + ); + let session_id_peer = create_session_id( + shared_secret_bytes, + host_statement_account_id, + identity.statement_public_key, + ); + + Ok(SsoSessionInfo { + ss_secret: identity.statement_secret, + ss_public_key: identity.statement_public_key, + enc_secret: identity.encryption_secret_key, + peer_enc_pubkey: host_encryption_public_key, + identity_account_id: host_statement_account_id, + session_id_own, + session_id_peer, + request_channel: keyed_hash(session_id_own, REQUEST_CHANNEL_SUFFIX), + response_channel: keyed_hash(session_id_own, RESPONSE_CHANNEL_SUFFIX), + peer_request_channel: keyed_hash(session_id_peer, REQUEST_CHANNEL_SUFFIX), + }) +} + +/// Statement channel acknowledging requests the peer initiated: the peer's +/// own `response` channel seen from this side of the session. +pub fn peer_response_channel(session: &SsoSessionInfo) -> [u8; 32] { + keyed_hash(session.session_id_peer, RESPONSE_CHANNEL_SUFFIX) +} + +/// Derive a deterministic P-256 keypair from BIP-39 entropy and a domain +/// label. Host-spec C.4 leaves signing-host P-256 derivation +/// implementation-defined; this scheme only needs to be stable per entropy. +pub fn derive_p256_keypair_from_entropy( + entropy: &[u8], + label: &[u8], +) -> Result<([u8; 32], [u8; 65]), PairingBootstrapError> { + for attempt in 0..MAX_P256_SECRET_ATTEMPTS { + let mut message = Vec::with_capacity(label.len() + 1); + message.extend_from_slice(label); + message.push(attempt as u8); + let candidate = blake2b256_keyed(&message, entropy); + let Ok(secret) = SecretKey::from_slice(&candidate) else { + continue; + }; + let public = secret.public_key().to_encoded_point(false); + let public = public.as_bytes(); + if public.len() != 65 { + return Err(PairingBootstrapError::InvalidP256Secret); + } + let mut encryption_public_key = [0u8; 65]; + encryption_public_key.copy_from_slice(public); + return Ok((candidate, encryption_public_key)); + } + + Err(PairingBootstrapError::InvalidP256Secret) +} + /// Encrypt session-channel statement data with a random nonce. pub fn encrypt_session_statement_data( session: &SsoSessionInfo, @@ -309,13 +443,17 @@ fn create_session_id( } fn keyed_hash(key: [u8; 32], message: &[u8]) -> [u8; 32] { - let digest = blake2b_simd::Params::new() + blake2b256_keyed(message, &key) +} + +fn blake2b256_keyed(message: &[u8], key: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() .hash_length(32) - .key(&key) - .hash(message); - let mut output = [0u8; 32]; - output.copy_from_slice(digest.as_bytes()); - output + .key(key) + .hash(message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") } /// Create one-shot pairing bootstrap material from runtime config. @@ -666,6 +804,136 @@ mod tests { ); } + #[test] + fn decodes_pairing_deeplink_round_trip() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + + let decoded = decode_pairing_deeplink(&deeplink).unwrap(); + + let VersionedHandshakeProposal::V2(proposal) = decoded; + assert_eq!(proposal.device.statement_account_id, SS_PUBLIC); + assert_eq!(proposal.device.encryption_public_key, ENC_PUBLIC); + } + + #[test] + fn decodes_bare_handshake_hex() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + let hex_payload = deeplink.split("handshake=").nth(1).unwrap(); + + assert_eq!( + decode_pairing_deeplink(hex_payload).unwrap(), + decode_pairing_deeplink(&deeplink).unwrap() + ); + } + + #[test] + fn rejects_pairing_deeplink_with_trailing_bytes() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + + let err = decode_pairing_deeplink(&format!("{deeplink}00")).unwrap_err(); + + assert_eq!(err, "invalid pairing handshake proposal: trailing bytes"); + } + + #[test] + fn encrypted_handshake_response_round_trips_through_host_decrypt() { + let host_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let host_public: [u8; 65] = host_secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(); + let response = v2::EncryptedResponse::Success(Box::new(v2::Success { + identity_account_id: [8; 32], + root_account_id: [7; 32], + identity_chat_private_key: [6; 32], + sso_enc_pub_key: ENC_PUBLIC, + device_enc_pub_key: ENC_PUBLIC, + root_entropy_source: [5; 32], + })); + + let VersionedHandshakeResponse::V2 { + encrypted_message, + public_key, + } = encrypt_v2_handshake_response(host_public, &response).unwrap(); + + assert_eq!( + decrypt_v2_handshake_response( + host_secret.to_bytes().into(), + public_key, + &encrypted_message + ) + .unwrap(), + response + ); + } + + fn responder_identity() -> ResponderIdentity { + let (statement_secret, statement_public_key) = generate_statement_store_keypair().unwrap(); + let (encryption_secret_key, encryption_public_key) = + derive_p256_keypair_from_entropy(&[0xAB; 16], b"sso-encryption").unwrap(); + ResponderIdentity { + statement_secret, + statement_public_key, + encryption_secret_key, + encryption_public_key, + } + } + + /// The responder-perspective session must mirror the host-perspective + /// session: swapped session ids, aligned channels, and one shared AES key + /// so either side can decrypt the other's statement data. + #[test] + fn responder_session_mirrors_host_session() { + let config = runtime_config(); + let host_bootstrap = create_pairing_bootstrap(&config).unwrap(); + let responder = responder_identity(); + + let responder_session = establish_responder_session_info( + &responder, + host_bootstrap.statement_store_public_key, + host_bootstrap.encryption_public_key, + ) + .unwrap(); + let host_session = establish_sso_session_info( + &host_bootstrap, + responder.statement_public_key, + responder.encryption_public_key, + ) + .unwrap(); + + assert_eq!( + responder_session.session_id_own, + host_session.session_id_peer + ); + assert_eq!( + responder_session.session_id_peer, + host_session.session_id_own + ); + assert_eq!( + responder_session.request_channel, + host_session.peer_request_channel + ); + assert_eq!( + peer_response_channel(&responder_session), + host_session.response_channel + ); + + let data = SsoStatementData::Request { + request_id: "req-1".to_string(), + data: vec![vec![0xde, 0xad]], + }; + let encrypted = encrypt_session_statement_data(&responder_session, &data).unwrap(); + assert_eq!( + decrypt_session_statement_data(&host_session, &encrypted).unwrap(), + data + ); + } + #[test] fn statement_data_codec_round_trips_request_and_response() { let request = SsoStatementData::Request { diff --git a/rust/crates/truapi-server/src/host_logic/statement_store.rs b/rust/crates/truapi-server/src/host_logic/statement_store.rs index 7195b5d6..e9bdfec4 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store.rs @@ -24,7 +24,7 @@ pub use statement::{ decode_verified_statement_data, hex_topic, sign_statement_fields, signed_statement_to_scale, statement_expiry_elapsed, statement_fields_from_v01, statement_proof_to_v01, statement_public_key_from_secret, statement_signing_payload, - unsigned_statement_signing_payload, + unsigned_statement_signing_payload, validate_unsigned_statement_signing_payload, }; /// Error while parsing statement-store JSON-RPC or SCALE statement payloads. diff --git a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs index f7dd7021..f5b4db20 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs @@ -218,6 +218,25 @@ pub fn unsigned_statement_signing_payload( statement_signing_payload(&fields) } +/// Validate an exact unsigned statement signing payload. +/// +/// Statement proof signing intentionally signs the encoded statement fields +/// without the surrounding SCALE vector length. Accept only payloads that +/// round-trip from the public unsigned statement shape, so a paired peer cannot +/// reuse the statement-signing path as a generic product-account signing oracle. +pub fn validate_unsigned_statement_signing_payload(payload: &[u8]) -> Result<(), String> { + let fields = decode_unsigned_statement_signing_payload_fields(payload)?; + if fields.is_empty() { + return Err("statement signing payload has no fields".to_string()); + } + let statement = unsigned_statement_from_fields(fields)?; + let canonical = unsigned_statement_signing_payload(statement_fields_from_v01(statement)?)?; + if canonical != payload { + return Err("statement signing payload is not canonical".to_string()); + } + Ok(()) +} + /// Build the statement signing payload from sorted fields. pub fn statement_signing_payload(fields: &[StatementField]) -> Result, String> { let encoded = fields.to_vec().encode(); @@ -228,6 +247,19 @@ pub fn statement_signing_payload(fields: &[StatementField]) -> Result, S Ok(encoded[compact_len..].to_vec()) } +fn decode_unsigned_statement_signing_payload_fields( + mut input: &[u8], +) -> Result, String> { + let mut fields = Vec::new(); + while !input.is_empty() { + fields.push( + StatementField::decode(&mut input) + .map_err(|err| format!("invalid statement signing payload: {err}"))?, + ); + } + Ok(fields) +} + fn decode_statement_fields( statement: &[u8], ) -> Result, StatementStoreParseError> { @@ -307,6 +339,69 @@ fn verify_statement_proof( Ok(signer) } +fn unsigned_statement_from_fields(fields: Vec) -> Result { + let mut decryption_key = None; + let mut expiry = None; + let mut channel = None; + let mut topics = Vec::new(); + let mut data = None; + + for field in fields { + match field { + StatementField::Proof(_) => { + return Err("statement signing payload must not include proof".to_string()); + } + StatementField::DecryptionKey(value) => { + if decryption_key.replace(value).is_some() { + return Err( + "statement signing payload has duplicate decryption key".to_string() + ); + } + } + StatementField::Expiry(value) => { + if expiry.replace(value).is_some() { + return Err("statement signing payload has duplicate expiry".to_string()); + } + } + StatementField::Channel(value) => { + if channel.replace(value).is_some() { + return Err("statement signing payload has duplicate channel".to_string()); + } + } + StatementField::Topic1(value) => push_unsigned_statement_topic(&mut topics, 0, value)?, + StatementField::Topic2(value) => push_unsigned_statement_topic(&mut topics, 1, value)?, + StatementField::Topic3(value) => push_unsigned_statement_topic(&mut topics, 2, value)?, + StatementField::Topic4(value) => push_unsigned_statement_topic(&mut topics, 3, value)?, + StatementField::Data(value) => { + if data.replace(value).is_some() { + return Err("statement signing payload has duplicate data".to_string()); + } + } + } + } + + Ok(v01::Statement { + proof: None, + decryption_key, + expiry, + channel, + topics, + data, + }) +} + +fn push_unsigned_statement_topic( + topics: &mut Vec<[u8; 32]>, + expected_index: usize, + value: [u8; 32], +) -> Result<(), String> { + if topics.len() != expected_index { + return Err("statement signing payload topics are not contiguous".to_string()); + } + topics.push(value); + Ok(()) +} + /// Convert a public v01 statement into SCALE statement fields. pub fn statement_fields_from_v01(statement: v01::Statement) -> Result, String> { let mut fields = Vec::new(); @@ -616,6 +711,57 @@ mod tests { assert_eq!(statement_signing_payload(&fields).unwrap(), encoded[1..]); } + #[test] + fn validates_unsigned_statement_signing_payload() { + let payload = unsigned_statement_signing_payload(vec![ + StatementField::Expiry(42), + StatementField::Topic1([1; 32]), + StatementField::Topic2([2; 32]), + StatementField::Data(vec![0xde, 0xad]), + ]) + .unwrap(); + + validate_unsigned_statement_signing_payload(&payload).unwrap(); + } + + #[test] + fn unsigned_statement_signing_payload_rejects_transaction_preimage() { + let tx_preimage = vec![0x00, 0x00, 0x01, 0x02, 0x03]; + + let err = validate_unsigned_statement_signing_payload(&tx_preimage).unwrap_err(); + + assert!(err.contains("invalid statement signing payload")); + } + + #[test] + fn unsigned_statement_signing_payload_rejects_proof_field() { + let payload = + statement_signing_payload(&[StatementField::Proof(StatementProof::Sr25519 { + signature: [1; 64], + signer: [2; 32], + })]) + .unwrap(); + + assert_eq!( + validate_unsigned_statement_signing_payload(&payload).unwrap_err(), + "statement signing payload must not include proof" + ); + } + + #[test] + fn unsigned_statement_signing_payload_rejects_non_contiguous_topics() { + let payload = statement_signing_payload(&[ + StatementField::Topic1([1; 32]), + StatementField::Topic3([3; 32]), + ]) + .unwrap(); + + assert_eq!( + validate_unsigned_statement_signing_payload(&payload).unwrap_err(), + "statement signing payload topics are not contiguous" + ); + } + #[test] fn builds_signed_session_request_statement() { let session = test_session(); diff --git a/rust/crates/truapi-server/src/host_logic/transaction.rs b/rust/crates/truapi-server/src/host_logic/transaction.rs new file mode 100644 index 00000000..13040bc2 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/transaction.rs @@ -0,0 +1,217 @@ +//! Extrinsic signing preimages and v4 signed-extrinsic assembly. +//! +//! Signing hosts receive pre-encoded payload fields (`HostSignPayloadData`) +//! or pre-encoded transaction extensions (`TxPayloadExtension`), so no chain +//! metadata is needed: the preimage is a byte concatenation and the signed +//! extrinsic is assembled mechanically. Matches the polkadot-app signing +//! convention: preimages longer than 256 bytes are BLAKE2b-256 hashed before +//! signing. + +use parity_scale_codec::{Compact, Encode}; +use truapi::latest::{HostSignPayloadData, TxPayloadExtension}; + +/// Preimages longer than this are hashed before signing (standard Substrate +/// signed-payload rule). +const MAX_SIGNED_PREIMAGE_LEN: usize = 256; + +/// Extrinsic version 4 with the signed bit set. +const EXTRINSIC_V4_SIGNED: u8 = 0x84; +/// `MultiAddress::Id` variant index. +const MULTI_ADDRESS_ID: u8 = 0x00; +/// `MultiSignature::Sr25519` variant index. +const MULTI_SIGNATURE_SR25519: u8 = 0x01; + +/// Signing preimage for an extrinsic payload assembled from pre-encoded +/// fields, in the polkadot-app field order. Empty optional fields are +/// skipped, mirroring the JS falsy-field rule. +pub fn extrinsic_payload_preimage(payload: &HostSignPayloadData) -> Vec { + let parts: [&[u8]; 8] = [ + &payload.method, + &payload.era, + &payload.nonce, + &payload.tip, + &payload.spec_version, + &payload.transaction_version, + &payload.genesis_hash, + &payload.block_hash, + ]; + let mut preimage = Vec::new(); + for part in parts { + preimage.extend_from_slice(part); + } + if let Some(asset_id) = &payload.asset_id { + preimage.extend_from_slice(asset_id); + } + if let Some(metadata_hash) = &payload.metadata_hash { + preimage.extend_from_slice(metadata_hash); + } + hash_large_preimage(preimage) +} + +/// Signing preimage for a transaction built from pre-encoded extensions: +/// call data, then every extension's `extra`, then every extension's +/// `additional_signed`. +pub fn transaction_signing_preimage( + call_data: &[u8], + extensions: &[TxPayloadExtension], +) -> Vec { + let mut preimage = call_data.to_vec(); + for extension in extensions { + preimage.extend_from_slice(&extension.extra); + } + for extension in extensions { + preimage.extend_from_slice(&extension.additional_signed); + } + hash_large_preimage(preimage) +} + +/// Assemble a v4 signed extrinsic from a signer public key, an sr25519 +/// signature over [`transaction_signing_preimage`], the pre-encoded +/// extension `extra` data, and the call data. +pub fn build_v4_signed_extrinsic( + signer_public_key: [u8; 32], + signature: [u8; 64], + extensions: &[TxPayloadExtension], + call_data: &[u8], +) -> Vec { + let mut body = Vec::with_capacity(2 + 32 + 1 + 64 + call_data.len()); + body.push(EXTRINSIC_V4_SIGNED); + body.push(MULTI_ADDRESS_ID); + body.extend_from_slice(&signer_public_key); + body.push(MULTI_SIGNATURE_SR25519); + body.extend_from_slice(&signature); + for extension in extensions { + body.extend_from_slice(&extension.extra); + } + body.extend_from_slice(call_data); + + let mut extrinsic = Compact(body.len() as u32).encode(); + extrinsic.extend_from_slice(&body); + extrinsic +} + +fn hash_large_preimage(preimage: Vec) -> Vec { + if preimage.len() > MAX_SIGNED_PREIMAGE_LEN { + blake2b_simd::Params::new() + .hash_length(32) + .hash(&preimage) + .as_bytes() + .to_vec() + } else { + preimage + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn payload() -> HostSignPayloadData { + HostSignPayloadData { + block_hash: vec![0xB1, 0xB2], + block_number: vec![0xFF], + era: vec![0xE1], + genesis_hash: vec![0x61, 0x62], + method: vec![0x4D], + nonce: vec![0x4E], + spec_version: vec![0x51], + tip: vec![0x54], + transaction_version: vec![0x56], + signed_extensions: vec![], + version: 4, + asset_id: None, + metadata_hash: None, + mode: None, + with_signed_transaction: None, + } + } + + #[test] + fn payload_preimage_uses_polkadot_app_field_order() { + // method, era, nonce, tip, spec_version, transaction_version, + // genesis_hash, block_hash. block_number is not part of the preimage. + assert_eq!( + extrinsic_payload_preimage(&payload()), + vec![0x4D, 0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2] + ); + } + + #[test] + fn payload_preimage_appends_asset_id_and_metadata_hash() { + let mut payload = payload(); + payload.asset_id = Some(vec![0xAA]); + payload.metadata_hash = Some(vec![0xBB]); + + assert_eq!( + extrinsic_payload_preimage(&payload), + vec![ + 0x4D, 0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2, 0xAA, 0xBB + ] + ); + } + + #[test] + fn long_preimages_are_blake2b_hashed() { + let mut payload = payload(); + payload.method = vec![0x4D; 300]; + + let preimage = extrinsic_payload_preimage(&payload); + + assert_eq!(preimage.len(), 32); + let mut raw = vec![0x4D; 300]; + raw.extend_from_slice(&[0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2]); + assert_eq!( + preimage, + blake2b_simd::Params::new() + .hash_length(32) + .hash(&raw) + .as_bytes() + .to_vec() + ); + } + + #[test] + fn transaction_preimage_orders_call_extra_then_implicit() { + let extensions = vec![ + TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![0x01], + additional_signed: vec![0x02], + }, + TxPayloadExtension { + id: "CheckSpecVersion".to_string(), + extra: vec![0x03], + additional_signed: vec![0x04], + }, + ]; + + assert_eq!( + transaction_signing_preimage(&[0xCA, 0x11], &extensions), + vec![0xCA, 0x11, 0x01, 0x03, 0x02, 0x04] + ); + } + + #[test] + fn builds_v4_signed_extrinsic_layout() { + let extensions = vec![TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![0xEE], + additional_signed: vec![0xDD], + }]; + + let extrinsic = + build_v4_signed_extrinsic([0xAB; 32], [0xCD; 64], &extensions, &[0xCA, 0x11]); + + let body_len = 1 + 1 + 32 + 1 + 64 + 1 + 2; + assert_eq!(extrinsic[..2], Compact(body_len as u32).encode()[..]); + let body = &extrinsic[2..]; + assert_eq!(body.len(), body_len); + assert_eq!(body[0], 0x84); + assert_eq!(body[1], 0x00); + assert_eq!(&body[2..34], &[0xAB; 32]); + assert_eq!(body[34], 0x01); + assert_eq!(&body[35..99], &[0xCD; 64]); + assert_eq!(body[99], 0xEE); + assert_eq!(&body[100..], &[0xCA, 0x11]); + } +} diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index 9d2d8489..3cf2bda2 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -32,6 +32,11 @@ pub use host_core::{ FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeError, SigningHostRuntime, }; +pub use runtime::ResponderExit; +#[cfg(not(target_arch = "wasm32"))] +pub use runtime::StatementRenewalTarget; +#[cfg(not(target_arch = "wasm32"))] +pub use runtime::statement_allowance; pub use truapi_platform::{ HostRuntimeConfig, PairingHostConfig, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, ProductContext, SigningHostConfig, diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index ee990b2d..be5bfd52 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,12 @@ 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; +#[cfg(not(target_arch = "wasm32"))] +pub use signing_host::StatementRenewalTarget; +pub(crate) use signing_host::{ + LocalActivation, SigningHost as SigningHostRole, respond_to_pairing, +}; use authority::{ AccountAliasAuthorityRequest, AuthorityCancelError, AuthorityError, AuthoritySession, @@ -140,10 +147,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 +877,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 +914,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 +958,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 +1703,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 +1990,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 +2015,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 +2047,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 +2148,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 +2522,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 +2587,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 +2638,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 +2685,17 @@ mod tests { ) .unwrap(); let HostGetLegacyAccountsResponse::V1(inner) = response; - assert_eq!(inner.accounts.len(), 1); + assert_eq!(inner.accounts.len(), 2); assert_eq!(inner.accounts[0].name.as_deref(), Some("alice")); assert_eq!( hex::encode(&inner.accounts[0].public_key), "1c822b488297fde8c60d9cbc5585839f70a69fb2c5c69daa66b6043c75184467" ); + assert_eq!(inner.accounts[1].name.as_deref(), Some("Identity")); + assert_eq!( + inner.accounts[1].public_key, + session_info().identity_account_id.unwrap() + ); } #[test] diff --git a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs index 3b106f81..23ba14eb 100644 --- a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs @@ -177,6 +177,18 @@ impl BulletinRpc { } } + /// Open a raw RPC client over the configured Bulletin chain. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn client( + &self, + label: &'static str, + ) -> Result { + self.chain + .rpc_client(label, &self.genesis_hash) + .await + .map(subxt_rpcs::RpcClient::new) + } + /// Submit `value` as a Bulletin preimage signed by `allowance`, returning /// the preimage key once the transaction is included and its dispatch /// succeeded. diff --git a/rust/crates/truapi-server/src/runtime/identity.rs b/rust/crates/truapi-server/src/runtime/identity.rs index 36e7d9f4..c783c603 100644 --- a/rust/crates/truapi-server/src/runtime/identity.rs +++ b/rust/crates/truapi-server/src/runtime/identity.rs @@ -17,7 +17,7 @@ use crate::host_logic::session::SessionInfo; use futures::{FutureExt, pin_mut}; use tracing::{debug, instrument, warn}; -use truapi::v01::{ +use truapi::latest::{ OperationStartedResult, RemoteChainHeadFollowRequest, RemoteChainHeadStorageRequest, StorageQueryItem, StorageQueryType, }; @@ -51,7 +51,7 @@ pub(super) async fn resolve_session_identity_with_chain( } let preferred_account = session.identity_account_id.unwrap_or(session.public_key); - if !lookup_and_apply( + if lookup_and_apply( chain, people_chain_genesis_hash, preferred_account, @@ -59,6 +59,7 @@ pub(super) async fn resolve_session_identity_with_chain( "identity", ) .await + == LookupOutcome::NoRecord && preferred_account != session.public_key { let public_key = session.public_key; @@ -75,42 +76,60 @@ pub(super) async fn resolve_session_identity_with_chain( session } +/// Maximum lookup attempts per account on transient failure. The first attempt +/// warms the People-chain connection (cached per genesis), so a retry after a +/// cold-start timeout usually resolves immediately. +const IDENTITY_LOOKUP_MAX_ATTEMPTS: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LookupOutcome { + /// A username record was found and applied. + Applied, + /// The account has no consumer record (definitive; do not retry). + NoRecord, + /// The lookup failed transiently after exhausting retries. + Failed, +} + /// Look up `account`'s people-chain identity and apply any usernames to -/// `session`; returns whether a username record was found and applied. +/// `session`, retrying transient failures against the warmed connection. async fn lookup_and_apply( chain: &ChainRuntime, people_chain_genesis_hash: [u8; 32], account: [u8; 32], session: &mut SessionInfo, label: &str, -) -> bool { - match lookup_people_identity(chain, people_chain_genesis_hash, account).await { - Ok(Some(identity)) => { - debug!( - account = %hex::encode(account), - lite_username = identity.lite_username.as_deref().unwrap_or(""), - full_username = identity.full_username.as_deref().unwrap_or(""), - "People-chain {label} lookup found username" - ); - session.apply_usernames(identity.lite_username, identity.full_username); - true - } - Ok(None) => { - debug!( - account = %hex::encode(account), - "People-chain {label} lookup found no consumer record" - ); - false - } - Err(reason) => { - warn!( - account = %hex::encode(account), - %reason, - "People-chain {label} lookup failed" - ); - false +) -> LookupOutcome { + for attempt in 1..=IDENTITY_LOOKUP_MAX_ATTEMPTS { + match lookup_people_identity(chain, people_chain_genesis_hash, account).await { + Ok(Some(identity)) => { + debug!( + account = %hex::encode(account), + lite_username = identity.lite_username.as_deref().unwrap_or(""), + full_username = identity.full_username.as_deref().unwrap_or(""), + "People-chain {label} lookup found username" + ); + session.apply_usernames(identity.lite_username, identity.full_username); + return LookupOutcome::Applied; + } + Ok(None) => { + debug!( + account = %hex::encode(account), + "People-chain {label} lookup found no consumer record" + ); + return LookupOutcome::NoRecord; + } + Err(reason) => { + warn!( + account = %hex::encode(account), + attempt, + %reason, + "People-chain {label} lookup failed" + ); + } } } + LookupOutcome::Failed } #[instrument(skip_all, fields(runtime.method = "session.identity.lookup"))] diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index dbd5c142..c97cfc11 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -224,7 +224,7 @@ impl PairingHost { if !session_matches_key(&session_state, key) { return Err(SsoRemoteResponseError::LocalDisconnected); } - statement_store_rpc::submit(&submit_client, statement) + statement_store_rpc::submit_sso(&submit_client, statement, "pairing-host request") .await .map_err(|err| { SsoRemoteResponseError::Failure(format!("SSO statement submit failed: {err}")) diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index afe09e97..0923a3e1 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -5,13 +5,26 @@ //! 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. + +#[cfg(not(target_arch = "wasm32"))] +mod allowance_renewal; mod local_activation; mod ring_vrf; +mod sso_responder; use std::sync::{Arc, Mutex}; +#[cfg(not(target_arch = "wasm32"))] +pub use allowance_renewal::StatementRenewalTarget; 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 +37,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 +50,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,11 +61,15 @@ 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, /// Root BIP-39 entropy held only while a session is active. root_entropy: Mutex>>>, + #[cfg(not(target_arch = "wasm32"))] + renewal: allowance_renewal::RenewalState, } impl SigningHost { @@ -58,10 +77,14 @@ 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, root_entropy: Mutex::new(None), + #[cfg(not(target_arch = "wasm32"))] + renewal: allowance_renewal::RenewalState::default(), }) } @@ -70,11 +93,21 @@ 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, root_entropy: Mutex::new(None), + #[cfg(not(target_arch = "wasm32"))] + renewal: allowance_renewal::RenewalState::default(), }) } @@ -140,6 +173,47 @@ 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), + }), + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl SigningHost { + /// Record statement-store accounts to keep renewed across periods. + pub(crate) async fn track_statement_renewal_targets( + &self, + targets: Vec, + ) -> Result<(), String> { + allowance_renewal::track_targets(self.platform.as_ref(), targets).await + } + + /// Run one statement-store renewal pass over the tracked targets. + pub(crate) async fn renew_statement_allowances( + &self, + ) -> Result { + allowance_renewal::renew_now(&self.services, self).await + } + + /// Start the periodic statement-store renewal loop. Idempotent. + pub(crate) fn start_statement_allowance_renewal(self: &Arc) { + allowance_renewal::start_renewal_loop(&self.services, self); + } } #[async_trait::async_trait] @@ -183,14 +257,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 +279,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 +360,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 +386,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 +422,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 +544,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 +642,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 +744,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 +813,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 +889,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 +1356,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/allowance_renewal.rs b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs new file mode 100644 index 00000000..ba0dad73 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs @@ -0,0 +1,394 @@ +//! Ledger and driver for automatic statement-store allowance renewal. +//! +//! The ledger records which accounts this signing host promised to keep +//! allowed, as derivation recipes where possible so entries stay valid when +//! the host rotates to a new root entropy. The driver resolves them against +//! the active session and runs the chain-pure pass in +//! `statement_allowance::renewal`, either once (`renew_now`) or on a periodic +//! tick (`start_renewal_loop`). + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use futures::lock::Mutex; +use parity_scale_codec::{Decode, Encode}; +use tracing::{debug, info, warn}; +use truapi_platform::{CoreStorage, CoreStorageKey}; + +use super::SigningHost; +use super::sso_responder::current_unix_secs; +use crate::host_logic::product_account::derive_sr25519_hard_path; +use crate::runtime::RuntimeServices; +use crate::runtime::statement_allowance::renewal::{ + RenewalChainContext, ResolvedRenewalTarget, StatementRenewalReport, next_tick_delay, + renew_targets, +}; +use crate::runtime::statement_allowance::{ + self, fetch_chain_state, fetch_metadata, find_including_ring, +}; + +/// Fallback tick delay when the system clock is unusable. +const CLOCK_FAILURE_TICK_DELAY: Duration = Duration::from_secs(3_600); + +/// A statement-store account the signing host promised to keep renewed. +/// +/// Entropy-derived variants are recipes, not raw account ids, so the ledger +/// survives root-entropy rotation (the CLI rotates auto-managed accounts on +/// slot exhaustion). +#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] +pub enum StatementRenewalTarget { + /// `//allowance//statement-store//{product_id}` from the active root entropy. + ProductStatementAllowance { + /// Product the allowance account belongs to. + product_id: String, + }, + /// `//wallet//sso` from the active root entropy. + WalletSso, + /// A fixed account, e.g. a pairing peer's device statement key. + Account { + /// Account to keep allowed. + account_id: [u8; 32], + /// Human-readable name used in logs and reports. + label: String, + }, +} + +/// Renewal coordination state owned by [`SigningHost`]. +#[derive(Default)] +pub(super) struct RenewalState { + /// Serializes slot registrations between the renewal pass and on-demand + /// allocation so both cannot race for the same free slot. + registration_lock: Mutex<()>, + loop_started: AtomicBool, +} + +impl RenewalState { + pub(super) fn registration_lock(&self) -> &Mutex<()> { + &self.registration_lock + } +} + +/// Read the renewal ledger; an absent slot is an empty ledger. +pub(super) async fn read_targets( + storage: &(impl CoreStorage + ?Sized), +) -> Result, String> { + let Some(blob) = storage + .read_core_storage(CoreStorageKey::StatementRenewalTargets) + .await + .map_err(|err| format!("renewal ledger read failed: {}", err.reason))? + else { + return Ok(Vec::new()); + }; + decode_targets(&blob) +} + +/// Append `new_targets` to the ledger, preserving order and skipping entries +/// already present. +pub(super) async fn track_targets( + storage: &(impl CoreStorage + ?Sized), + new_targets: Vec, +) -> Result<(), String> { + let mut targets = read_targets(storage).await?; + let mut changed = false; + for target in new_targets { + if !targets.contains(&target) { + targets.push(target); + changed = true; + } + } + if !changed { + return Ok(()); + } + storage + .write_core_storage(CoreStorageKey::StatementRenewalTargets, targets.encode()) + .await + .map_err(|err| format!("renewal ledger write failed: {}", err.reason)) +} + +fn decode_targets(blob: &[u8]) -> Result, String> { + let mut input = blob; + let targets = Vec::::decode(&mut input) + .map_err(|err| format!("invalid persisted renewal targets: {err}"))?; + if !input.is_empty() { + return Err("invalid persisted renewal targets: trailing bytes".to_string()); + } + Ok(targets) +} + +/// Resolve a ledger entry into a concrete account for this session's entropy. +fn resolve_target( + entropy: &[u8], + target: &StatementRenewalTarget, +) -> Result { + match target { + StatementRenewalTarget::ProductStatementAllowance { product_id } => { + let pair = derive_sr25519_hard_path( + entropy, + &["allowance", "statement-store", product_id.as_str()], + ) + .map_err(|err| err.to_string())?; + Ok(ResolvedRenewalTarget { + label: format!("product:{product_id}"), + account_id: pair.public.to_bytes(), + }) + } + StatementRenewalTarget::WalletSso => { + let pair = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) + .map_err(|err| err.to_string())?; + Ok(ResolvedRenewalTarget { + label: "wallet-sso".to_string(), + account_id: pair.public.to_bytes(), + }) + } + StatementRenewalTarget::Account { account_id, label } => Ok(ResolvedRenewalTarget { + label: label.clone(), + account_id: *account_id, + }), + } +} + +/// One renewal pass: resolve the ledger against the active session and renew +/// every target for the current period. +pub(super) async fn renew_now( + services: &Arc, + signing_host: &SigningHost, +) -> Result { + let entropy = signing_host.root_entropy().map_err(|err| err.reason())?; + let period = statement_allowance::slot::current_period(current_unix_secs()?); + let targets = read_targets(signing_host.platform.as_ref()).await?; + let resolved = targets + .iter() + .map(|target| resolve_target(&entropy, target)) + .collect::, String>>()?; + if resolved.is_empty() { + return Ok(StatementRenewalReport { + period, + outcomes: Vec::new(), + slots_exhausted: false, + }); + } + + let bandersnatch = statement_allowance::bandersnatch_entropy(&entropy); + let rpc = statement_allowance::rpc::RpcClient::new( + services + .statement_store + .client("statement-allowance renewal") + .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 renew statement-store allowances" + .to_string() + })?; + let context = RenewalChainContext { + rpc: &rpc, + metadata: &metadata, + chain_state: &chain_state, + ring: &ring, + }; + Ok(renew_targets( + &context, + bandersnatch, + period, + &resolved, + signing_host.renewal.registration_lock(), + ) + .await) +} + +/// Spawn the periodic renewal loop; repeated calls are no-ops. The loop holds +/// only weak references, so it exits when the owning runtime is dropped. +pub(super) fn start_renewal_loop(services: &Arc, signing_host: &Arc) { + if signing_host + .renewal + .loop_started + .swap(true, Ordering::SeqCst) + { + return; + } + let weak_services = Arc::downgrade(services); + let weak_host = Arc::downgrade(signing_host); + let spawner = services.spawner.clone(); + spawner(Box::pin(async move { + loop { + { + let (Some(services), Some(signing_host)) = + (weak_services.upgrade(), weak_host.upgrade()) + else { + return; + }; + run_tick(&services, &signing_host).await; + } + let delay = match current_unix_secs() { + Ok(now) => next_tick_delay(now), + Err(_) => CLOCK_FAILURE_TICK_DELAY, + }; + futures_timer::Delay::new(delay).await; + } + })); +} + +async fn run_tick(services: &Arc, signing_host: &SigningHost) { + if signing_host.root_entropy().is_err() { + debug!("skipping statement-store renewal tick; no active session"); + return; + } + match renew_now(services, signing_host).await { + Ok(report) if report.slots_exhausted => { + warn!( + period = report.period, + "statement-store renewal hit slot exhaustion" + ); + } + Ok(report) => { + info!( + period = report.period, + targets = report.outcomes.len(), + "statement-store renewal pass complete" + ); + } + Err(reason) => warn!(%reason, "statement-store renewal tick failed"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + use truapi::latest::GenericError; + + #[derive(Default)] + struct MemStorage { + inner: Mutex, Vec>>, + } + + #[truapi_platform::async_trait] + impl CoreStorage for MemStorage { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, GenericError> { + Ok(self + .inner + .lock() + .expect("storage mutex poisoned") + .get(&key.encode()) + .cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), GenericError> { + self.inner + .lock() + .expect("storage mutex poisoned") + .insert(key.encode(), value); + Ok(()) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), GenericError> { + self.inner + .lock() + .expect("storage mutex poisoned") + .remove(&key.encode()); + Ok(()) + } + } + + fn product(product_id: &str) -> StatementRenewalTarget { + StatementRenewalTarget::ProductStatementAllowance { + product_id: product_id.to_string(), + } + } + + #[test] + fn ledger_round_trips_dedupes_and_preserves_order() { + let storage = MemStorage::default(); + + futures::executor::block_on(async { + track_targets( + &storage, + vec![StatementRenewalTarget::WalletSso, product("a.dot")], + ) + .await + .unwrap(); + track_targets( + &storage, + vec![ + product("a.dot"), + StatementRenewalTarget::Account { + account_id: [9; 32], + label: "device".to_string(), + }, + ], + ) + .await + .unwrap(); + + assert_eq!( + read_targets(&storage).await.unwrap(), + vec![ + StatementRenewalTarget::WalletSso, + product("a.dot"), + StatementRenewalTarget::Account { + account_id: [9; 32], + label: "device".to_string(), + }, + ] + ); + }); + } + + #[test] + fn ledger_rejects_trailing_bytes() { + let mut blob = vec![product("a.dot")].encode(); + blob.push(0xff); + assert!(decode_targets(&blob).is_err()); + } + + #[test] + fn product_target_resolves_to_allocation_derivation() { + let entropy = [7u8; 32]; + let expected = + derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", "a.dot"]) + .unwrap() + .public + .to_bytes(); + + let resolved = resolve_target(&entropy, &product("a.dot")).unwrap(); + assert_eq!( + resolved, + ResolvedRenewalTarget { + label: "product:a.dot".to_string(), + account_id: expected, + } + ); + } + + #[test] + fn wallet_sso_target_resolves_to_wallet_sso_derivation() { + let entropy = [7u8; 32]; + let expected = derive_sr25519_hard_path(&entropy, &["wallet", "sso"]) + .unwrap() + .public + .to_bytes(); + + let resolved = resolve_target(&entropy, &StatementRenewalTarget::WalletSso).unwrap(); + assert_eq!( + resolved, + ResolvedRenewalTarget { + label: "wallet-sso".to_string(), + account_id: expected, + } + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs index 9da344b0..c8ba2611 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs @@ -16,11 +16,28 @@ pub(crate) trait LocalActivation: Send + Sync { /// Activate a local session from raw BIP-39 entropy, deriving the root /// public key and marking the session connected. async fn activate_local_session(&self, secret: Vec) -> Result<(), AuthorityError>; + + /// Activate a local session and attach known identity metadata from the + /// host's signer/account store. + async fn activate_local_session_with_identity( + &self, + secret: Vec, + lite_username: Option, + ) -> Result<(), AuthorityError>; } #[async_trait::async_trait] impl LocalActivation for SigningHost { async fn activate_local_session(&self, secret: Vec) -> Result<(), AuthorityError> { + self.activate_local_session_with_identity(secret, None) + .await + } + + async fn activate_local_session_with_identity( + &self, + secret: Vec, + lite_username: Option, + ) -> Result<(), AuthorityError> { let secret = Zeroizing::new(secret); let root = derive_root_keypair_from_entropy(&secret).map_err(product_authority_error)?; let public_key = root.public.to_bytes(); @@ -33,7 +50,7 @@ impl LocalActivation for SigningHost { sso: None, root_entropy_source: None, identity_account_id: None, - lite_username: None, + lite_username, full_username: None, }; self.session_state.set_session(session.clone()); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs b/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs index cf2bb939..a5976955 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs @@ -416,6 +416,18 @@ mod tests { ); } + #[test] + fn context_matches_ios_host_vector() { + let context = ProductProofContext { + product_id: "voting.dot".to_string(), + suffix: vec![0, 1, 2, 3], + }; + assert_eq!( + hex::encode(context_bytes(&context)), + "03fba4e4f9ce1b2eb228e79b8aabef71213cfc53bec6dcae9d24a075a2d5a89e" + ); + } + #[test] fn collection_selects_corresponding_person_key() { assert_eq!(key_for_collection(&FULL_PERSON_COLLECTION), PersonKey::Full); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs new file mode 100644 index 00000000..fb7d8669 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -0,0 +1,1365 @@ +//! 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 super::allowance_renewal::{self, StatementRenewalTarget}; + 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 = { + let _guard = signing_host.renewal.registration_lock().lock().await; + 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" + ); + } + } + if let Err(reason) = allowance_renewal::track_targets( + signing_host.platform.as_ref(), + vec![StatementRenewalTarget::ProductStatementAllowance { + product_id: product_id.to_string(), + }], + ) + .await + { + warn!(%product_id, %reason, "failed to record statement-store renewal target"); + } + 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"))] +pub(super) fn current_unix_secs() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| "system clock before UNIX epoch".to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn product_account_error(err: ProductAccountError) -> String { + err.to_string() +} + +/// Confirm and serve a payload or raw signing request. +async fn sign_response( + services: &Arc, + signing_host: &Arc, + message_id: &str, + request: SigningRequest, +) -> SigningResponse { + let payload = serve_sign_request(services, signing_host, request).await; + if let Err(reason) = &payload { + warn!(%reason, "sign request failed"); + } + SigningResponse { + responding_to: message_id.to_string(), + payload, + } +} + +async fn serve_sign_request( + services: &Arc, + signing_host: &Arc, + request: SigningRequest, +) -> Result { + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + let cx = CallContext::new(); + let response = match request { + SigningRequest::Payload(request) => { + let request: api::HostSignPayloadRequest = (*request).into(); + confirm( + services, + UserConfirmationReview::SignPayload(SignPayloadReview::Product(request.clone())), + ) + .await?; + signing_host + .sign_payload(&cx, &session, SignPayloadAuthorityRequest::Product(request)) + .await + } + SigningRequest::Raw(request) => { + let request: api::HostSignRawRequest = request.into(); + confirm( + services, + UserConfirmationReview::SignRaw(SignRawReview::Product(request.clone())), + ) + .await?; + signing_host + .sign_raw(&cx, &session, SignRawAuthorityRequest::Product(request)) + .await + } + } + .map_err(|err| err.reason())?; + Ok(SigningPayloadResponseData { + signature: response.signature, + signed_transaction: response.signed_transaction, + }) +} + +async fn sign_raw_legacy_response( + services: &Arc, + signing_host: &Arc, + request: messages::SignRawLegacyRequest, +) -> Result, String> { + let public_request = api::HostSignRawWithLegacyAccountRequest { + signer: product_public_key_to_address(request.account), + payload: request.data.into(), + }; + confirm( + services, + UserConfirmationReview::SignRaw(SignRawReview::LegacyAccount(public_request.clone())), + ) + .await?; + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + signing_host + .sign_raw( + &CallContext::new(), + &session, + SignRawAuthorityRequest::LegacyAccount { + account: request.account, + request: public_request, + }, + ) + .await + .map(|response| response.signature) + .map_err(|err| err.reason()) +} + +async fn statement_store_product_sign_response( + services: &Arc, + signing_host: &Arc, + request: messages::StatementStoreProductSignRequest, +) -> Result, String> { + validate_unsigned_statement_signing_payload(&request.payload)?; + confirm( + services, + UserConfirmationReview::SignRaw(SignRawReview::Product(api::HostSignRawRequest { + account: request.product_account_id.clone(), + payload: api::RawPayload::Bytes { + bytes: request.payload.clone(), + }, + })), + ) + .await?; + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + let cx = CallContext::new(); + signing_host + .sign_statement_store_product_payload( + &cx, + &session, + request.product_account_id, + request.payload, + ) + .await + .map(|signature| signature.to_vec()) + .map_err(|err| err.reason()) +} + +/// Confirm and serve a transaction-creation request. +async fn create_transaction_response( + services: &Arc, + signing_host: &Arc, + review: CreateTransactionReview, + request: CreateTransactionAuthorityRequest, +) -> Result, String> { + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + confirm(services, UserConfirmationReview::CreateTransaction(review)).await?; + let cx = CallContext::new(); + signing_host + .create_transaction(&cx, &session, request) + .await + .map(|response| response.transaction) + .map_err(|err| err.reason()) +} + +async fn account_alias_response( + signing_host: &Arc, + request: messages::RingVrfAliasRequest, +) -> Result { + let session = signing_host + .current_session() + .ok_or_else(disconnected_ring_vrf)?; + let cx = CallContext::new(); + signing_host + .account_alias( + &cx, + &session, + AccountAliasAuthorityRequest { + calling_product_id: request.calling_product_id, + context: request.context, + ring_location: request.ring_location, + }, + ) + .await +} + +async fn create_proof_response( + signing_host: &Arc, + request: messages::RingVrfProofRequest, +) -> Result { + let session = signing_host + .current_session() + .ok_or_else(disconnected_ring_vrf)?; + let cx = CallContext::new(); + signing_host + .create_proof( + &cx, + &session, + CreateProofAuthorityRequest { + calling_product_id: request.calling_product_id, + context: request.context, + ring_location: request.ring_location, + message: request.message, + }, + ) + .await +} + +fn disconnected_ring_vrf() -> RingVrfError { + RingVrfError::Unknown { + reason: "signing host session is not active".to_string(), + } +} + +/// Run the platform confirmation seam; rejection and failure both refuse the +/// operation with an opaque reason (host-spec B.7). +async fn confirm( + services: &Arc, + review: UserConfirmationReview, +) -> Result<(), String> { + match services.platform.confirm_user_action(review).await { + Ok(true) => Ok(()), + Ok(false) => Err("Rejected".to_string()), + Err(err) => Err(format!("confirmation failed: {}", err.reason)), + } +} + +#[cfg(test)] +mod tests { + use super::super::LocalActivation; + use super::*; + use crate::host_logic::statement_store::{StatementField, unsigned_statement_signing_payload}; + use crate::runtime::services::RuntimeServices; + use crate::test_support::{StubPlatform, test_spawner}; + use std::sync::Arc; + use truapi_platform::{HostInfo, Platform, PlatformInfo, SigningHostConfig}; + + const ENTROPY: [u8; 16] = [0xab; 16]; + + fn product_account(product_id: &str) -> api::ProductAccountId { + api::ProductAccountId { + dot_ns_identifier: product_id.to_string(), + derivation_index: 0, + } + } + + fn signing_fixture(platform: Arc) -> (Arc, Arc) { + let platform: Arc = platform; + let config = SigningHostConfig::new( + HostInfo { + name: "Polkadot Mobile".to_string(), + icon: None, + version: None, + }, + PlatformInfo::default(), + [0; 32], + [0xbb; 32], + ) + .expect("signing host config is valid"); + let services = RuntimeServices::new( + platform.clone(), + config.people_chain_genesis_hash, + config.bulletin_chain_genesis_hash, + test_spawner(), + ); + let signing_host = SigningHost::new(services.clone()); + futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + (services, signing_host) + } + + fn statement_sign_request(payload: Vec) -> v1::RemoteMessage { + v1::RemoteMessage::StatementStoreProductSignRequest( + messages::StatementStoreProductSignRequest { + product_account_id: product_account("myapp.dot"), + payload, + }, + ) + } + + fn statement_payload() -> Vec { + unsigned_statement_signing_payload(vec![ + StatementField::Expiry(42), + StatementField::Topic1([1; 32]), + StatementField::Data(vec![0xde, 0xad]), + ]) + .expect("valid statement payload") + } + + fn response_payload(answer: AnsweredRemoteMessage) -> v1::RemoteMessage { + let RemoteMessageData::V1(data) = answer.response.data; + data + } + + #[test] + fn statement_store_product_sign_rejects_non_statement_payload() { + let (services, signing_host) = signing_fixture(Arc::new(StubPlatform { + sign_raw_confirmed: true, + ..StubPlatform::default() + })); + + let response = futures::executor::block_on(answer_remote_message( + &services, + &signing_host, + "request-1".to_string(), + statement_sign_request(vec![0, 0, 1, 2, 3]), + )) + .expect("response is emitted"); + + let v1::RemoteMessage::StatementStoreProductSignResponse(response) = + response_payload(response) + else { + panic!("expected statement sign response"); + }; + assert_eq!(response.responding_to, "request-1"); + assert!( + response + .signature + .unwrap_err() + .contains("invalid statement signing payload") + ); + } + + #[test] + fn statement_store_product_sign_requires_confirmation() { + let (services, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); + + let response = futures::executor::block_on(answer_remote_message( + &services, + &signing_host, + "request-1".to_string(), + statement_sign_request(statement_payload()), + )) + .expect("response is emitted"); + + let v1::RemoteMessage::StatementStoreProductSignResponse(response) = + response_payload(response) + else { + panic!("expected statement sign response"); + }; + assert_eq!(response.signature.unwrap_err(), "Rejected"); + } + + #[test] + fn account_alias_requires_confirmation_for_cross_product_request() { + let (services, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); + + let response = futures::executor::block_on(answer_remote_message( + &services, + &signing_host, + "alias-1".to_string(), + v1::RemoteMessage::RingVrfAliasRequest(messages::RingVrfAliasRequest { + calling_product_id: "myapp.dot".to_string(), + context: api::ProductProofContext { + product_id: "other.dot".to_string(), + suffix: vec![], + }, + ring_location: api::RingLocation { + chain_id: [0; 32], + junctions: vec![], + }, + }), + )) + .expect("response is emitted"); + + let v1::RemoteMessage::RingVrfAliasResponse(response) = response_payload(response) else { + panic!("expected alias response"); + }; + assert_eq!(response.payload.unwrap_err(), RingVrfError::Rejected); + } + + #[test] + fn response_summary_reports_protocol_errors_without_multiline_output() { + let response = RemoteMessageData::V1(v1::RemoteMessage::RingVrfAliasResponse( + RingVrfAliasResponse { + responding_to: "alias-1".to_string(), + payload: Err(RingVrfError::Unknown { + reason: "chain RPC\ntimed out".to_string(), + }), + }, + )); + + let result = remote_response_result(&response); + let summary = response_cli_summary( + "SSO response sent", + "get_account_alias", + "statement-1", + "alias-1", + "alias-1:response", + &result, + 42, + ); + + assert_eq!(result.outcome, "error"); + assert_eq!( + summary, + "SSO response sent · get_account_alias · error\n\ + statement_request_id=statement-1\n\ + responding_to=alias-1\n\ + response_message_id=alias-1:response\n\ + elapsed_ms=42\n\ + reason=Unknown: chain RPC timed out" + ); + } + + #[test] + fn resource_allocation_summary_reflects_per_resource_outcomes() { + let result = resource_allocation_payload_result( + &Ok(vec![SsoAllocationOutcome::Rejected]), + &["timed out waiting for Bulletin authorization".to_string()], + ); + assert_eq!(result.outcome, "rejected"); + assert_eq!( + result.reason.as_deref(), + Some("Requested resource was rejected: timed out waiting for Bulletin authorization") + ); + + let result = resource_allocation_payload_result( + &Ok(vec![ + SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { + slot_account_key: vec![1; 64], + }), + SsoAllocationOutcome::Rejected, + SsoAllocationOutcome::NotAvailable, + ]), + &[], + ); + assert_eq!(result.outcome, "partial"); + assert_eq!( + result.reason.as_deref(), + Some("1 of 3 requested resources allocated; 1 rejected; 1 unavailable") + ); + + let result = + resource_allocation_payload_result(&Ok(vec![SsoAllocationOutcome::NotAvailable]), &[]); + assert_eq!(result.outcome, "not_available"); + assert_eq!( + result.reason.as_deref(), + Some("Requested resource is not available") + ); + } + + #[test] + fn resource_allocation_requires_confirmation_before_allocation() { + let (services, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); + + let response = futures::executor::block_on(answer_remote_message( + &services, + &signing_host, + "alloc-1".to_string(), + v1::RemoteMessage::ResourceAllocationRequest(messages::ResourceAllocationRequest { + calling_product_id: "myapp.dot".to_string(), + resources: vec![SsoAllocatableResource::StatementStoreAllowance], + on_existing: messages::OnExistingAllowancePolicy::Ignore, + }), + )) + .expect("response is emitted"); + + let v1::RemoteMessage::ResourceAllocationResponse(response) = response_payload(response) + else { + panic!("expected resource allocation response"); + }; + assert_eq!(response.payload.unwrap_err(), "Rejected"); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs new file mode 100644 index 00000000..af85f977 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -0,0 +1,467 @@ +//! 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 renewal; +pub mod ring; +pub mod rpc; +pub mod slot; + +use std::time::{Duration, Instant}; + +use futures::FutureExt; +use parity_scale_codec::Decode; +use serde_json::{Value, json}; +use sp_crypto_hashing::twox_128; +use tracing::{info, warn}; + +use extension::{ChainState, Metadata}; +use ring::RingParams; +use rpc::RpcClient; +use slot::SlotSelection; + +/// Bandersnatch entropy for a bip39 entropy: `blake2b256(bip39_entropy)`. +pub fn bandersnatch_entropy(bip39_entropy: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .hash(bip39_entropy) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +/// Fetch and decode the runtime metadata (`state_getMetadata`). +pub async fn fetch_metadata(rpc: &RpcClient) -> Result { + let value = rpc + .call("state_getMetadata", json!([])) + .await + .map_err(|e| e.to_string())?; + let hex_str = value + .as_str() + .ok_or_else(|| "state_getMetadata returned non-string".to_string())?; + let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(hex_str)) + .map_err(|e| format!("metadata hex: {e}"))?; + // `state_getMetadata` may return either the raw `RuntimeMetadataPrefixed` + // (starts with the `meta` magic) or an OpaqueMetadata wrapper + // (`Vec` = compact(len) ‖ bytes). Strip the wrapper only when present. + const META_MAGIC: [u8; 4] = *b"meta"; + if bytes.get(..4) == Some(&META_MAGIC) { + Metadata::decode(&bytes) + } else { + let inner = + Vec::::decode(&mut &bytes[..]).map_err(|e| format!("opaque metadata: {e}"))?; + Metadata::decode(&inner) + } +} + +/// Fetch the chain state needed to fill the signed extensions. +pub async fn fetch_chain_state(rpc: &RpcClient) -> Result { + let genesis_hex = rpc + .call("chain_getBlockHash", json!([0])) + .await + .map_err(|e| e.to_string())?; + let genesis_str = genesis_hex + .as_str() + .ok_or_else(|| "chain_getBlockHash returned non-string".to_string())?; + let genesis = hex::decode(genesis_str.strip_prefix("0x").unwrap_or(genesis_str)) + .map_err(|e| format!("genesis hex: {e}"))?; + let genesis_hash: [u8; 32] = genesis + .try_into() + .map_err(|_| "genesis hash is not 32 bytes".to_string())?; + + let runtime = rpc + .call("state_getRuntimeVersion", json!([])) + .await + .map_err(|e| e.to_string())?; + let spec_version = json_u32(&runtime, "specVersion")?; + let transaction_version = json_u32(&runtime, "transactionVersion")?; + + Ok(ChainState { + spec_version, + transaction_version, + genesis_hash, + nonce: 0, + }) +} + +/// Read a u32 field from a JSON object. +fn json_u32(value: &Value, field: &str) -> Result { + value + .get(field) + .and_then(Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .ok_or_else(|| format!("missing/invalid {field}")) +} + +/// Result of a statement-store allowance registration attempt. +pub enum RegistrationOutcome { + /// The extrinsic reached a block; the target now holds slot `seq`. + Registered { + /// Block hash the extrinsic landed in. + block_hash: String, + /// Claimed slot sequence. + seq: u32, + /// Ring index the proof was built against. + ring_index: u32, + }, + /// The target already held a slot this period; nothing submitted. + AlreadyAllocated { + /// Existing slot sequence. + seq: u32, + }, +} + +/// Result of a long-term storage claim attempt. +pub enum LongTermStorageOutcome { + /// The extrinsic reached a block; the target should receive Bulletin + /// authorization once XCM/chain propagation completes. + Claimed { + /// Block hash the extrinsic landed in. + block_hash: String, + /// Claimed counter within the long-term storage period. + counter: u8, + /// Ring index the proof was built against. + ring_index: u32, + }, +} + +/// Bulletin authorization state for one account. +#[derive(Debug, Clone, Copy)] +pub struct BulletinAllowanceInfo { + pub remained_size: u64, + pub remained_transactions: u32, + pub expires_in: u32, + pub fetched_at: u32, +} + +impl BulletinAllowanceInfo { + pub fn available(self) -> bool { + self.remained_size > 0 + && self.remained_transactions > 0 + && self.fetched_at < self.expires_in + } +} + +/// Find the newest ring (scanning up to `lookback` back from the current index) +/// that includes our member key. Reads the ring exponent once and stops at the +/// first match. +pub async fn find_including_ring( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + lookback: u32, +) -> Result, String> { + let member = proof::member_key(entropy); + let exponent = ring::read_ring_exponent(rpc, metadata).await?; + let current = ring::read_current_ring_index(rpc).await?; + let oldest = current.saturating_sub(lookback); + for ring_index in (oldest..=current).rev() { + let members = ring::read_ring_members_at(rpc, ring_index).await?; + if members.contains(&member) { + return Ok(Some(RingParams { + members, + exponent, + ring_index, + })); + } + } + Ok(None) +} + +/// Register statement-store allowance for `target`, proving membership in the +/// already-located `ring`, at UTC-day `period`. +pub async fn register_statement_account( + rpc: &RpcClient, + metadata: &Metadata, + chain_state: &ChainState, + entropy: [u8; 32], + target: &[u8; 32], + period: u32, + ring: &RingParams, +) -> Result { + let mut skipped_duplicate_slots = Vec::new(); + loop { + let seq = match slot::scan_slot_excluding( + rpc, + metadata, + entropy, + period, + target, + &skipped_duplicate_slots, + ) + .await? + { + SlotSelection::AlreadyAllocated(seq) => { + return Ok(RegistrationOutcome::AlreadyAllocated { seq }); + } + SlotSelection::Free(seq) => seq, + }; + + let context = slot::derive_slot_context(period, seq); + let call = extrinsic::build_set_statement_store_account_call(period, seq, target); + let message = extension::build_proof_message(metadata, &call, chain_state)?; + let domain = proof::domain_for_ring_exponent(ring.exponent)?; + let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; + let as_resources_extra = extrinsic::build_as_resources_extra(&ring_proof, ring.ring_index); + let extrinsic = + extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; + + match rpc.submit_and_watch(&extrinsic).await { + Ok(block_hash) => { + return Ok(RegistrationOutcome::Registered { + block_hash, + seq, + ring_index: ring.ring_index, + }); + } + Err(err) if duplicate_submit_error(&err) => { + skipped_duplicate_slots.push(seq); + } + Err(err) => return Err(err.to_string()), + } + } +} + +/// Claim long-term Bulletin storage authorization for `target`, proving +/// membership in the already-located `ring`, at People-chain `period`. +pub async fn claim_long_term_storage( + rpc: &RpcClient, + metadata: &Metadata, + chain_state: &ChainState, + entropy: [u8; 32], + target: &[u8; 32], + period: u32, + ring: &RingParams, +) -> Result { + let revision = ring::read_ring_revision(rpc, metadata, ring.ring_index).await?; + let mut skipped_duplicate_counters = Vec::new(); + loop { + let counter = slot::scan_long_term_storage_counter_excluding( + rpc, + metadata, + entropy, + period, + &skipped_duplicate_counters, + ) + .await?; + + let context = slot::derive_long_term_storage_context(period, counter); + let call = extrinsic::build_claim_long_term_storage_call(period, counter, target); + let message = extension::build_proof_message(metadata, &call, chain_state)?; + let domain = proof::domain_for_ring_exponent(ring.exponent)?; + let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; + let as_resources_extra = + extrinsic::build_long_term_storage_extra(&ring_proof, ring.ring_index, revision); + let extrinsic = + extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; + info!( + period, + counter, + ring_index = ring.ring_index, + revision, + "submitting Bulletin long-term-storage claim" + ); + + match rpc.submit_and_watch(&extrinsic).await { + Ok(block_hash) => { + return Ok(LongTermStorageOutcome::Claimed { + block_hash, + counter, + ring_index: ring.ring_index, + }); + } + Err(err) if duplicate_submit_error(&err) => { + skipped_duplicate_counters.push(counter); + } + Err(err) => { + warn!( + period, + counter, + ring_index = ring.ring_index, + revision, + %err, + "Bulletin long-term-storage claim failed" + ); + return Err(err.to_string()); + } + } + } +} + +/// Fetch Bulletin `TransactionStorage.Authorizations[Account(target)]`. +pub async fn fetch_bulletin_allowance( + rpc: &RpcClient, + target: &[u8; 32], +) -> Result, String> { + let Some(bytes) = rpc + .get_storage(&bulletin_authorization_key(target)) + .await + .map_err(|e| e.to_string())? + else { + return Ok(None); + }; + let fetched_at = fetch_block_number(rpc).await?; + decode_bulletin_allowance(&bytes, fetched_at).map(Some) +} + +/// Wait until Bulletin authorization is available and fresher than `current`. +pub async fn wait_bulletin_authorization( + rpc: &RpcClient, + target: &[u8; 32], + current: Option, + timeout: Duration, +) -> Result { + let started = Instant::now(); + let baseline = current.filter(|info| info.available()); + loop { + let Some(info) = fetch_bulletin_allowance(rpc, target).await? else { + wait_before_next_bulletin_authorization_poll(started, timeout).await?; + continue; + }; + if authorization_refreshed(info, baseline) { + return Ok(info); + } + wait_before_next_bulletin_authorization_poll(started, timeout).await?; + } +} + +async fn wait_before_next_bulletin_authorization_poll( + started: Instant, + timeout: Duration, +) -> Result<(), String> { + let Some(remaining) = timeout.checked_sub(started.elapsed()) else { + return Err("timed out waiting for Bulletin authorization".to_string()); + }; + let delay = futures_timer::Delay::new(remaining.min(Duration::from_secs(2))).fuse(); + futures::pin_mut!(delay); + delay.await; + Ok(()) +} + +fn authorization_refreshed( + info: BulletinAllowanceInfo, + baseline: Option, +) -> bool { + if !info.available() { + return false; + } + match baseline { + None => true, + Some(current) => { + info.remained_transactions > current.remained_transactions + || info.remained_size > current.remained_size + || info.expires_in > current.expires_in + } + } +} + +/// `TransactionStorage.Authorizations[AuthorizationScope::Account(target)]`. +fn bulletin_authorization_key(target: &[u8; 32]) -> Vec { + let mut scope = Vec::with_capacity(1 + 32); + scope.push(0x00); + scope.extend_from_slice(target); + [ + twox_128(b"TransactionStorage").as_slice(), + twox_128(b"Authorizations").as_slice(), + &ring::blake2_128_concat(&scope), + ] + .concat() +} + +fn decode_bulletin_allowance( + bytes: &[u8], + fetched_at: u32, +) -> Result { + let mut input = bytes; + let transactions = + u32::decode(&mut input).map_err(|err| format!("authorization transactions: {err}"))?; + let transactions_allowance = u32::decode(&mut input) + .map_err(|err| format!("authorization transactions_allowance: {err}"))?; + let bytes_used = + u64::decode(&mut input).map_err(|err| format!("authorization bytes: {err}"))?; + let _bytes_permanent = + u64::decode(&mut input).map_err(|err| format!("authorization bytes_permanent: {err}"))?; + let bytes_allowance = + u64::decode(&mut input).map_err(|err| format!("authorization bytes_allowance: {err}"))?; + let expires_in = + u32::decode(&mut input).map_err(|err| format!("authorization expiration: {err}"))?; + Ok(BulletinAllowanceInfo { + remained_size: bytes_allowance.saturating_sub(bytes_used), + remained_transactions: transactions_allowance.saturating_sub(transactions), + expires_in, + fetched_at, + }) +} + +async fn fetch_block_number(rpc: &RpcClient) -> Result { + let header = rpc + .call("chain_getHeader", json!([])) + .await + .map_err(|err| err.to_string())?; + let number = header + .get("number") + .and_then(Value::as_str) + .ok_or_else(|| "chain_getHeader returned no number".to_string())?; + u32::from_str_radix(number.trim_start_matches("0x"), 16) + .map_err(|err| format!("chain_getHeader number: {err}")) +} + +fn duplicate_submit_error(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("priority is too low") + || message.contains("already imported") + || message.contains("temporarily banned") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn allowance( + remained_size: u64, + remained_transactions: u32, + expires_in: u32, + ) -> BulletinAllowanceInfo { + BulletinAllowanceInfo { + remained_size, + remained_transactions, + expires_in, + fetched_at: 10, + } + } + + #[test] + fn bulletin_refresh_accepts_available_state_when_baseline_was_unusable() { + let exhausted_by_size = allowance(0, 4, 100); + let refreshed_same_transactions = allowance(4096, 4, 100); + + assert!(!exhausted_by_size.available()); + assert!(authorization_refreshed( + refreshed_same_transactions, + Some(exhausted_by_size).filter(|info| info.available()), + )); + } + + #[test] + fn bulletin_refresh_accepts_size_only_increase() { + let baseline = allowance(128, 4, 100); + let refreshed = allowance(4096, 4, 100); + + assert!(authorization_refreshed(refreshed, Some(baseline))); + } + + #[test] + fn bulletin_refresh_rejects_unchanged_available_state() { + let baseline = allowance(128, 4, 100); + + assert!(!authorization_refreshed(baseline, Some(baseline))); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs new file mode 100644 index 00000000..42e1df77 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs @@ -0,0 +1,164 @@ +//! Minimal metadata-driven SCALE walker. +//! +//! Just enough to read one field out of a storage struct without a full dynamic +//! codec: `skip` advances a cursor past one value of a given type, +//! `read_field_variant_name` walks a composite to a named field and returns its +//! enum variant name (used for `CollectionInfo.ring_size` -> `R2e9`/`R2e10`/`R2e14`), +//! and `read_field_u32` reads simple numeric fields such as `RingRoot.revision`. + +use parity_scale_codec::{Compact, Decode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + +/// Advance `input` past exactly one SCALE-encoded value of `type_id`. +pub fn skip(registry: &PortableRegistry, type_id: u32, input: &mut &[u8]) -> Result<(), String> { + let ty = registry + .resolve(type_id) + .ok_or_else(|| format!("unknown type id {type_id}"))?; + match &ty.type_def { + TypeDef::Composite(c) => { + for field in &c.fields { + skip(registry, field.ty.id, input)?; + } + } + TypeDef::Tuple(t) => { + for field in &t.fields { + skip(registry, field.id, input)?; + } + } + TypeDef::Array(a) => { + for _ in 0..a.len { + skip(registry, a.type_param.id, input)?; + } + } + TypeDef::Sequence(s) => { + let len = read_compact(input)?; + for _ in 0..len { + skip(registry, s.type_param.id, input)?; + } + } + TypeDef::Variant(v) => { + let index = read_u8(input)?; + let variant = v + .variants + .iter() + .find(|var| var.index == index) + .ok_or_else(|| format!("unknown variant index {index}"))?; + for field in &variant.fields { + skip(registry, field.ty.id, input)?; + } + } + TypeDef::Compact(_) => { + read_compact(input)?; + } + TypeDef::BitSequence(_) => { + let bits = read_compact(input)?; + advance(input, bits.div_ceil(8))?; + } + TypeDef::Primitive(p) => { + let len = match p { + TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => 1, + TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => 2, + TypeDefPrimitive::Char | TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => 4, + TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => 8, + TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => 16, + TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => 32, + // Length-prefixed UTF-8: compact byte length then the bytes. + TypeDefPrimitive::Str => read_compact(input)?, + }; + advance(input, len)?; + } + } + Ok(()) +} + +/// Walk composite `struct_type_id` to `field_name` and return the enum variant +/// name selected there (the field must be a fieldless/simple enum). +pub fn read_field_variant_name( + registry: &PortableRegistry, + struct_type_id: u32, + field_name: &str, + bytes: &[u8], +) -> Result { + let ty = registry + .resolve(struct_type_id) + .ok_or_else(|| format!("unknown type id {struct_type_id}"))?; + let TypeDef::Composite(composite) = &ty.type_def else { + return Err(format!("type {struct_type_id} is not a composite")); + }; + + let mut input = bytes; + for field in &composite.fields { + if field.name.as_deref() == Some(field_name) { + let field_ty = registry + .resolve(field.ty.id) + .ok_or_else(|| format!("unknown field type id {}", field.ty.id))?; + let TypeDef::Variant(variant) = &field_ty.type_def else { + return Err(format!("field `{field_name}` is not an enum")); + }; + let index = read_u8(&mut input)?; + return variant + .variants + .iter() + .find(|var| var.index == index) + .map(|var| var.name.clone()) + .ok_or_else(|| format!("unknown variant index {index} for `{field_name}`")); + } + skip(registry, field.ty.id, &mut input)?; + } + Err(format!("field `{field_name}` not found")) +} + +/// Walk composite `struct_type_id` to `field_name` and decode the field as a +/// SCALE `u32`. +pub fn read_field_u32( + registry: &PortableRegistry, + struct_type_id: u32, + field_name: &str, + bytes: &[u8], +) -> Result { + let ty = registry + .resolve(struct_type_id) + .ok_or_else(|| format!("unknown type id {struct_type_id}"))?; + let TypeDef::Composite(composite) = &ty.type_def else { + return Err(format!("type {struct_type_id} is not a composite")); + }; + + let mut input = bytes; + for field in &composite.fields { + if field.name.as_deref() == Some(field_name) { + let field_ty = registry + .resolve(field.ty.id) + .ok_or_else(|| format!("unknown field type id {}", field.ty.id))?; + if !matches!(field_ty.type_def, TypeDef::Primitive(TypeDefPrimitive::U32)) { + return Err(format!("field `{field_name}` is not a u32")); + } + return u32::decode(&mut input).map_err(|err| format!("field `{field_name}`: {err}")); + } + skip(registry, field.ty.id, &mut input)?; + } + Err(format!("field `{field_name}` not found")) +} + +/// Decode a SCALE compact-encoded length, advancing `input`. +fn read_compact(input: &mut &[u8]) -> Result { + let Compact(value) = Compact::::decode(input).map_err(|err| format!("compact: {err}"))?; + usize::try_from(value).map_err(|_| "compact length overflow".to_string()) +} + +/// Read one byte, advancing `input`. +fn read_u8(input: &mut &[u8]) -> Result { + let (&first, rest) = input + .split_first() + .ok_or_else(|| "unexpected end".to_string())?; + *input = rest; + Ok(first) +} + +/// Advance `input` by `n` bytes. +fn advance(input: &mut &[u8], n: usize) -> Result<(), String> { + if input.len() < n { + return Err(format!("need {n} bytes, have {}", input.len())); + } + *input = &input[n..]; + Ok(()) +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs new file mode 100644 index 00000000..64bf6153 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs @@ -0,0 +1,419 @@ +//! Signed-extension encoding for the unsigned General (v5) `AsResources` +//! extrinsic, driven by live chain metadata. +//! +//! The extension **order** and per-extension type ids come from the runtime +//! metadata (`state_getMetadata`, V14/V15/V16); the per-extension `extra` / +//! `additional_signed` bytes come from a name-keyed encoder mirroring +//! signing-bot `src/core/create-transaction.ts` `encodeSignedExtensions`, with a +//! generic default for the personhood extensions (all `Option`/void). +//! +//! Two concatenations are derived from the same encoded list: +//! - the ring-VRF proof message (`build_proof_message`) over the extensions +//! strictly *after* `AsResources` (host-spec inherited implication), and +//! - the full extrinsic body's `Σ extra` (see `extrinsic.rs`), over *all* +//! extensions with `AsResources` carrying `Some(AsResourcesInfo)`. + +use std::collections::HashMap; + +use frame_metadata::RuntimeMetadata; +use frame_metadata::RuntimeMetadataPrefixed; +use parity_scale_codec::{Compact, Decode, Encode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + +/// Signed-extension identifier that carries the `AsResources` authorization. +pub const AS_RESOURCES: &str = "AsResources"; + +/// Chain state needed to fill the standard signed extensions. +#[derive(Debug, Clone, Copy)] +pub struct ChainState { + /// Runtime `specVersion` (CheckSpecVersion implicit). + pub spec_version: u32, + /// Runtime `transactionVersion` (CheckTxVersion implicit). + pub transaction_version: u32, + /// Genesis block hash (CheckGenesis / CheckMortality implicit). + pub genesis_hash: [u8; 32], + /// Account nonce (CheckNonce extra); ignored by the unsigned path. + pub nonce: u32, +} + +/// A signed extension's identifier plus the type ids of its `extra` and +/// `additional_signed` fields, in metadata order. +struct ExtensionDef { + identifier: String, + extra_type: u32, + additional_signed_type: u32, +} + +/// A signed extension encoded to its `extra` and `additional_signed` bytes. +pub struct EncodedExtension { + /// SCALE-encoded `extra` (goes into the extrinsic body). + pub extra: Vec, + /// SCALE-encoded `additional_signed` (the implicit, part of the signed data). + pub additional_signed: Vec, +} + +/// Decoded metadata: the ordered signed-extension defs, the type registry, and +/// each storage entry's value type id (`(pallet, entry) -> type id`). +pub struct Metadata { + extensions: Vec, + registry: PortableRegistry, + storage_values: HashMap<(String, String), u32>, + constants: HashMap<(String, String), Vec>, +} + +/// Collect extensions, type registry, storage value types, and pallet constants +/// from decoded metadata; `$set` is the version's `StorageEntryType`. +macro_rules! collect_metadata { + ($m:expr, $set:path) => {{ + let extensions = $m + .extrinsic + .signed_extensions + .iter() + .map(|e| ExtensionDef { + identifier: e.identifier.clone(), + extra_type: e.ty.id, + additional_signed_type: e.additional_signed.id, + }) + .collect(); + let mut storage_values = HashMap::new(); + let mut constants = HashMap::new(); + for pallet in &$m.pallets { + for constant in &pallet.constants { + constants.insert( + (pallet.name.clone(), constant.name.clone()), + constant.value.clone(), + ); + } + let Some(storage) = &pallet.storage else { + continue; + }; + for entry in &storage.entries { + use $set as EntryType; + let value_type = match &entry.ty { + EntryType::Plain(ty) => ty.id, + EntryType::Map { value, .. } => value.id, + }; + storage_values.insert((pallet.name.clone(), entry.name.clone()), value_type); + } + } + (extensions, $m.types, storage_values, constants) + }}; +} + +macro_rules! collect_metadata_v16 { + ($m:expr) => {{ + let extension_indexes = $m + .extrinsic + .transaction_extensions_by_version + .get(&5) + .map(|indexes| { + indexes + .iter() + .map(|Compact(index)| *index as usize) + .collect::>() + }) + .unwrap_or_else(|| (0..$m.extrinsic.transaction_extensions.len()).collect()); + let extensions = extension_indexes + .into_iter() + .filter_map(|index| $m.extrinsic.transaction_extensions.get(index)) + .map(|e| ExtensionDef { + identifier: e.identifier.clone(), + extra_type: e.ty.id, + additional_signed_type: e.implicit.id, + }) + .collect(); + let mut storage_values = HashMap::new(); + let mut constants = HashMap::new(); + for pallet in &$m.pallets { + for constant in &pallet.constants { + constants.insert( + (pallet.name.clone(), constant.name.clone()), + constant.value.clone(), + ); + } + let Some(storage) = &pallet.storage else { + continue; + }; + for entry in &storage.entries { + use frame_metadata::v16::StorageEntryType as EntryType; + let value_type = match &entry.ty { + EntryType::Plain(ty) => ty.id, + EntryType::Map { value, .. } => value.id, + }; + storage_values.insert((pallet.name.clone(), entry.name.clone()), value_type); + } + } + (extensions, $m.types, storage_values, constants) + }}; +} + +impl Metadata { + /// Decode `state_getMetadata` bytes (a `RuntimeMetadataPrefixed`, V14 or + /// V15) into the ordered signed-extension defs, type registry, and storage + /// value types. + pub fn decode(bytes: &[u8]) -> Result { + let prefixed = RuntimeMetadataPrefixed::decode(&mut &bytes[..]) + .map_err(|err| format!("metadata decode failed: {err}"))?; + let (extensions, registry, storage_values, constants) = match prefixed.1 { + RuntimeMetadata::V14(m) => collect_metadata!(m, frame_metadata::v14::StorageEntryType), + RuntimeMetadata::V15(m) => collect_metadata!(m, frame_metadata::v15::StorageEntryType), + RuntimeMetadata::V16(m) => collect_metadata_v16!(m), + other => return Err(format!("unsupported metadata version {}", other.version())), + }; + Ok(Self { + extensions, + registry, + storage_values, + constants, + }) + } + + /// The type registry, for dynamic decoding of storage values. + pub fn registry(&self) -> &PortableRegistry { + &self.registry + } + + /// The value type id of storage entry `pallet::entry`, if present. + pub fn storage_value_type(&self, pallet: &str, entry: &str) -> Option { + self.storage_values + .get(&(pallet.to_string(), entry.to_string())) + .copied() + } + + /// The SCALE-encoded value bytes of pallet constant `pallet::name`. + pub fn constant(&self, pallet: &str, name: &str) -> Option<&[u8]> { + self.constants + .get(&(pallet.to_string(), name.to_string())) + .map(Vec::as_slice) + } + + /// Encode every signed extension in metadata order. + pub fn encode_signed_extensions(&self, state: &ChainState) -> Vec { + self.extensions + .iter() + .map(|ext| { + let (extra, additional_signed) = self.encode_one(ext, state); + EncodedExtension { + extra, + additional_signed, + } + }) + .collect() + } + + /// The signed-extension identifiers, in metadata order. + #[cfg(test)] + pub fn extension_ids(&self) -> Vec<&str> { + self.extensions + .iter() + .map(|e| e.identifier.as_str()) + .collect() + } + + /// Encode a single extension's `(extra, additional_signed)`, mirroring the + /// signing-bot switch; unknown personhood extensions fall back to the + /// metadata type default (`Option` -> None, void -> empty). + fn encode_one(&self, ext: &ExtensionDef, state: &ChainState) -> (Vec, Vec) { + match ext.identifier.as_str() { + "CheckNonce" => (Compact(state.nonce).encode(), Vec::new()), + "CheckSpecVersion" => (Vec::new(), state.spec_version.to_le_bytes().to_vec()), + "CheckTxVersion" => (Vec::new(), state.transaction_version.to_le_bytes().to_vec()), + "CheckGenesis" => (Vec::new(), state.genesis_hash.to_vec()), + // extra = Era::Immortal (0x00); implicit = genesis hash. + "CheckMortality" => (vec![0x00], state.genesis_hash.to_vec()), + // extra = first variant `Disabled` (void) = 0x00. + "VerifyMultiSignature" => (vec![0x00], Vec::new()), + // extra = { tip: compact(0), asset_id: None } = 0x00 0x00. + "ChargeAssetTxPayment" => (vec![0x00, 0x00], Vec::new()), + // extra = bool false = 0x00. + "RestrictOrigins" => (vec![0x00], Vec::new()), + _ => ( + self.encode_default(ext.extra_type), + self.encode_default(ext.additional_signed_type), + ), + } + } + + /// Encode the "disabled" default value for a metadata type: `Option` -> None + /// (`0x00`), void/empty tuple -> empty, enums -> first variant, primitives + /// -> zero. Matches signing-bot `defaultValueForType`. + fn encode_default(&self, type_id: u32) -> Vec { + let Some(ty) = self.registry.resolve(type_id) else { + return Vec::new(); + }; + match &ty.type_def { + TypeDef::Composite(c) => c + .fields + .iter() + .flat_map(|f| self.encode_default(f.ty.id)) + .collect(), + TypeDef::Tuple(t) => t + .fields + .iter() + .flat_map(|f| self.encode_default(f.id)) + .collect(), + TypeDef::Variant(v) => { + // Option encodes None as 0x00. + if ty.path.segments.last().map(String::as_str) == Some("Option") { + return vec![0x00]; + } + match v.variants.iter().min_by_key(|var| var.index) { + None => Vec::new(), + Some(first) => { + let mut out = vec![first.index]; + for field in &first.fields { + out.extend(self.encode_default(field.ty.id)); + } + out + } + } + } + TypeDef::Array(a) => { + let elem = self.encode_default(a.type_param.id); + elem.repeat(a.len as usize) + } + // Sequences / strings / bit-sequences encode an empty run as compact(0). + TypeDef::Sequence(_) | TypeDef::BitSequence(_) => vec![0x00], + TypeDef::Compact(_) => vec![0x00], + TypeDef::Primitive(p) => match p { + TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => vec![0], + TypeDefPrimitive::Char | TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => { + vec![0; 4] + } + TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => vec![0; 2], + TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => vec![0; 8], + TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => vec![0; 16], + TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => vec![0; 32], + // Length-prefixed string: empty = compact(0). + TypeDefPrimitive::Str => vec![0x00], + }, + } + } + + /// Index of `AsResources` in the extension list, if present. + pub fn as_resources_index(&self) -> Option { + self.extensions + .iter() + .position(|e| e.identifier == AS_RESOURCES) + } +} + +/// Build the ring-VRF proof message for an `AsResources`-authorized call: +/// `blake2b256(0x00 ‖ call ‖ Σ tail.extra ‖ Σ tail.additional_signed)`, where +/// the tail is the extensions ordered strictly after `AsResources`. The leading +/// `0x00` is the General-transaction extension-version byte. +pub fn build_proof_message( + metadata: &Metadata, + call_data: &[u8], + state: &ChainState, +) -> Result<[u8; 32], String> { + let all = metadata.encode_signed_extensions(state); + let tail_start = metadata + .as_resources_index() + .map(|i| i + 1) + .ok_or_else(|| format!("{AS_RESOURCES} extension not found in metadata"))?; + let tail = &all[tail_start..]; + + let mut payload = Vec::with_capacity(1 + call_data.len()); + payload.push(0x00); + payload.extend_from_slice(call_data); + for ext in tail { + payload.extend_from_slice(&ext.extra); + } + for ext in tail { + payload.extend_from_slice(&ext.additional_signed); + } + Ok(blake2b256(&payload)) +} + +/// BLAKE2b-256 of `message`. +pub fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .hash(message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fixture metadata captured from paseo-next-v2 (raw `RuntimeMetadataPrefixed`). + const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + + /// The known-answer chain state frozen alongside the fixture. + fn fixture_state() -> ChainState { + ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + } + } + + /// `Resources.set_statement_store_account(period=7, seq=0, target=0)`. + fn fixture_call() -> Vec { + let mut call = vec![0x3f, 0x0a]; + call.extend_from_slice(&7u32.to_le_bytes()); + call.extend_from_slice(&0u32.to_le_bytes()); + call.extend_from_slice(&[0u8; 32]); + call + } + + #[test] + fn proof_message_matches_frozen_known_answer() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let msg = build_proof_message(&metadata, &fixture_call(), &fixture_state()).unwrap(); + assert_eq!( + hex::encode(msg), + "1d2e6d8d8f421b0857097c6076115507432d66fea47ebe0c3be282a369f6743c", + ); + } + + #[test] + fn as_resources_tail_is_indices_10_through_20() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let idx = metadata.as_resources_index().unwrap(); + // AsResources sits at index 9; the proof tail is everything after it. + assert_eq!(idx, 9); + let ids = metadata.extension_ids(); + assert_eq!( + ids[idx + 1..].to_vec(), + vec![ + "AuthorizeCall", + "RestrictOrigins", + "CheckNonZeroSender", + "CheckSpecVersion", + "CheckTxVersion", + "CheckGenesis", + "CheckMortality", + "CheckNonce", + "CheckWeight", + "ChargeAssetTxPayment", + "StorageWeightReclaim", + ], + ); + } + + #[test] + fn dropping_the_version_byte_changes_the_hash() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let state = fixture_state(); + let call = fixture_call(); + let all = metadata.encode_signed_extensions(&state); + let tail = &all[metadata.as_resources_index().unwrap() + 1..]; + let mut without = call.clone(); + for e in tail { + without.extend_from_slice(&e.extra); + } + for e in tail { + without.extend_from_slice(&e.additional_signed); + } + assert_ne!( + build_proof_message(&metadata, &call, &state).unwrap(), + blake2b256(&without), + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs new file mode 100644 index 00000000..a427e88a --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs @@ -0,0 +1,204 @@ +//! `Resources.set_statement_store_account` call + unsigned General (v5) +//! extrinsic assembly. Mirrors signing-bot `allocation.ts` / `extrinsic-submit.ts`. + +use parity_scale_codec::{Compact, Encode}; + +use super::extension::{ChainState, Metadata}; + +/// Pallet + call index for `Resources.set_statement_store_account` (63 / 10). +pub const SET_STATEMENT_STORE_ACCOUNT_CALL: [u8; 2] = [0x3f, 0x0a]; +/// Pallet + call index for `Resources.claim_long_term_storage` (63 / 12). +pub const CLAIM_LONG_TERM_STORAGE_CALL: [u8; 2] = [0x3f, 0x0c]; +/// `AsResourcesInfo::RegisterStatementStoreAllowance` variant index. +const REGISTER_STATEMENT_STORE_ALLOWANCE: u8 = 0x02; +/// `AsResourcesInfo::ClaimLongTermStorage` variant index. +const CLAIM_LONG_TERM_STORAGE: u8 = 0x03; +/// `MembershipCollection::LitePeople` variant index. +const MEMBERSHIP_COLLECTION_LITE_PEOPLE: u8 = 0x01; +/// General-transaction preamble byte: `0b01` (General) | version 5. +const GENERAL_V5_PREAMBLE: u8 = 0x45; +/// Current signed-extension version byte. +const EXTENSION_VERSION: u8 = 0x00; +/// `Option::Some` discriminant for the `AsResources` extension `extra`. +const OPTION_SOME: u8 = 0x01; + +/// Encode `Resources.set_statement_store_account(period, seq, target)`: +/// `3f 0a ‖ period_u32LE ‖ seq_u32LE ‖ target[32]`. +pub fn build_set_statement_store_account_call(period: u32, seq: u32, target: &[u8; 32]) -> Vec { + let mut call = Vec::with_capacity(2 + 4 + 4 + 32); + call.extend_from_slice(&SET_STATEMENT_STORE_ACCOUNT_CALL); + call.extend_from_slice(&period.to_le_bytes()); + call.extend_from_slice(&seq.to_le_bytes()); + call.extend_from_slice(target); + call +} + +/// Encode `Resources.claim_long_term_storage(period, counter, account_id)`: +/// `3f 0c ‖ period_u32LE ‖ counter_u8 ‖ account_id[32]`. +pub fn build_claim_long_term_storage_call( + period: u32, + counter: u8, + account_id: &[u8; 32], +) -> Vec { + let mut call = Vec::with_capacity(2 + 4 + 1 + 32); + call.extend_from_slice(&CLAIM_LONG_TERM_STORAGE_CALL); + call.extend_from_slice(&period.to_le_bytes()); + call.push(counter); + call.extend_from_slice(account_id); + call +} + +/// Encode the `AsResources` extension `extra` for a statement-store allowance: +/// `Some(RegisterStatementStoreAllowance { proof, ring_index, LitePeople })`. +pub fn build_as_resources_extra(proof: &[u8], ring_index: u32) -> Vec { + let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 1); + extra.push(OPTION_SOME); + extra.push(REGISTER_STATEMENT_STORE_ALLOWANCE); + extra.extend_from_slice(&Compact(proof.len() as u32).encode()); + extra.extend_from_slice(proof); + extra.extend_from_slice(&ring_index.to_le_bytes()); + extra.push(MEMBERSHIP_COLLECTION_LITE_PEOPLE); + extra +} + +/// Encode the `AsResources` extension `extra` for a long-term storage claim: +/// `Some(ClaimLongTermStorage { proof, ring_index, revision, LitePeople })`. +pub fn build_long_term_storage_extra(proof: &[u8], ring_index: u32, revision: u32) -> Vec { + let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 4 + 1); + extra.push(OPTION_SOME); + extra.push(CLAIM_LONG_TERM_STORAGE); + extra.extend_from_slice(&Compact(proof.len() as u32).encode()); + extra.extend_from_slice(proof); + extra.extend_from_slice(&ring_index.to_le_bytes()); + extra.extend_from_slice(&revision.to_le_bytes()); + extra.push(MEMBERSHIP_COLLECTION_LITE_PEOPLE); + extra +} + +/// Assemble the unsigned General (v5) extrinsic: +/// `compact(len) ‖ 0x45 ‖ 0x00 ‖ Σ(all extra, AsResources = Some(info)) ‖ call`. +pub fn build_unsigned_extrinsic( + metadata: &Metadata, + state: &ChainState, + call_data: &[u8], + as_resources_extra: &[u8], +) -> Result, String> { + let all = metadata.encode_signed_extensions(state); + let as_resources_index = metadata + .as_resources_index() + .ok_or_else(|| "AsResources extension not found in metadata".to_string())?; + + let mut body = vec![GENERAL_V5_PREAMBLE, EXTENSION_VERSION]; + for (i, ext) in all.iter().enumerate() { + if i == as_resources_index { + body.extend_from_slice(as_resources_extra); + } else { + body.extend_from_slice(&ext.extra); + } + } + body.extend_from_slice(call_data); + + let mut extrinsic = Compact(body.len() as u32).encode(); + extrinsic.extend_from_slice(&body); + Ok(extrinsic) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + + fn fixture_state() -> ChainState { + ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + } + } + + #[test] + fn call_layout_is_pallet_call_period_seq_target() { + let call = build_set_statement_store_account_call(7, 0, &[0u8; 32]); + assert_eq!( + call, + [ + vec![0x3f, 0x0a], + 7u32.to_le_bytes().to_vec(), + 0u32.to_le_bytes().to_vec(), + vec![0u8; 32], + ] + .concat() + ); + } + + #[test] + fn long_term_storage_call_layout_is_pallet_call_period_counter_account() { + let call = build_claim_long_term_storage_call(7, 3, &[0u8; 32]); + assert_eq!( + call, + [ + vec![0x3f, 0x0c], + 7u32.to_le_bytes().to_vec(), + vec![3], + vec![0u8; 32], + ] + .concat() + ); + } + + #[test] + fn as_resources_extra_wraps_proof_as_bytes() { + let proof = vec![0xEE; 785]; + let extra = build_as_resources_extra(&proof, 3); + // Some(0x01) ‖ variant(0x02) ‖ compact(785)=0x45,0x0c ‖ 785 bytes ‖ ringIndex LE ‖ LitePeople. + assert_eq!(&extra[..2], &[0x01, 0x02]); + assert_eq!(&extra[2..4], &Compact(785u32).encode()[..]); + assert_eq!(&extra[4..4 + 785], &proof[..]); + assert_eq!(&extra[4 + 785..4 + 785 + 4], &3u32.to_le_bytes()); + assert_eq!(extra[4 + 785 + 4], MEMBERSHIP_COLLECTION_LITE_PEOPLE); + } + + #[test] + fn long_term_storage_extra_wraps_revision() { + let proof = vec![0xEE; 785]; + let extra = build_long_term_storage_extra(&proof, 3, 9); + // Some(0x01) ‖ variant(0x03) ‖ compact(785)=0x45,0x0c ‖ proof + // ‖ ringIndex LE ‖ revision LE ‖ LitePeople. + assert_eq!(&extra[..2], &[0x01, 0x03]); + assert_eq!(&extra[2..4], &Compact(785u32).encode()[..]); + assert_eq!(&extra[4..4 + 785], &proof[..]); + assert_eq!(&extra[4 + 785..4 + 785 + 4], &3u32.to_le_bytes()); + assert_eq!(&extra[4 + 785 + 4..4 + 785 + 8], &9u32.to_le_bytes()); + assert_eq!(extra[4 + 785 + 8], MEMBERSHIP_COLLECTION_LITE_PEOPLE); + } + + #[test] + fn extrinsic_has_general_v5_preamble_and_embeds_call() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let call = build_set_statement_store_account_call(7, 0, &[0u8; 32]); + let extra = build_as_resources_extra(&vec![0xEE; 785], 0); + let xt = build_unsigned_extrinsic(&metadata, &fixture_state(), &call, &extra).unwrap(); + + // Strip the compact length prefix and check the body head + tail. + let body = &xt[compact_prefix_len(&xt)..]; + assert_eq!(&body[..2], &[GENERAL_V5_PREAMBLE, EXTENSION_VERSION]); + assert_eq!(&body[body.len() - call.len()..], &call[..]); + // The Some(info) extra appears verbatim in the body. + assert!( + body.windows(extra.len()).any(|w| w == extra), + "AsResources Some(info) extra should appear in the body", + ); + } + + /// Length of the SCALE compact prefix at the head of `xt`. + fn compact_prefix_len(xt: &[u8]) -> usize { + match xt[0] & 0b11 { + 0b00 => 1, + 0b01 => 2, + 0b10 => 4, + _ => 5, + } + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs new file mode 100644 index 00000000..b9dc567f --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs @@ -0,0 +1,111 @@ +//! Bandersnatch ring-VRF one-shot membership proof (prover side). +//! +//! Wraps `verifiable`'s prover-gated `open` + `create` into the single-shot +//! proof a `RegisterStatementStoreAllowance` needs: prove that our member key is +//! in the LitePeople ring, bound to a slot `context` and the extrinsic proof +//! `message`. Mirrors signing-bot `ring-proof.ts` `oneShotProof`. + +use verifiable::GenerateVerifiable; +use verifiable::ring::RingDomainSize; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +/// A single-context ring-VRF signature is exactly 785 bytes. +pub const RING_VRF_PROOF_LEN: usize = 785; + +/// Map an on-chain `RingExponent` (9 / 10 / 14) to the FFT domain size +/// (power = exponent + 2). +pub fn domain_for_ring_exponent(exponent: u8) -> Result { + match exponent { + 9 => Ok(RingDomainSize::Domain11), + 10 => Ok(RingDomainSize::Domain12), + 14 => Ok(RingDomainSize::Domain16), + other => Err(format!("unsupported ring exponent {other}")), + } +} + +/// The ring member key for a bandersnatch entropy (`blake2b256(bip39_entropy)`). +pub fn member_key(entropy: [u8; 32]) -> [u8; 32] { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + BandersnatchVrfVerifiable::member_from_secret(&secret) +} + +/// Produce the 785-byte ring-VRF membership proof over `members` (already +/// sliced to the ring's included prefix), bound to `context` and `message`. +/// +/// `entropy` is the bandersnatch entropy; its member key must be present in +/// `members` or `open` fails with `NotInRing`. +pub fn ring_vrf_proof( + domain: RingDomainSize, + entropy: [u8; 32], + members: &[[u8; 32]], + context: &[u8], + message: &[u8], +) -> Result, String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let member = BandersnatchVrfVerifiable::member_from_secret(&secret); + let commitment = BandersnatchVrfVerifiable::open(domain, &member, members.iter().copied()) + .map_err(|err| format!("ring-VRF open failed: {err:?}"))?; + let (proof, _alias) = BandersnatchVrfVerifiable::create(commitment, &secret, context, message) + .map_err(|err| format!("ring-VRF create failed: {err:?}"))?; + let bytes = proof.into_inner(); + if bytes.len() != RING_VRF_PROOF_LEN { + return Err(format!( + "ring-VRF proof is {} bytes, expected {RING_VRF_PROOF_LEN}", + bytes.len() + )); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exponent_maps_to_domain() { + assert_eq!( + domain_for_ring_exponent(9).unwrap(), + RingDomainSize::Domain11 + ); + assert_eq!( + domain_for_ring_exponent(10).unwrap(), + RingDomainSize::Domain12 + ); + assert_eq!( + domain_for_ring_exponent(14).unwrap(), + RingDomainSize::Domain16 + ); + assert!(domain_for_ring_exponent(11).is_err()); + } + + #[test] + fn proof_is_785_bytes_for_a_single_member_ring() { + let entropy = [0x11u8; 32]; + let member = member_key(entropy); + let members = vec![member]; + let proof = ring_vrf_proof( + RingDomainSize::Domain11, + entropy, + &members, + b"SSS_SLOT:test-context-padding..", + &[0x42; 32], + ) + .unwrap(); + assert_eq!(proof.len(), RING_VRF_PROOF_LEN); + } + + #[test] + fn open_fails_when_member_absent_from_ring() { + let entropy = [0x11u8; 32]; + let other = member_key([0x22u8; 32]); + let err = ring_vrf_proof( + RingDomainSize::Domain11, + entropy, + &[other], + b"SSS_SLOT:test-context-padding..", + &[0x42; 32], + ) + .unwrap_err(); + assert!(err.contains("open failed"), "unexpected error: {err}"); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs new file mode 100644 index 00000000..606e0506 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs @@ -0,0 +1,295 @@ +//! Proactive renewal of statement-store allowances across period boundaries. +//! +//! Allowances are claimed per UTC-day period and die at the boundary, so a +//! long-lived host must re-register every account it promised to keep allowed +//! (RFC-0010 assigns renewal to the Account Holder). This module is the +//! chain-pure pass: given already-resolved targets, register each for the +//! requested period. Scheduling and target persistence live in +//! `signing_host::allowance_renewal`. + +use std::time::Duration; + +use futures::lock::Mutex; +use tracing::{debug, info, warn}; + +use super::extension::{ChainState, Metadata}; +use super::ring::RingParams; +use super::rpc::RpcClient; +use super::slot::STATEMENT_STORE_PERIOD_SECONDS; +use super::{RegistrationOutcome, register_statement_account}; + +/// Cap between renewal ticks, mirroring the on-chain grace period after a +/// period boundary. +const MAX_TICK_INTERVAL: Duration = Duration::from_secs(3_600); +/// Margin after a period boundary before the boundary tick fires, so the +/// chain has rotated to the new period by the time we scan slots. +const PERIOD_BOUNDARY_MARGIN: Duration = Duration::from_secs(120); + +/// One resolved renewal target: account id plus a label for reports. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedRenewalTarget { + /// Human-readable name used in logs and reports. + pub label: String, + /// Account to keep allowed. + pub account_id: [u8; 32], +} + +/// Outcome of renewing one target. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TargetRenewalStatus { + /// The extrinsic reached a block; the target holds `seq` this period. + Registered { + /// Claimed slot sequence. + seq: u32, + /// Block hash the extrinsic landed in. + block_hash: String, + }, + /// The target already held a slot this period; nothing submitted. + AlreadyAllocated { + /// Existing slot sequence. + seq: u32, + }, + /// Registration failed; the target is retried on the next tick. + Failed { + /// Failure detail. + reason: String, + }, + /// Not attempted: the host ran out of slots earlier in the pass. + SkippedExhausted, +} + +/// Summary of one renewal pass. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatementRenewalReport { + /// Period the pass registered for. + pub period: u32, + /// Per-target `(label, status)` in ledger order. + pub outcomes: Vec<(String, TargetRenewalStatus)>, + /// Whether the pass hit slot exhaustion for this period. + pub slots_exhausted: bool, +} + +/// Chain context shared by every registration in one renewal pass. +pub struct RenewalChainContext<'a> { + /// People-chain RPC connection. + pub rpc: &'a RpcClient, + /// Decoded runtime metadata. + pub metadata: &'a Metadata, + /// Signed-extension chain state. + pub chain_state: &'a ChainState, + /// Ring the host's membership proof is built against. + pub ring: &'a RingParams, +} + +/// Register every target for `period`, continuing past per-target failures +/// and stopping early once the host's slots for the period are exhausted +/// (remaining targets are reported as skipped). +/// +/// `registration_lock` is held per target, not for the whole pass, so an +/// on-demand allocation sharing the lock waits at most one registration. +pub async fn renew_targets( + context: &RenewalChainContext<'_>, + entropy: [u8; 32], + period: u32, + targets: &[ResolvedRenewalTarget], + registration_lock: &Mutex<()>, +) -> StatementRenewalReport { + let mut results = Vec::with_capacity(targets.len()); + for target in targets { + let result = { + let _guard = registration_lock.lock().await; + register_statement_account( + context.rpc, + context.metadata, + context.chain_state, + entropy, + &target.account_id, + period, + context.ring, + ) + .await + }; + log_target_result(period, &target.label, &result); + let exhausted = matches!(&result, Err(reason) if is_slot_exhaustion(reason)); + results.push(result); + if exhausted { + break; + } + } + fold_outcomes(period, targets, results) +} + +/// Delay until the next renewal tick: hourly, but always shortly after each +/// period boundary so expired allowances are refreshed within the grace window. +pub fn next_tick_delay(now_seconds: u64) -> Duration { + let next_boundary = + (now_seconds / STATEMENT_STORE_PERIOD_SECONDS + 1) * STATEMENT_STORE_PERIOD_SECONDS; + let until_after_boundary = + Duration::from_secs(next_boundary - now_seconds) + PERIOD_BOUNDARY_MARGIN; + until_after_boundary.min(MAX_TICK_INTERVAL) +} + +fn log_target_result(period: u32, label: &str, result: &Result) { + match result { + Ok(RegistrationOutcome::Registered { + block_hash, seq, .. + }) => info!(period, label, seq, %block_hash, "renewed statement-store allowance"), + Ok(RegistrationOutcome::AlreadyAllocated { seq }) => { + debug!( + period, + label, seq, "statement-store allowance already fresh" + ); + } + Err(reason) => warn!(period, label, %reason, "statement-store renewal failed"), + } +} + +/// Pair each target with its registration result; targets past the end of +/// `results` were never attempted (the pass stopped on slot exhaustion). +fn fold_outcomes( + period: u32, + targets: &[ResolvedRenewalTarget], + results: Vec>, +) -> StatementRenewalReport { + let mut slots_exhausted = false; + let mut results = results.into_iter(); + let outcomes = targets + .iter() + .map(|target| { + let status = match results.next() { + Some(Ok(RegistrationOutcome::Registered { + block_hash, seq, .. + })) => TargetRenewalStatus::Registered { seq, block_hash }, + Some(Ok(RegistrationOutcome::AlreadyAllocated { seq })) => { + TargetRenewalStatus::AlreadyAllocated { seq } + } + Some(Err(reason)) => { + if is_slot_exhaustion(&reason) { + slots_exhausted = true; + } + TargetRenewalStatus::Failed { reason } + } + None => TargetRenewalStatus::SkippedExhausted, + }; + (target.label.clone(), status) + }) + .collect(); + StatementRenewalReport { + period, + outcomes, + slots_exhausted, + } +} + +fn is_slot_exhaustion(reason: &str) -> bool { + reason.contains("no free StatementStore slot") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn target(label: &str) -> ResolvedRenewalTarget { + ResolvedRenewalTarget { + label: label.to_string(), + account_id: [0u8; 32], + } + } + + #[test] + fn tick_delay_caps_at_one_hour_mid_day() { + let mid_day = 86_400 * 20_000 + 43_200; + assert_eq!(next_tick_delay(mid_day), Duration::from_secs(3_600)); + } + + #[test] + fn tick_delay_lands_after_the_period_boundary() { + let just_before_boundary = 86_400 * 20_001 - 10; + assert_eq!( + next_tick_delay(just_before_boundary), + Duration::from_secs(10 + 120) + ); + } + + #[test] + fn tick_delay_at_boundary_reverts_to_hourly() { + assert_eq!(next_tick_delay(86_400 * 20_001), Duration::from_secs(3_600)); + } + + #[test] + fn mid_list_failure_does_not_stop_the_pass() { + let targets = [target("a"), target("b"), target("c")]; + let report = fold_outcomes( + 7, + &targets, + vec![ + Ok(RegistrationOutcome::AlreadyAllocated { seq: 1 }), + Err("rpc timeout".to_string()), + Ok(RegistrationOutcome::Registered { + block_hash: "0xabc".to_string(), + seq: 2, + ring_index: 0, + }), + ], + ); + assert_eq!( + report, + StatementRenewalReport { + period: 7, + outcomes: vec![ + ( + "a".to_string(), + TargetRenewalStatus::AlreadyAllocated { seq: 1 } + ), + ( + "b".to_string(), + TargetRenewalStatus::Failed { + reason: "rpc timeout".to_string() + } + ), + ( + "c".to_string(), + TargetRenewalStatus::Registered { + seq: 2, + block_hash: "0xabc".to_string() + } + ), + ], + slots_exhausted: false, + } + ); + } + + #[test] + fn exhaustion_skips_remaining_targets() { + let targets = [target("a"), target("b"), target("c")]; + let report = fold_outcomes( + 7, + &targets, + vec![ + Ok(RegistrationOutcome::AlreadyAllocated { seq: 0 }), + Err("no free StatementStore slot in period 7 (max 8)".to_string()), + ], + ); + assert_eq!( + report, + StatementRenewalReport { + period: 7, + outcomes: vec![ + ( + "a".to_string(), + TargetRenewalStatus::AlreadyAllocated { seq: 0 } + ), + ( + "b".to_string(), + TargetRenewalStatus::Failed { + reason: "no free StatementStore slot in period 7 (max 8)".to_string() + } + ), + ("c".to_string(), TargetRenewalStatus::SkippedExhausted), + ], + slots_exhausted: true, + } + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs new file mode 100644 index 00000000..e70be951 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs @@ -0,0 +1,201 @@ +//! LitePeople ring parameters from the People chain (`Members` pallet). +//! +//! Reads the on-chain ring so the membership proof is built against the same +//! members the runtime verifies against: the baked-in `included` prefix of the +//! current ring. Mirrors signing-bot `ring-proof.ts`. + +use parity_scale_codec::{Compact, Decode}; +use sp_crypto_hashing::{blake2_128, twox_64, twox_128}; + +use super::dynamic::{read_field_u32, read_field_variant_name}; +use super::extension::Metadata; +use super::rpc::RpcClient; + +/// LitePeople collection identifier: ASCII, exactly 32 bytes. +const LITE_PEOPLE_IDENTIFIER: &[u8; 32] = b"pop:polkadot.network/people-lite"; +/// Ring member public key length. +const MEMBER_LEN: usize = 32; + +/// On-chain LitePeople ring parameters for building a verifying proof. +pub struct RingParams { + /// Ring members, sliced to the baked-in `included` prefix. + pub members: Vec<[u8; 32]>, + /// Ring size exponent (9 / 10 / 14). + pub exponent: u8, + /// Ring index these members belong to. + pub ring_index: u32, +} + +/// `Members.CurrentRingIndex[id]` storage key. +fn current_ring_index_key() -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"CurrentRingIndex").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + ] + .concat() +} + +/// `Members.Collections[id]` storage key. +fn collections_key() -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"Collections").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + ] + .concat() +} + +/// `Members.RingKeysStatus[(id, ring_index)]` storage key. +fn ring_keys_status_key(ring_index: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeysStatus").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + ] + .concat() +} + +/// `Members.Root[(id, ring_index)]` storage key. +fn ring_root_key(ring_index: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"Root").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + ] + .concat() +} + +/// `Members.RingKeys[(id, ring_index, page)]` storage key. +fn ring_keys_key(ring_index: u32, page: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeys").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + &twox_64_concat(&page.to_le_bytes()), + ] + .concat() +} + +/// `Blake2_128Concat(x)` = `blake2_128(x) ‖ x`. +pub(super) fn blake2_128_concat(x: &[u8]) -> Vec { + [blake2_128(x).as_slice(), x].concat() +} + +/// `Twox64Concat(x)` = `twox_64(x) ‖ x`. +fn twox_64_concat(x: &[u8]) -> Vec { + [twox_64(x).as_slice(), x].concat() +} + +/// Map a `RingExponent` variant name to its exponent. +fn ring_exponent_from_name(name: &str) -> Result { + match name { + "R2e9" => Ok(9), + "R2e10" => Ok(10), + "R2e14" => Ok(14), + other => Err(format!("unsupported RingExponent variant `{other}`")), + } +} + +/// Read the current LitePeople ring index (absent => 0). +pub async fn read_current_ring_index(rpc: &RpcClient) -> Result { + match rpc + .get_storage(¤t_ring_index_key()) + .await + .map_err(|e| e.to_string())? + { + Some(bytes) => u32::decode(&mut &bytes[..]).map_err(|e| format!("ring index: {e}")), + None => Ok(0), + } +} + +/// Read the LitePeople ring size exponent from `Collections[LitePeople].ring_size`. +/// This is a chain constant, so read it once and reuse across ring indices. +pub async fn read_ring_exponent(rpc: &RpcClient, metadata: &Metadata) -> Result { + let collection = rpc + .get_storage(&collections_key()) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Members.Collections[LitePeople] missing".to_string())?; + let value_type = metadata + .storage_value_type("Members", "Collections") + .ok_or_else(|| "Members.Collections type not in metadata".to_string())?; + let variant = + read_field_variant_name(metadata.registry(), value_type, "ring_size", &collection)?; + ring_exponent_from_name(&variant) +} + +/// Read the members of `ring_index`, sliced to the baked-in `included` prefix. +pub async fn read_ring_members_at( + rpc: &RpcClient, + ring_index: u32, +) -> Result, String> { + // 1. Page through RingKeys collecting raw 32-byte members. + let mut members = Vec::new(); + for page in 0.. { + let Some(bytes) = rpc + .get_storage(&ring_keys_key(ring_index, page)) + .await + .map_err(|e| e.to_string())? + else { + break; + }; + let mut cursor = &bytes[..]; + let Compact(len) = + Compact::::decode(&mut cursor).map_err(|e| format!("ring keys len: {e}"))?; + if len == 0 { + break; + } + for i in 0..len as usize { + let start = i * MEMBER_LEN; + let member: [u8; 32] = cursor + .get(start..start + MEMBER_LEN) + .ok_or_else(|| "ring keys page truncated".to_string())? + .try_into() + .expect("slice is 32 bytes"); + members.push(member); + } + } + + // 2. Slice to the baked-in `included` prefix (absent status => all included). + if let Some(status) = rpc + .get_storage(&ring_keys_status_key(ring_index)) + .await + .map_err(|e| e.to_string())? + { + // RingStatus = { total: u32 LE, included: u32 LE, .. }. + let included_bytes = status + .get(4..) + .ok_or_else(|| "ring status truncated before included field".to_string())?; + let included = + u32::decode(&mut &included_bytes[..]).map_err(|e| format!("ring status: {e}"))?; + members.truncate(included as usize); + } + + Ok(members) +} + +/// Read `Members.Root[LitePeople][ring_index].revision` (absent => 0). +pub async fn read_ring_revision( + rpc: &RpcClient, + metadata: &Metadata, + ring_index: u32, +) -> Result { + match rpc + .get_storage(&ring_root_key(ring_index)) + .await + .map_err(|e| e.to_string())? + { + Some(bytes) => { + let value_type = metadata + .storage_value_type("Members", "Root") + .ok_or_else(|| "Members.Root type not in metadata".to_string())?; + read_field_u32(metadata.registry(), value_type, "revision", &bytes) + .map_err(|e| format!("ring revision: {e}")) + } + None => Ok(0), + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs new file mode 100644 index 00000000..34e04642 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs @@ -0,0 +1,153 @@ +//! Host-backed JSON-RPC helpers for statement-store allowance registration. + +use core::time::Duration; + +use futures::{FutureExt, pin_mut}; +use serde_json::Value; +use subxt_rpcs::RpcClient as HostRpcClient; +use subxt_rpcs::client::{RpcClient as NativeRpcClient, RpcParams, rpc_params}; + +/// Timeout for an allowance registration extrinsic to reach a block. +const SUBMIT_TIMEOUT: Duration = Duration::from_secs(120); + +/// Thin adapter matching the allowance allocator's minimal RPC surface. +#[derive(Clone)] +pub struct RpcClient { + inner: HostRpcClient, +} + +impl RpcClient { + /// Open a native JSON-RPC connection to `url`. + pub async fn connect(url: &str) -> Result { + let inner = NativeRpcClient::from_insecure_url(url) + .await + .map_err(|err| format!("connect {url}: {err}"))?; + Ok(Self { inner }) + } + + /// Wrap a platform-backed Subxt RPC client. + pub fn new(inner: HostRpcClient) -> Self { + Self { inner } + } + + /// Call `method` with JSON-array `params`, returning the result value. + pub async fn call(&self, method: &str, params: Value) -> Result { + self.inner + .request(method, value_to_params(params)?) + .await + .map_err(rpc_error_message) + } + + /// `state_getStorage(key)` -> raw value bytes, or `None` if absent. + pub async fn get_storage(&self, key: &[u8]) -> Result>, String> { + let key_hex = format!("0x{}", hex::encode(key)); + match self + .inner + .request::("state_getStorage", rpc_params![key_hex]) + .await + .map_err(rpc_error_message)? + { + Value::String(hex_value) => Ok(Some(decode_hex(&hex_value)?)), + _ => Ok(None), + } + } + + /// Submit an extrinsic and wait for `inBlock` or `finalized`; returns the block hash. + pub async fn submit_and_watch(&self, extrinsic: &[u8]) -> Result { + let extrinsic_hex = format!("0x{}", hex::encode(extrinsic)); + let mut subscription = self + .inner + .subscribe::( + "author_submitAndWatchExtrinsic", + rpc_params![extrinsic_hex], + "author_unwatchExtrinsic", + ) + .await + .map_err(rpc_error_message)?; + let timeout = futures_timer::Delay::new(SUBMIT_TIMEOUT).fuse(); + pin_mut!(timeout); + + loop { + let next = subscription.next().fuse(); + pin_mut!(next); + let status = futures::select! { + item = next => item.ok_or_else(|| { + "author_submitAndWatchExtrinsic subscription ended".to_string() + })?.map_err(rpc_error_message)?, + () = timeout => return Err( + "timed out waiting for author_submitAndWatchExtrinsic inclusion".to_string() + ), + }; + tracing::debug!(?status, "allowance extrinsic status"); + match extrinsic_status(&status) { + ExtrinsicStatus::Included(hash) => return Ok(hash), + ExtrinsicStatus::Rejected(reason) => return Err(format!("extrinsic {reason}")), + ExtrinsicStatus::Pending => {} + } + } + } +} + +enum ExtrinsicStatus { + Included(String), + Rejected(String), + Pending, +} + +fn extrinsic_status(status: &Value) -> ExtrinsicStatus { + for key in ["finalized", "inBlock"] { + if let Some(hash) = status.get(key).and_then(Value::as_str) { + return ExtrinsicStatus::Included(hash.to_string()); + } + } + for key in ["invalid", "dropped", "usurped", "finalityTimeout"] { + if status.get(key).is_some() { + return ExtrinsicStatus::Rejected(key.to_string()); + } + } + ExtrinsicStatus::Pending +} + +fn value_to_params(value: Value) -> Result { + let Value::Array(values) = value else { + return Err("RPC params must be a JSON array".to_string()); + }; + let mut params = RpcParams::new(); + for value in values { + params.push(value).map_err(rpc_error_message)?; + } + Ok(params) +} + +fn decode_hex(value: &str) -> Result, String> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)) + .map_err(|err| format!("decode hex storage value: {err}")) +} + +fn rpc_error_message(error: subxt_rpcs::Error) -> String { + match error { + subxt_rpcs::Error::User(error) => error.message, + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{ExtrinsicStatus, extrinsic_status}; + + #[test] + fn in_block_status_completes_submission() { + let status = extrinsic_status(&json!({"inBlock": "0x1234"})); + + assert!(matches!(status, ExtrinsicStatus::Included(hash) if hash == "0x1234")); + } + + #[test] + fn finalized_status_completes_submission() { + let status = extrinsic_status(&json!({"finalized": "0xabcd"})); + + assert!(matches!(status, ExtrinsicStatus::Included(hash) if hash == "0xabcd")); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs new file mode 100644 index 00000000..a9a99b81 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -0,0 +1,247 @@ +//! StatementStore allowance slot selection. +//! +//! An allowance is claimed at `(period, seq)`. The slot is bound to a 32-byte +//! `SSS_SLOT` context; occupancy is read from +//! `Resources.StatementStoreAllowances[period][alias]`, where the alias is +//! derived from OUR bandersnatch entropy in that slot context. Mirrors +//! signing-bot `allowance.ts` / `allowance-slots.ts`. + +use sp_crypto_hashing::twox_128; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use super::extension::Metadata; +use super::ring::blake2_128_concat; +use super::rpc::RpcClient; + +/// StatementStore allowance period: one UTC day, in seconds. +pub const STATEMENT_STORE_PERIOD_SECONDS: u64 = 86_400; +/// Bulletin long-term-storage claim context prefix. +const LONG_TERM_STORAGE_CONTEXT_PREFIX: &[u8] = b"pop:polkadot.net/rsc-lts"; + +/// The current allowance period for `now_seconds`. +pub fn current_period(now_seconds: u64) -> u32 { + (now_seconds / STATEMENT_STORE_PERIOD_SECONDS) as u32 +} + +/// The current long-term-storage period for `now_seconds`. +pub fn current_long_term_storage_period( + now_seconds: u64, + period_duration: u32, +) -> Result { + if period_duration == 0 { + return Err("Resources.LongTermStoragePeriodDuration is zero".to_string()); + } + Ok((now_seconds / u64::from(period_duration)) as u32) +} + +/// Derive the 32-byte StatementStore slot context: +/// `"SSS_SLOT:" ‖ u32be(period) ‖ u32be(seq) ‖ 0x20 fill`. +pub fn derive_slot_context(period: u32, seq: u32) -> [u8; 32] { + let mut ctx = [0x20u8; 32]; + ctx[..9].copy_from_slice(b"SSS_SLOT:"); + ctx[9..13].copy_from_slice(&period.to_be_bytes()); + ctx[13..17].copy_from_slice(&seq.to_be_bytes()); + ctx +} + +/// Derive the 32-byte Bulletin long-term-storage slot context: +/// `"pop:polkadot.net/rsc-lts" ‖ u32be(period) ‖ counter ‖ zero fill`. +pub fn derive_long_term_storage_context(period: u32, counter: u8) -> [u8; 32] { + let mut ctx = [0u8; 32]; + ctx[..LONG_TERM_STORAGE_CONTEXT_PREFIX.len()].copy_from_slice(LONG_TERM_STORAGE_CONTEXT_PREFIX); + let offset = LONG_TERM_STORAGE_CONTEXT_PREFIX.len(); + ctx[offset..offset + 4].copy_from_slice(&period.to_be_bytes()); + ctx[offset + 4] = counter; + ctx +} + +/// The slot alias for our `entropy` at `(period, seq)`. +pub fn slot_alias(entropy: [u8; 32], period: u32, seq: u32) -> Result<[u8; 32], String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let context = derive_slot_context(period, seq); + BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("alias_in_context failed: {err:?}")) +} + +/// The long-term-storage slot alias for our `entropy` at `(period, counter)`. +pub fn long_term_storage_alias( + entropy: [u8; 32], + period: u32, + counter: u8, +) -> Result<[u8; 32], String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let context = derive_long_term_storage_context(period, counter); + BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("alias_in_context failed: {err:?}")) +} + +/// `Resources.StatementStoreAllowances[period][alias]` storage key. +/// key1 = Identity(u32be period); key2 = Blake2_128Concat(alias). +fn statement_store_allowance_key(period: u32, alias: &[u8; 32]) -> Vec { + [ + twox_128(b"Resources").as_slice(), + twox_128(b"StatementStoreAllowances").as_slice(), + &period.to_be_bytes(), + &blake2_128_concat(alias), + ] + .concat() +} + +/// `Resources.SpentLongTermStorageAliases[period][alias]` storage key. +/// key1 = Identity(u32be period); key2 = Blake2_128Concat(alias). +fn spent_long_term_storage_alias_key(period: u32, alias: &[u8; 32]) -> Vec { + [ + twox_128(b"Resources").as_slice(), + twox_128(b"SpentLongTermStorageAliases").as_slice(), + &period.to_be_bytes(), + &blake2_128_concat(alias), + ] + .concat() +} + +/// Max StatementStore slots per period from `Resources.LiteStmtStoreSlotsPerPeriod`. +fn max_slots(metadata: &Metadata) -> Result { + let bytes = metadata + .constant("Resources", "LiteStmtStoreSlotsPerPeriod") + .ok_or_else(|| "Resources.LiteStmtStoreSlotsPerPeriod constant missing".to_string())?; + let mut buf = [0u8; 4]; + let n = bytes.len().min(4); + buf[..n].copy_from_slice(&bytes[..n]); + Ok(u32::from_le_bytes(buf)) +} + +/// Max long-term-storage claims per period from +/// `Resources.LongTermStorageClaimsPerPeriod`. +fn long_term_storage_claims_per_period(metadata: &Metadata) -> Result { + metadata + .constant("Resources", "LongTermStorageClaimsPerPeriod") + .and_then(|bytes| bytes.first().copied()) + .ok_or_else(|| "Resources.LongTermStorageClaimsPerPeriod constant missing".to_string()) +} + +/// Long-term-storage period duration in seconds from +/// `Resources.LongTermStoragePeriodDuration`. +pub fn long_term_storage_period_duration(metadata: &Metadata) -> Result { + let bytes = metadata + .constant("Resources", "LongTermStoragePeriodDuration") + .ok_or_else(|| "Resources.LongTermStoragePeriodDuration constant missing".to_string())?; + let mut buf = [0u8; 4]; + let n = bytes.len().min(4); + buf[..n].copy_from_slice(&bytes[..n]); + Ok(u32::from_le_bytes(buf)) +} + +/// The account id occupying a slot entry, if the storage value is present. +/// Entry = `account_id(32) ‖ seq(u32 LE) ‖ since(u64 LE)`. +fn entry_account_id(bytes: &[u8]) -> Option<[u8; 32]> { + bytes.get(..32).map(|s| s.try_into().expect("32 bytes")) +} + +/// Outcome of scanning for a slot to register `target` in. +pub enum SlotSelection { + /// A free `seq` we should claim. + Free(u32), + /// `target` already holds `seq` this period; no registration needed. + AlreadyAllocated(u32), +} + +/// Scan slots `0..max` for `period`, returning the first non-excluded free seq +/// (or detecting that `target` already holds one). `entropy` is our +/// bandersnatch entropy. +pub async fn scan_slot_excluding( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + period: u32, + target: &[u8; 32], + excluded: &[u32], +) -> Result { + let max = max_slots(metadata)?; + let mut first_free: Option = None; + for seq in 0..max { + let alias = slot_alias(entropy, period, seq)?; + let key = statement_store_allowance_key(period, &alias); + match rpc.get_storage(&key).await.map_err(|e| e.to_string())? { + None => { + if first_free.is_none() && !excluded.contains(&seq) { + first_free = Some(seq); + } + } + Some(bytes) => { + if entry_account_id(&bytes) == Some(*target) { + return Ok(SlotSelection::AlreadyAllocated(seq)); + } + } + } + } + first_free + .map(SlotSelection::Free) + .ok_or_else(|| format!("no free StatementStore slot in period {period} (max {max})")) +} + +/// Scan long-term-storage aliases `0..max` for `period`, returning the first +/// free counter not listed in `excluded`. `entropy` is our bandersnatch entropy. +pub async fn scan_long_term_storage_counter_excluding( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + period: u32, + excluded: &[u8], +) -> Result { + let max = long_term_storage_claims_per_period(metadata)?; + for counter in 0..max { + if excluded.contains(&counter) { + continue; + } + let alias = long_term_storage_alias(entropy, period, counter)?; + let key = spent_long_term_storage_alias_key(period, &alias); + if rpc + .get_storage(&key) + .await + .map_err(|e| e.to_string())? + .is_none() + { + return Ok(counter); + } + } + Err(format!( + "no free long-term-storage slot in period {period} (max {max})" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slot_context_layout() { + let ctx = derive_slot_context(7, 3); + assert_eq!(&ctx[..9], b"SSS_SLOT:"); + assert_eq!(&ctx[9..13], &7u32.to_be_bytes()); + assert_eq!(&ctx[13..17], &3u32.to_be_bytes()); + assert!(ctx[17..].iter().all(|&b| b == 0x20)); + } + + #[test] + fn long_term_storage_context_layout() { + let ctx = derive_long_term_storage_context(7, 3); + assert_eq!(&ctx[..24], b"pop:polkadot.net/rsc-lts"); + assert_eq!(&ctx[24..28], &7u32.to_be_bytes()); + assert_eq!(ctx[28], 3); + assert!(ctx[29..].iter().all(|&b| b == 0)); + } + + #[test] + fn period_is_utc_day_index() { + assert_eq!(current_period(86_400 * 20_000 + 5), 20_000); + } + + #[test] + fn long_term_storage_period_uses_chain_duration() { + assert_eq!( + current_long_term_storage_period(1_209_600 * 20 + 5, 1_209_600).unwrap(), + 20, + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index a8adf8a9..1f597ff1 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -577,7 +577,9 @@ mod tests { #[test] fn statement_store_submit_posts_signed_statement_and_waits_for_ack() { let platform = Arc::new(StubPlatform { - rpc_responses: vec![r#"{"jsonrpc":"2.0","id":"truapi:1","result":"0xok"}"#.to_string()], + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":{"status":"new"}}"#.to_string(), + ], ..Default::default() }); let host = ProductRuntimeHost::new( diff --git a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs index d54ac0c9..3abbcf48 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs @@ -8,9 +8,12 @@ use std::sync::Arc; +use core::time::Duration; use serde_json::{Value, json}; + use subxt_rpcs::RpcClient; use subxt_rpcs::client::{RpcSubscription, rpc_params}; +use tracing::warn; use truapi_platform::{JsonRpcConnection, Platform}; use crate::host_logic::statement_store::{ @@ -20,6 +23,9 @@ use crate::host_logic::statement_store::{ use crate::host_rpc_client::HostRpcClient; use crate::subscription::Spawner; +const SSO_NO_ALLOWANCE_RETRY_ATTEMPTS: usize = 5; +const SSO_NO_ALLOWANCE_RETRY_DELAY: Duration = Duration::from_secs(1); + /// People-chain statement-store RPC client factory. #[derive(Clone)] pub(crate) struct StatementStoreRpc { @@ -62,6 +68,18 @@ impl StatementStoreRpc { submit(&rpc_client, statement).await } + /// Submit an SSO statement, tolerating the short propagation window after + /// an allowance registration is included but not yet visible to the + /// Statement Store RPC backend. + pub(super) async fn submit_sso( + &self, + statement: Vec, + label: &'static str, + ) -> Result<(), String> { + let rpc_client = self.client(label).await?; + submit_sso(&rpc_client, statement, label).await + } + /// Submit a SCALE-encoded statement without waiting for the JSON-RPC ack. pub(super) async fn submit_fire_and_forget( &self, @@ -109,16 +127,54 @@ pub(super) async fn subscribe_match_all( subscribe(rpc_client, TopicFilterKind::MatchAll, topics).await } -/// Submit a SCALE-encoded statement and wait for the JSON-RPC ack. +/// Submit a SCALE-encoded statement and confirm the store accepted it. +/// +/// `statement_submit` returns an RPC error only for internal failures; a +/// rejected or invalid statement (e.g. `NoAllowance`, `BadProof`) comes back as +/// `Ok(SubmitResult)`. Treat only `new`/`known` as success, so allowance/proof +/// rejections surface instead of being silently dropped. pub(super) async fn submit(rpc_client: &RpcClient, statement: Vec) -> Result<(), String> { - rpc_client + let result = rpc_client .request::( SUBMIT_STATEMENT_METHOD, rpc_params![format!("0x{}", hex::encode(&statement))], ) .await - .map(|_| ()) - .map_err(rpc_error_message) + .map_err(rpc_error_message)?; + match result.get("status").and_then(Value::as_str) { + Some("new") | Some("known") => Ok(()), + _ => Err(format!("statement_submit not accepted: {result}")), + } +} + +pub(super) async fn submit_sso( + rpc_client: &RpcClient, + statement: Vec, + label: &'static str, +) -> Result<(), String> { + for attempt in 1..=SSO_NO_ALLOWANCE_RETRY_ATTEMPTS { + match submit(rpc_client, statement.clone()).await { + Ok(()) => return Ok(()), + Err(reason) + if is_transient_no_allowance(&reason) + && attempt < SSO_NO_ALLOWANCE_RETRY_ATTEMPTS => + { + warn!( + label, + attempt, + max_attempts = SSO_NO_ALLOWANCE_RETRY_ATTEMPTS, + "SSO allowance not visible yet; retrying statement submission" + ); + futures_timer::Delay::new(SSO_NO_ALLOWANCE_RETRY_DELAY).await; + } + Err(reason) => return Err(reason), + } + } + unreachable!("the bounded SSO submit loop always returns") +} + +fn is_transient_no_allowance(reason: &str) -> bool { + reason.contains("noAllowance") } /// Statement-store topic filter encoded as JSON-RPC params. @@ -138,3 +194,18 @@ pub(super) fn rpc_error_message(error: subxt_rpcs::Error) -> String { other => other.to_string(), } } + +#[cfg(test)] +mod tests { + use super::is_transient_no_allowance; + + #[test] + fn identifies_no_allowance_submit_rejections_for_retry() { + assert!(is_transient_no_allowance( + r#"statement_submit not accepted: {"reason":"noAllowance","status":"rejected"}"# + )); + assert!(!is_transient_no_allowance( + r#"statement_submit not accepted: {"reason":"badProof","status":"rejected"}"# + )); + } +} diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 3f322a28..d6677218 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -424,10 +424,12 @@ pub(crate) fn subscribe_ack_frame(request_id: &str, subscription_id: &str) -> St } fn statement_submit_ack_frame(request_id: &str) -> String { + // Mirror the real `statement_submit` result shape (`SubmitResult`); `submit` + // treats only `new`/`known` as accepted. serde_json::json!({ "jsonrpc": "2.0", "id": request_id, - "result": "0xok", + "result": { "status": "new" }, }) .to_string() } diff --git a/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata.scale b/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata.scale new file mode 100644 index 00000000..28a7ac3f Binary files /dev/null and b/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata.scale differ diff --git a/rust/crates/truapi/src/api/chain.rs b/rust/crates/truapi/src/api/chain.rs index 158dd12f..1f47c85b 100644 --- a/rust/crates/truapi/src/api/chain.rs +++ b/rust/crates/truapi/src/api/chain.rs @@ -362,12 +362,22 @@ pub trait Chain: Send + Sync { /// ```ts /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; /// - /// const result = await truapi.chain.stopTransaction({ + /// // Start a broadcast, then stop it using the returned operation id. + /// const broadcast = await truapi.chain.broadcastTransaction({ /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, - /// operationId: "op-id", + /// transaction: "0x", /// }); - /// assert(result.isOk(), "stopTransaction failed:", result); - /// console.log("transaction broadcast stopped"); + /// assert(broadcast.isOk(), "broadcastTransaction failed:", broadcast); + /// if (broadcast.value.operationId) { + /// const result = await truapi.chain.stopTransaction({ + /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// operationId: broadcast.value.operationId, + /// }); + /// assert(result.isOk(), "stopTransaction failed:", result); + /// console.log("transaction broadcast stopped"); + /// } else { + /// console.log("broadcast completed before a stop operation was created"); + /// } /// ``` #[wire(request_id = 102)] async fn stop_transaction( diff --git a/rust/crates/truapi/src/api/preimage.rs b/rust/crates/truapi/src/api/preimage.rs index 13075f6b..ad8769c9 100644 --- a/rust/crates/truapi/src/api/preimage.rs +++ b/rust/crates/truapi/src/api/preimage.rs @@ -15,13 +15,14 @@ pub trait Preimage: Send + Sync { /// import { firstValueFrom, from } from "rxjs"; /// /// // Submit a preimage first so the lookup resolves to a value. - /// const submitted = await truapi.preimage.submit("0xdeadbeef"); + /// const value = `0x${crypto.getRandomValues(new Uint8Array(4)).toHex()}` as `0x${string}`; + /// const submitted = await truapi.preimage.submit(value); /// assert(submitted.isOk(), "submit failed:", submitted); /// /// const item = await firstValueFrom( /// from(truapi.preimage.lookupSubscribe({ request: { key: submitted.value } })), /// ); - /// assert(item.value === "0xdeadbeef", "preimage lookup returned the wrong value:", item); + /// assert(item.value === value, "preimage lookup returned the wrong value:", item); /// console.log("preimage lookup received:", item); /// ``` #[wire(start_id = 64)] @@ -36,7 +37,8 @@ pub trait Preimage: Send + Sync { /// Submit a preimage. Returns the preimage key (hash) on success. /// /// ```ts - /// const result = await truapi.preimage.submit("0xdeadbeef"); + /// const value = `0x${crypto.getRandomValues(new Uint8Array(4)).toHex()}` as `0x${string}`; + /// const result = await truapi.preimage.submit(value); /// assert(result.isOk(), "submit failed:", result); /// console.log("preimage submitted:", result.value); /// ``` diff --git a/rust/crates/truapi/src/api/resource_allocation.rs b/rust/crates/truapi/src/api/resource_allocation.rs index 813ecef4..a2476ef6 100644 --- a/rust/crates/truapi/src/api/resource_allocation.rs +++ b/rust/crates/truapi/src/api/resource_allocation.rs @@ -23,16 +23,16 @@ pub trait ResourceAllocation: Send + Sync { /// assert(result.isOk(), "request failed:", result); /// assert(result.value.outcomes.length === 4, "missing allocation outcomes:", result.value); /// 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, /// ); /// assert( - /// result.value.outcomes[3] === "NotAvailable", - /// "AutoSigning support changed; update this example:", + /// result.value.outcomes.slice(2).every((outcome) => outcome !== "Rejected"), + /// "an optional allocation was rejected:", /// result.value, /// ); - /// console.log("statement-store, bulletin, and smart-contract allowances allocated"); + /// console.log("resource allocation outcomes:", result.value.outcomes); /// ``` #[wire(request_id = 130)] async fn request( diff --git a/rust/crates/truapi/src/api/signing.rs b/rust/crates/truapi/src/api/signing.rs index f688f5ed..f7314e3d 100644 --- a/rust/crates/truapi/src/api/signing.rs +++ b/rust/crates/truapi/src/api/signing.rs @@ -88,11 +88,15 @@ pub trait Signing: Send + Sync { /// Sign raw bytes with a non-product account. /// /// ```ts - /// const identityResult = await ss58AddressForDotNsUsername(); - /// assert(identityResult.isOk(), "DotNS identity lookup failed:", identityResult); + /// const accountsResult = await truapi.account.getLegacyAccounts(); + /// assert(accountsResult.isOk(), "getLegacyAccounts failed:", accountsResult); + /// const identityAccount = + /// accountsResult.value.accounts.find((account) => account.name === "Identity") ?? + /// accountsResult.value.accounts[0]; + /// assert(identityAccount, "no legacy accounts available"); /// /// const result = await truapi.signing.signRawWithLegacyAccount({ - /// signer: identityResult.value, + /// signer: identityAccount.publicKey, /// payload: { /// tag: "Bytes", /// value: { bytes: "0x48656c6c6f" }, diff --git a/rust/crates/truapi/src/api/statement_store.rs b/rust/crates/truapi/src/api/statement_store.rs index 1110155c..0f6cf5e1 100644 --- a/rust/crates/truapi/src/api/statement_store.rs +++ b/rust/crates/truapi/src/api/statement_store.rs @@ -37,18 +37,23 @@ pub trait StatementStore: Send + Sync { /// assert(submitted.isOk(), "failed to submit statement:", submitted); /// console.log("statement submitted"); /// - /// const page = await firstValueFrom( - /// from( - /// truapi.statementStore.subscribe({ - /// request: { tag: "MatchAll", value: [topic] }, - /// }), - /// ), - /// ); - /// assert( - /// page.statements.some((item) => item.topics.includes(topic)), - /// "subscription did not return the submitted statement:", - /// page, - /// ); + /// const waitForStatement = async () => { + /// for (let attempt = 0; attempt < 15; attempt++) { + /// const page = await firstValueFrom( + /// from( + /// truapi.statementStore.subscribe({ + /// request: { tag: "MatchAll", value: [topic] }, + /// }), + /// ), + /// ); + /// if (page.statements.some((item) => item.topics.includes(topic))) { + /// return page; + /// } + /// await new Promise((resolve) => setTimeout(resolve, 1_000)); + /// } + /// throw new Error("submitted statement was not visible after 15 seconds"); + /// }; + /// const page = await waitForStatement(); /// console.log("subscribe received", page); /// ``` #[wire(start_id = 56)]