diff --git a/.gitignore b/.gitignore index 9dc237bb..d19caf60 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ local.properties .env .env.* !.env.example +/.e2e-dotli/ # Editor / OS .vscode/* diff --git a/CLAUDE.md b/CLAUDE.md index b18cab4f..bb360b08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -182,33 +182,32 @@ still verify that the playground renders, the TrUAPI debug panel receives host/product events, generated examples can call non-confirmation methods, and logout/relogin does not restore a stale session. -The dotli Playwright e2e suite under `hosts/dotli/apps/host/tests/e2e/` -pairs through the signer-bot service. It requires `SIGNER_BOT_SVC_TOKEN`; -`SIGNER_BOT_BASE_URL` and `SIGNER_BOT_NETWORK` default to dotli CI's -`https://signing-bot-dev.novasama-tech.org/` and `paseo-next-v2`. Without the -token, do not treat the full suite as locally runnable. Use -`E2E_DOTLI_SMOKE=1 make e2e-dotli` for the no-phone QR smoke path. -If those signer-bot variables are not available in a worktree, check for a -repo-root `.env` and load or copy the values from there before falling back to -smoke mode. Prefer the current worktree's `.env` when it exists. +The root `make e2e-dotli` target builds the local `truapi-host` binary and +drives the dotli/playground diagnosis through a non-interactive signing-host +CLI process. The CLI answers the QR-derived pairing deeplink, auto-approves +remote requests, stays alive for the SSO session, and is launched again to +verify same-account reconnect after host sign-out. It uses +an explicitly exported `HOST_CLI_SIGNER_MNEMONIC` when present. Without one, +it auto-manages a reusable isolated identity under `.e2e-dotli/`. Set +`E2E_DOTLI_SIGNING_HOST_BASE_PATH` to preserve and reuse signing-host state +while debugging. Use `E2E_DOTLI_SMOKE=1 make e2e-dotli` for the QR-only smoke +path. For a fully automated local playground diagnosis run, use: ```bash -SIGNER_BOT_SVC_TOKEN=... \ make e2e-dotli ``` `make e2e-dotli` starts dotli preview and the playground, signs out any -restored host session, signs in through signer-bot by extracting the QR payload, -runs the playground Diagnosis screen, auto-accepts host-side Allow/Sign modals, -and writes `hosts/dotli/test-results/e2e-dotli/diagnosis-report.md`. - -Root CI runs the same target when it can read the private dotli submodule. It -needs `DOTLI_CHECKOUT_TOKEN` for submodule checkout; without that token, the -job warns and skips dotli e2e rather than failing unrelated PR checks. With -dotli access but without `SIGNER_BOT_SVC_TOKEN`, CI runs the no-phone smoke -path only. +restored host session, signs in through the local signing-host CLI by extracting +the QR payload, runs the playground Diagnosis screen, auto-accepts host-side +Allow/Sign modals, and writes +`playground/test-results/e2e-dotli/diagnosis-report.md`. + +Any CI job running the same target needs `DOTLI_CHECKOUT_TOKEN` for private +submodule checkout; without dotli access it should skip this integration gate +rather than fail unrelated checks. A useful no-phone smoke assertion is: diff --git a/Cargo.lock b/Cargo.lock index 4ba5ec08..656f335c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -49,6 +49,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -111,6 +120,32 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + [[package]] name = "ark-bls12-381" version = "0.5.0" @@ -261,7 +296,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" dependencies = [ "num-traits", - "rand", + "rand 0.8.6", "rayon", ] @@ -275,7 +310,7 @@ dependencies = [ "ark-serialize", "ark-std", "digest 0.10.7", - "rand_core", + "rand_core 0.6.4", "sha3", ] @@ -505,6 +540,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ "bitcoin_hashes", + "rand 0.8.6", + "rand_core 0.6.4", + "serde", + "unicode-normalization", ] [[package]] @@ -518,9 +557,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitvec" @@ -623,6 +662,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "byte-slice-cast" version = "1.2.3" @@ -647,6 +692,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.62" @@ -669,6 +723,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.9.1" @@ -677,7 +737,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -730,6 +801,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -746,6 +826,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -847,6 +941,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -881,6 +984,34 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more 2.1.1", + "document-features", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -894,7 +1025,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -906,7 +1037,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -936,7 +1067,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto", @@ -962,8 +1093,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -980,17 +1121,47 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "der" version = "0.7.10" @@ -1002,6 +1173,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive-where" version = "1.6.1" @@ -1076,6 +1253,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1087,6 +1274,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1113,7 +1309,7 @@ dependencies = [ "ed25519", "hashbrown 0.16.1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sha2 0.10.9", "subtle", "zeroize", @@ -1150,7 +1346,7 @@ dependencies = [ "generic-array", "group", "hkdf", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -1192,6 +1388,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -1213,6 +1415,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastbloom" version = "0.17.0" @@ -1237,7 +1445,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1270,7 +1478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" dependencies = [ "byteorder", - "rand", + "rand 0.8.6", "rustc-hex", "static_assertions", ] @@ -1332,6 +1540,16 @@ dependencies = [ "serde", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "funty" version = "2.0.0" @@ -1460,6 +1678,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1480,10 +1708,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1492,8 +1723,8 @@ version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea1015b5a70616b688dc230cfe50c8af89d972cb132d5a622814d29773b10b9" dependencies = [ - "rand", - "rand_core", + "rand 0.8.6", + "rand_core 0.6.4", ] [[package]] @@ -1559,7 +1790,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1601,6 +1832,11 @@ name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -1678,12 +1914,94 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.9", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1858,6 +2176,25 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2067,13 +2404,24 @@ dependencies = [ "url", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + [[package]] name = "keccak" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -2138,7 +2486,7 @@ dependencies = [ "libsecp256k1-core", "libsecp256k1-gen-ecmult", "libsecp256k1-gen-genmult", - "rand", + "rand 0.8.6", "serde", "sha2 0.9.9", "typenum", @@ -2173,6 +2521,15 @@ dependencies = [ "libsecp256k1-core", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2185,6 +2542,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2210,8 +2573,32 @@ dependencies = [ ] [[package]] -name = "memchr" -version = "2.8.0" +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.0", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" @@ -2223,7 +2610,7 @@ checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" dependencies = [ "byteorder", "keccak", - "rand_core", + "rand_core 0.6.4", "zeroize", ] @@ -2244,6 +2631,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2260,7 +2648,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" dependencies = [ - "rand", + "rand 0.8.6", ] [[package]] @@ -2297,6 +2685,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -2327,6 +2721,88 @@ dependencies = [ "libm", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2367,6 +2843,30 @@ dependencies = [ "primeorder", ] +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -2521,7 +3021,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2533,7 +3033,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2553,6 +3053,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2634,6 +3140,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2663,7 +3225,18 @@ checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -2673,7 +3246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2685,6 +3258,87 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags", + "compact_str", + "hashbrown 0.17.0", + "itertools 0.14.0", + "kasuari", + "lru 0.18.1", + "palette", + "serde", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags", + "hashbrown 0.17.0", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rayon" version = "1.12.0" @@ -2714,6 +3368,61 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.9", +] + [[package]] name = "ring" version = "0.17.14" @@ -2795,6 +3504,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -2896,7 +3606,7 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cb245f7fdb489e7ba43a616cbd34427fe3ba6fe0edc1d0d250085e6c84f3ec" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn", @@ -2923,7 +3633,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17020f2d59baabf2ddcdc20a4e567f8210baf089b8a8d4785f5fd5e716f92038" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro-crate", "proc-macro2", "quote", @@ -3034,7 +3744,7 @@ dependencies = [ "curve25519-dalek", "getrandom_or_panic", "merlin", - "rand_core", + "rand_core 0.6.4", "serde_bytes", "sha2 0.10.9", "subtle", @@ -3157,6 +3867,18 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -3177,7 +3899,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -3189,7 +3911,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -3201,7 +3923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -3230,6 +3952,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -3304,7 +4047,7 @@ dependencies = [ "bip39", "blake2-rfc", "bs58", - "chacha20", + "chacha20 0.9.1", "crossbeam-queue", "derive_more 2.1.1", "ed25519-zebra", @@ -3328,7 +4071,7 @@ dependencies = [ "pbkdf2", "pin-project", "poly1305", - "rand", + "rand 0.8.6", "rand_chacha", "ruzstd", "schnorrkel", @@ -3360,7 +4103,7 @@ dependencies = [ "bip39", "blake2-rfc", "bs58", - "chacha20", + "chacha20 0.9.1", "crossbeam-queue", "derive_more 2.1.1", "ed25519-zebra", @@ -3384,7 +4127,7 @@ dependencies = [ "pbkdf2", "pin-project", "poly1305", - "rand", + "rand 0.8.6", "rand_chacha", "ruzstd", "schnorrkel", @@ -3424,10 +4167,10 @@ dependencies = [ "hex", "itertools 0.14.0", "log", - "lru", + "lru 0.16.4", "parking_lot", "pin-project", - "rand", + "rand 0.8.6", "rand_chacha", "serde", "serde_json", @@ -3459,7 +4202,7 @@ dependencies = [ "futures", "httparse", "log", - "rand", + "rand 0.8.6", "sha1", ] @@ -3511,6 +4254,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "substrate-bip39" version = "0.6.1" @@ -3620,7 +4384,7 @@ version = "0.50.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5132bb78596d4957bf3039b9c54f5f88cefefd64ab61f0e71592526e14ef7f13" dependencies = [ - "darling", + "darling 0.20.11", "parity-scale-codec", "proc-macro-error2", "quote", @@ -3712,6 +4476,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -3791,6 +4564,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -3831,9 +4625,11 @@ version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -3869,6 +4665,23 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -3915,6 +4728,51 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -3944,6 +4802,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", ] [[package]] @@ -3952,9 +4822,16 @@ version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", + "smallvec", "thread_local", + "tracing", "tracing-core", + "tracing-log", ] [[package]] @@ -3983,6 +4860,41 @@ dependencies = [ "truapi", ] +[[package]] +name = "truapi-host-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "async-trait", + "bip39", + "clap", + "crossterm", + "fs2", + "futures", + "futures-util", + "hex", + "parity-scale-codec", + "ratatui", + "reqwest", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "shlex", + "subxt-rpcs", + "tempfile", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "truapi", + "truapi-platform", + "truapi-server", + "unicode-width", +] + [[package]] name = "truapi-macros" version = "0.1.0" @@ -4052,6 +4964,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "twox-hash" version = "1.6.3" @@ -4060,7 +4998,7 @@ checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ "cfg-if", "digest 0.10.7", - "rand", + "rand 0.8.6", "static_assertions", ] @@ -4109,6 +5047,23 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -4149,6 +5104,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4161,6 +5122,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "verifiable" version = "0.5.0" @@ -4210,7 +5177,7 @@ dependencies = [ "ark-serialize", "ark-std", "getrandom_or_panic", - "rand_core", + "rand_core 0.6.4", "rayon", "w3f-pcs", ] @@ -4242,6 +5209,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4482,6 +5458,40 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -4491,6 +5501,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -4772,6 +5788,23 @@ dependencies = [ "tap", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -4779,7 +5812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "serde", "zeroize", ] diff --git a/Makefile b/Makefile index 35f26bc0..b6c65785 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # Run `make help` for the list of targets. .DEFAULT_GOAL := help -.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test dotli-link dev dev-bootstrap dev-link-check e2e-dotli matrix explorer +.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test dotli-link dev dev-bootstrap dev-link-check e2e-dotli headless install matrix explorer TRUAPI_PKG := js/packages/truapi PLAYGROUND := playground @@ -21,13 +21,7 @@ DOTLI_TRUAPI_LINK := $(DOTLI_NODE_MODULES)/@parity/truapi DOTLI_HOST_WASM_LINK := $(DOTLI_NODE_MODULES)/@parity/truapi-host DOTLI_UI_TRUAPI_SHADOW := $(DOTLI_UI)/node_modules/@parity/truapi DOTLI_UI_HOST_WASM_SHADOW := $(DOTLI_UI)/node_modules/@parity/truapi-host -SIGNER_BOT_BASE_URL ?= https://signing-bot-dev.novasama-tech.org/ -SIGNER_BOT_NETWORK ?= paseo-next-v2 -SIGNER_BOT_BASE_URL_ORIGIN := $(origin SIGNER_BOT_BASE_URL) -SIGNER_BOT_NETWORK_ORIGIN := $(origin SIGNER_BOT_NETWORK) VITE_NETWORKS ?= paseo-next-v2,previewnet -export SIGNER_BOT_BASE_URL -export SIGNER_BOT_NETWORK export VITE_NETWORKS # Local product URLs (`http://localhost:5173/localhost:3000`) are intentionally @@ -55,6 +49,13 @@ build: ## Build the Rust workspace and the TypeScript client. cd $(TRUAPI_PKG) && npm run build cd $(HOST_WASM_PKG) && npm run build +headless: ## Build the truapi-host CLI and generated TypeScript client. + cargo build -p truapi-host-cli + cd $(TRUAPI_PKG) && npm run build + +install: headless ## Install the truapi-host CLI into Cargo's bin dir; use as `make headless install`. + cargo install --path rust/crates/truapi-host-cli --bin truapi-host --locked --force + codegen: ## Regenerate generated TS/Rust artifacts from the Rust crates. ./scripts/codegen.sh cd $(PLAYGROUND) && rm -rf node_modules/@parity && yarn install @@ -138,20 +139,9 @@ dev: dev-bootstrap ## Start dotli host (:5173) + playground (:3000) together; op ( until curl -fsS http://localhost:3000/ >/dev/null 2>&1; do sleep 1; done; curl -fsS http://localhost:3000/diagnostics >/dev/null 2>&1 || true ) & \ wait -e2e-dotli: ## Fully automated dotli + playground diagnosis e2e. Requires SIGNER_BOT_SVC_TOKEN unless E2E_DOTLI_SMOKE=1. - @SIGNER_BOT_SVC_TOKEN_ENV="$$SIGNER_BOT_SVC_TOKEN"; \ - SIGNER_BOT_BASE_URL_ENV="$$SIGNER_BOT_BASE_URL"; \ - SIGNER_BOT_NETWORK_ENV="$$SIGNER_BOT_NETWORK"; \ - SIGNER_BOT_BASE_URL_ORIGIN="$(SIGNER_BOT_BASE_URL_ORIGIN)"; \ - SIGNER_BOT_NETWORK_ORIGIN="$(SIGNER_BOT_NETWORK_ORIGIN)"; \ - set -a; \ - if [ -f .env ]; then . ./.env; fi; \ - set +a; \ - if [ -n "$$SIGNER_BOT_SVC_TOKEN_ENV" ]; then SIGNER_BOT_SVC_TOKEN="$$SIGNER_BOT_SVC_TOKEN_ENV"; export SIGNER_BOT_SVC_TOKEN; fi; \ - if [ "$$SIGNER_BOT_BASE_URL_ORIGIN" != "file" ] && [ -n "$$SIGNER_BOT_BASE_URL_ENV" ]; then SIGNER_BOT_BASE_URL="$$SIGNER_BOT_BASE_URL_ENV"; export SIGNER_BOT_BASE_URL; fi; \ - if [ "$$SIGNER_BOT_NETWORK_ORIGIN" != "file" ] && [ -n "$$SIGNER_BOT_NETWORK_ENV" ]; then SIGNER_BOT_NETWORK="$$SIGNER_BOT_NETWORK_ENV"; export SIGNER_BOT_NETWORK; fi; \ - if [ "$$E2E_DOTLI_SMOKE" != "1" ]; then test -n "$$SIGNER_BOT_SVC_TOKEN" || (echo "Missing SIGNER_BOT_SVC_TOKEN. e2e-dotli requires signer-bot; without it a human phone scan is required."; exit 1); fi; \ - $(MAKE) dev-bootstrap; \ +e2e-dotli: ## Fully automated dotli + playground diagnosis e2e using the local signing-host CLI. + @$(MAKE) dev-bootstrap + cargo build -p truapi-host-cli cd $(PLAYGROUND) && bun tests/e2e/dotli-diagnosis.ts matrix: ## Regenerate the host compatibility matrix from explorer/diagnosis-reports. diff --git a/README.md b/README.md index 3f2e0f00..e5b62cd4 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,14 @@ make wasm # rebuild truapi-server WASM artifacts under js/packages/truapi-ho CI regenerates the shared bindings before building and testing both npm packages, so generated client and host callback changes are checked together. +The native `truapi-host` utility can run pairing and signing hosts against the +real SSO transport for local end-to-end work. Both roles provide a +transcript-based terminal UI with commands such as `/product` and `/script`; +the signing host also provides `/pair` and a non-interactive `exec` form for +automation. See the +[`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) for setup, +controls, and examples. + To run the playground locally: ```bash @@ -125,6 +133,12 @@ make playground # rebuild the playground against the refreshed snapshot This repopulates the ignored generated TS under `js/packages/truapi/`, including the playground metadata. `make dev` and `make e2e-dotli` run this generation step unconditionally before starting their local stacks. +The full `make e2e-dotli` diagnosis builds and launches the local +`truapi-host signing-host` CLI to answer dotli's pairing QR and auto-approve +remote signing requests. It does not require the external signer-bot service. +When `HOST_CLI_SIGNER_MNEMONIC` is absent, the CLI manages a reusable isolated +test identity under `.e2e-dotli/`. Set `E2E_DOTLI_SIGNING_HOST_BASE_PATH` to +use a different state directory while debugging. ## Protocol versions diff --git a/deny.toml b/deny.toml index 7acc3441..3c039293 100644 --- a/deny.toml +++ b/deny.toml @@ -8,6 +8,7 @@ allow = [ "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", + "BSL-1.0", "CC0-1.0", "CDLA-Permissive-2.0", "ISC", diff --git a/docs/issue-drafts/bulletin-preimage-in-core.md b/docs/issue-drafts/bulletin-preimage-in-core.md new file mode 100644 index 00000000..c2f25664 --- /dev/null +++ b/docs/issue-drafts/bulletin-preimage-in-core.md @@ -0,0 +1,336 @@ +# Move Bulletin preimage submission into the Rust core + +## TL;DR + +Preimage submission to the Bulletin chain now happens **entirely inside the Rust +core** (`truapi-server`). The core builds, signs, and submits the +`TransactionStorage.store` extrinsic itself, routing the chain traffic through +the host's existing `chain.connect` byte-pipe — the same transport products +already use for any chain access. + +Previously the core handed the host a _signing capability_ and the host built +and submitted the transaction with PAPI. That seam is gone. As a result: + +- The wallet-delegated **allowance secret never leaves the core** (it no longer + crosses the host / FFI boundary as a signer handle). +- The host's only remaining preimage job is **content lookup** (Helia / IPFS). +- All hosts (web, and later mobile over uniffi) get identical submission + behaviour for free, because the logic lives in the shared core rather than in + each host's JS/native code. + +This issue explains the moving parts and, most importantly, **the messages +exchanged** between the product, the Rust core, the host, and the network. + +--- + +## Why + +The base branch already routes every chain call through one host callback, +`chain.connect(genesisHash)`, which returns a JSON-RPC byte pipe. Preimage +submission was the one chain write that did _not_ use it: instead the core +derived an allowance signer and passed a `sign(payload)` closure out to the +host, which reconstructed a PAPI transaction and submitted it. That meant: + +- Every host reimplemented extrinsic construction/submission (and dotli pulled + `@novasamatech` + PAPI signer deps back in to do it). +- The allowance secret's signing capability crossed the worker / FFI boundary. +- Submission behaviour (nonce, mortality, dispatch-error handling, retries) + diverged per host. + +Folding it into the core removes all three. + +--- + +## Before vs after: who owns what + +The topology is unchanged — the core still reaches the network only through the +host — what changes is **ownership**. Each box below lists what that side owns; +every arrow crossing the vertical boundary is labelled with exactly what +crosses it. + +Before, the *secret bytes* were core-owned, but the *capability to use them* — +and everything that decides what gets signed — was host-owned: + +``` +BEFORE + core-owned (WASM worker) host-owned (JS main thread) + +------------------------------+ || +------------------------------+ + | Rust core | || | dotli (PAPI) | + | | || | | + | owns: | || | owns: | + | - allowance secret (64 B) | || | - signer handle | + | - sign() execution | || | { publicKey, sign() } | + | | || | - tx builder (PAPI) | + | | || | - decides WHAT is signed | + | | || | - submission + watch | + +------------------------------+ || +------------------------------+ + + crosses ||: core --[ signer handle ]--> host (a live signing capability) + core <--[ sign(payload) ]-- host (one request per tx) + core --[ signature ]--> host --> Bulletin +``` + +After, everything signing-related sits on the core side; the only thing that +crosses the boundary during submission is opaque JSON-RPC text: + +``` +AFTER + core-owned (WASM worker) host-owned (JS main thread) + +------------------------------+ || +------------------------------+ + | Rust core | || | dotli | + | | || | | + | owns: | || | owns: | + | - allowance secret (64 B) | || | - permission/confirm modals | + | - signer (crate-private, | || | - chain.connect byte pipe | + | never exported) | || | (forwards verbatim) | + | - tx builder (subxt) | || | - lookupPreimage backend | + | - decides WHAT is signed | || | (Helia / IPFS) | + | - submission + watch | || | | + +------------------------------+ || +------------------------------+ + + crosses ||: core --[ JSON-RPC request ]--> host --> Bulletin + core <--[ JSON-RPC response ]-- host + opaque strings only: no key, no capability, no sign requests +``` + +The security consequence: before, host code held a live capability and chose +the payloads it signed; a compromised host could sign arbitrary Bulletin +transactions with the user's allowance. After, the host can at worst drop or +corrupt bytes — a lie in either direction produces a transaction the real +chain rejects, never a signature over attacker-chosen content. + +--- + +## Actors and responsibilities + +| Actor | Runs where | Owns | +| -------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------ | +| **Product** | sandboxed iframe / app | speaks TrUAPI; never sees chains or keys | +| **Rust core** (`truapi-server`) | WASM in a Web Worker (web); native lib (mobile) | wire protocol, tx build + sign, submit + watch, allowance key | +| **Host** (dotli / mobile shell) | main thread / OS shell | user modals, `chain.connect` transport, `lookupPreimage` content backend | +| **Wallet** (paired signing host) | phone | allocates the Bulletin allowance key over SSO | +| **Network** | — | Bulletin chain (writes) + People chain (allowance allocation / SSO) | + +--- + +## Message flow: preimage submission (happy path) + +This is the core of the change. The product calls one wire method, +`preimage.submit(value)`; everything below it is new in-core work. + +The columns are ownership lanes. A boundary glyph shows what crosses it on that +row: `>` left-to-right, `<` right-to-left, `:` nothing. Everything in the CORE +lane stays in the WASM worker unless a glyph carries it out. + +``` + PRODUCT | CORE (holds keys, builds+signs) : HOST (modals+pipe) : NET + ---------+------------------------------------+--------------------+--- + submit > receive : : + | -- gate (unchanged) -- : : + | remotePermission > prompt user : + | confirmUserAction > prompt user : + | -- allowance key (cached) -- : : + | requestResourceAllocation > relay SSO > wlt + | slotAccountKey < passthrough < key + | [[ 64-B secret stays in core ]] : : + | -- build + submit via pipe -- : : + | chain.connect(bulletin) > open pipe : + | chainHead_v1_follow > forward > N + | Metadata_metadata_at_version > forward > N + | AccountNonceApi_account_nonce > forward > N + | [[ build + SIGN store(value) ]] : : + | validate_transaction (dry-run) > forward > N + | transaction_v1_broadcast > forward > N + | chainHead_v1_body (watch) > forward > N + | chainHead_v1_storage(Events) > forward > N + key< return blake2_256(value) : : + | [[ prime lookup cache ]] : : + + boundary glyphs: > message crosses left->right < crosses right->left + : boundary, no message on this row + CORE:HOST is the WASM-worker edge; [[..]] work never leaves the core; NET is + the Bulletin chain, reached only through the host's pipe (wlt = paired wallet) +``` + +Key points on this flow: + +- The **gate** (permission + confirmation) is unchanged from the base protocol; + it still guards the write. +- The **dry-run** is load-bearing: `transaction_v1_broadcast` is + spec-guaranteed to be _silent_ on invalid transactions, so without a + `validate_transaction` call the core could never distinguish "rejected" from + "still pending" and would only ever see a timeout. +- Inclusion is confirmed by **matching the extrinsic hash in a block body**, + then the **dispatch outcome** is read from `System.Events` — matching the + fidelity the old PAPI `signSubmitAndWatch` path had. + +--- + +## Message flow: allowance key (the one secret) + +The allowance key is a wallet-delegated, scoped signing capability. The wallet +owns *minting* it; the core owns *holding and using* it; the host only relays +the SSO messages and never sees the resulting secret used. It is allocated once +per (session, product), cached in the core, and used only to sign the `store` +call. + +``` + CORE (holds + uses the key) : HOST (relays SSO) : WALLET (mints) + ---------------------------------------+--------------------+--- + -- first submit for this product -- : : + requestResourceAllocation > relay (Ignore) > allocate + slotAccountKey < passthrough < { key } + [[ store in memory + persisted ]] : : + [[ used only to sign store() calls ]] : : + : : + -- later: allowance is exhausted -- : : + [[ evict the cached key ]] : : + requestResourceAllocation > relay (Increase) > mint fresh, + slotAccountKey < passthrough < larger one + [[ retry the submit exactly once ]] : : + + > crosses core -> host -> wallet < the reply crossing back + the key lands in CORE and is used there; the host never sees it signing +``` + +The retry is bounded: the core refreshes the allowance and retries **at most +once**, and only when the failure is a typed "allowance rejected" signal (from +the dry-run or from a `TransactionStorage` authorization error in the events) — +never on transport errors or nonce races. + +--- + +## Message flow: lookup (unchanged owner, new integrity check) + +The *content backend* is host-owned (the host chooses Helia P2P or an IPFS +gateway); the *cache* and the *integrity gate* are core-owned. So even though +the bytes originate on the host side, the core decides whether they reach the +product. + +``` + CORE (cache + integrity gate) : HOST (content backend) + -----------------------------------------+----------------------- + lookup_subscribe(key) from product : + : + cache hit (primed by in-core submit): : + emit value once, keep open : + : + cache miss: : + lookupPreimage(key) > poll Helia / IPFS + value | miss < host-owned bytes + [[ gate: blake2_256(value)==key? ]] : + match: forward mismatch: -> miss : + + > request crosses into the host < host's bytes cross back + the backend is host-owned; the cache + integrity gate are core-owned, + so the core decides what reaches the product (a mismatch is warn-logged) +``` + +A host that returns bytes not matching the requested key can no longer feed a +product forged content: the core's integrity gate downgrades the mismatch to a +miss before it reaches the product. + +--- + +## Failure handling and the inclusion watch + +The submit flow returns a typed error that maps to a stable reason string on the +wire (`PreimageSubmitError::Unknown { reason }` — the product wire is +unchanged). The decision the core makes at each failure: + +``` + dry-run: validate_transaction + | + +-- Valid ................................. broadcast + watch (see below) + +-- Invalid::Payment / Custom / BadSigner . allowance rejected -> refresh + retry once + +-- Invalid::Future / Stale ............... nonce race (no retry) + +-- other Invalid ......................... invalid (no retry) + + broadcast + watch: included? + | + +-- ExtrinsicSuccess ...................... return the preimage key + +-- TransactionStorage auth error ......... allowance rejected -> refresh + retry once + +-- other dispatch error .................. dispatch failed + +-- 120s elapsed / follow stopped ......... broadcast, inclusion unverified +``` + +"Allowance rejected" is the only outcome that refreshes the key +(`onExisting=Increase`) and retries — at most once, in both the dry-run and the +dispatch branches. + +The inclusion watch is a single event loop over one ephemeral chainHead follow. +It fetches a block body only after the allowance account's nonce is seen to +advance (so it does not download every block), matches by extrinsic hash, and +releases pins as it goes. Because the follow is dropped when the 120 s budget or +a cancellation fires, its pins and connection lease are released automatically. + +--- + +## Security invariants + +Because the chain provider is an untrusted byte pipe (especially in RPC-gateway +mode), the core enforces four invariants so a hostile or buggy provider cannot +subvert the allowance key: + +1. **Genesis is config-pinned.** The signed payload's genesis hash always comes + from host _configuration_, never from provider-echoed chain data — so a + provider cannot redirect the allowance-key signature to a different chain. + Every other provider-supplied input (spec/tx version, nonce, mortality + anchor, metadata) is then fail-closed: lying about it yields a signature the + real chain rejects, never one valid elsewhere. +2. **Call is pinned.** Name resolution of `TransactionStorage.store` is + hard-asserted against audited pallet/call indices, and the metadata-built + call bytes are checked byte-for-byte against a canonically built copy — so + provider metadata cannot make the key sign a different call. +3. **Signer is confined.** The only allowance-key -> signer conversion is + crate-private to the bulletin module; the signer is transient, never stored, + never logged, and the key type is zeroized on drop. +4. **Lookup is content-addressed.** Host-returned bytes are verified against the + requested key before reaching the product. + +A crafted `NewBlock` parent link (self-referential or cyclic) is also guarded in +the inclusion watch so the provider cannot spin the worker. + +--- + +## What changed (high-level map) + +- `truapi-server/src/host_logic/extrinsic.rs` — offline subxt assembler: + config-pinned `SubstrateConfig`, sr25519 signer, metadata / validity / events + / header decoders. +- `truapi-server/src/host_logic/bulletin.rs` — `store{data}` build + sign, call + pinning, byte-level call-data encoder. +- `truapi-server/src/runtime/bulletin_rpc.rs` — the submit flow (follow -> + metadata -> nonce -> dry-run -> broadcast -> watch -> events) and its typed + errors. +- `truapi-server/src/runtime.rs` — `Preimage::submit` ordering + refresh/retry; + `lookup_subscribe` cache + integrity check. +- `truapi-platform` — `PreimageHost` keeps only `lookupPreimage`; + `BulletinAllowanceKey` is zeroized on drop; configs gain an optional Bulletin + genesis hash. +- Host TS (`@parity/truapi-host`) + dotli — the signer bridge is removed; dotli + keeps only lookup, threads the Bulletin genesis into its runtime config, and + routes the Bulletin genesis through `chain.connect` on both backends. + +The **product-facing wire is unchanged** — `preimage.submit` / +`preimage.lookup_subscribe` keep their wire ids and shapes, so products need no +changes. + +--- + +## Verification + +- Rust: build / fmt / clippy (`-D warnings`) / tests all green; wasm32 compiles; + `cargo deny` licenses ok. New unit tests cover extrinsic construction against + real Bulletin metadata, genesis-binding, call pinning, the extension-encoding + rules, and the inclusion-watch cycle guard. +- Host TS: `tsc` + `bun` tests green. WASM bundle grows ~0.5 MB (subxt offline). +- **Manual / e2e gates** (network + signing-host CLI, not in CI): the live-chain + dry-run encoding proof and `make e2e-dotli` preimage flow. + +## Follow-ups + +- Signing-host (mobile local-key) role: derive the Bulletin allowance key from + root entropy so submission works there too — everything downstream of the + allowance-key fetch is already shared. +- Reuse `host_logic/extrinsic.rs` for the local `create_transaction` path. diff --git a/docs/local-e2e-testing.md b/docs/local-e2e-testing.md index 7ffde321..1547c917 100644 --- a/docs/local-e2e-testing.md +++ b/docs/local-e2e-testing.md @@ -27,18 +27,20 @@ for failure modes — both the skills and CI cite it. `make e2e-dotli` is the end-to-end dotli + playground diagnosis harness. It starts the local dotli preview and playground, opens Chromium, signs out any -restored host session, signs in through the signer-bot SSO service, runs the -playground Diagnosis screen, and writes -`playground/test-results/e2e-dotli/diagnosis-report.md`. Full automation -requires `SIGNER_BOT_SVC_TOKEN`; `SIGNER_BOT_BASE_URL` and -`SIGNER_BOT_NETWORK` default to dotli CI's signer-bot service and -`paseo-next-v2`. Without the token, use +restored host session, builds and launches the local `truapi-host signing-host` +CLI to answer the pairing deeplink and auto-approve SSO requests, runs the +playground Diagnosis screen, verifies sign-out and same-account reconnect, and +writes `playground/test-results/e2e-dotli/diagnosis-report.md`. The harness +uses `HOST_CLI_SIGNER_MNEMONIC` when supplied. Otherwise the CLI provisions +and reuses an isolated test identity under `.e2e-dotli/`. Set +`E2E_DOTLI_SIGNING_HOST_BASE_PATH` to use a different state directory while +debugging. Set `E2E_DOTLI_NETWORK` to override the default `paseo-next-v2` +network. Use `E2E_DOTLI_SMOKE=1 make e2e-dotli` to verify the local stack, browser launch, login click, TrUAPI debug logs, and QR/deeplink extraction without a phone. In root CI, the job also needs `DOTLI_CHECKOUT_TOKEN` to read the private dotli submodule. Without dotli access it reports a warning and skips the e2e -job; with dotli access but without `SIGNER_BOT_SVC_TOKEN`, it runs the smoke -path only. +job. The order matters: each layer assumes the layer below it builds clean. Skip a step only if you are certain the change cannot affect that layer. diff --git a/explorer/diagnosis-reports/pairing-host-cli.md b/explorer/diagnosis-reports/pairing-host-cli.md new file mode 100644 index 00000000..c32ca3e5 --- /dev/null +++ b/explorer/diagnosis-reports/pairing-host-cli.md @@ -0,0 +1,68 @@ +## Truapi Pairing Host CLI Diagnosis + +| Method | Status | Details | +| --- | --- | --- | +| `Account/connection_status_subscribe` | ✅ | | +| `Account/get_account` | ✅ | | +| `Account/get_account_alias` | ✅ | | +| `Account/create_account_proof` | ✅ | | +| `Account/get_legacy_accounts` | ✅ | | +| `Account/get_user_id` | ✅ | | +| `Account/request_login` | ✅ | | +| `Chain/follow_head_subscribe` | ✅ | | +| `Chain/get_head_header` | ✅ | | +| `Chain/get_head_body` | ✅ | | +| `Chain/get_head_storage` | ✅ | | +| `Chain/call_head` | ✅ | | +| `Chain/unpin_head` | ✅ | | +| `Chain/continue_head` | ✅ | | +| `Chain/stop_head_operation` | ✅ | | +| `Chain/get_spec_genesis_hash` | ✅ | | +| `Chain/get_spec_chain_name` | ✅ | | +| `Chain/get_spec_properties` | ✅ | | +| `Chain/broadcast_transaction` | ✅ | | +| `Chain/stop_transaction` | ✅ | | +| `Chat/create_room` | ❌ | createRoom failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/register_bot` | ❌ | registerBot failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/list_subscribe` | ❌ | no elements in sequence | +| `Chat/post_message` | ❌ | postMessage failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/action_subscribe` | ❌ | no elements in sequence | +| `Chat/custom_message_render_subscribe` | ❌ | no elements in sequence | +| `Coin Payment/create_purse` | ❌ | createPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/query_purse` | ❌ | queryPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/rebalance_purse` | ❌ | Subscription interrupted | +| `Coin Payment/delete_purse` | ❌ | Subscription interrupted | +| `Coin Payment/create_receivable` | ❌ | createReceivable failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/create_cheque` | ❌ | createCheque failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/deposit` | ❌ | Subscription interrupted | +| `Coin Payment/refund` | ❌ | Subscription interrupted | +| `Coin Payment/listen_for_payment` | ❌ | Subscription interrupted | +| `Entropy/derive` | ✅ | | +| `Local Storage/read` | ✅ | | +| `Local Storage/write` | ✅ | | +| `Local Storage/clear` | ✅ | | +| `Notifications/send_push_notification` | ✅ | | +| `Notifications/cancel_push_notification` | ✅ | | +| `Payment/balance_subscribe` | ❌ | Subscription interrupted | +| `Payment/top_up` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Payment/request` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Payment/status_subscribe` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Permissions/request_device_permission` | ✅ | | +| `Permissions/request_remote_permission` | ✅ | | +| `Preimage/lookup_subscribe` | ✅ | | +| `Preimage/submit` | ✅ | | +| `Resource Allocation/request` | ✅ | | +| `Signing/create_transaction` | ✅ | | +| `Signing/create_transaction_with_legacy_account` | ✅ | | +| `Signing/sign_raw_with_legacy_account` | ✅ | | +| `Signing/sign_payload_with_legacy_account` | ✅ | | +| `Signing/sign_raw` | ✅ | | +| `Signing/sign_payload` | ✅ | | +| `Statement Store/subscribe` | ✅ | | +| `Statement Store/create_proof` | ✅ | | +| `Statement Store/submit` | ✅ | | +| `Statement Store/create_proof_authorized` | ✅ | | +| `System/handshake` | ✅ | | +| `System/feature_supported` | ✅ | | +| `System/navigate_to` | ✅ | | +| `Theme/subscribe` | ✅ | | diff --git a/explorer/diagnosis-reports/signing-host-cli.md b/explorer/diagnosis-reports/signing-host-cli.md new file mode 100644 index 00000000..bf343c44 --- /dev/null +++ b/explorer/diagnosis-reports/signing-host-cli.md @@ -0,0 +1,68 @@ +## Truapi Signing Host CLI Diagnosis + +| Method | Status | Details | +| --- | --- | --- | +| `Account/connection_status_subscribe` | ✅ | | +| `Account/get_account` | ✅ | | +| `Account/get_account_alias` | ✅ | | +| `Account/create_account_proof` | ✅ | | +| `Account/get_legacy_accounts` | ✅ | | +| `Account/get_user_id` | ✅ | | +| `Account/request_login` | ✅ | | +| `Chain/follow_head_subscribe` | ✅ | | +| `Chain/get_head_header` | ✅ | | +| `Chain/get_head_body` | ✅ | | +| `Chain/get_head_storage` | ✅ | | +| `Chain/call_head` | ✅ | | +| `Chain/unpin_head` | ✅ | | +| `Chain/continue_head` | ✅ | | +| `Chain/stop_head_operation` | ✅ | | +| `Chain/get_spec_genesis_hash` | ✅ | | +| `Chain/get_spec_chain_name` | ✅ | | +| `Chain/get_spec_properties` | ✅ | | +| `Chain/broadcast_transaction` | ✅ | | +| `Chain/stop_transaction` | ✅ | | +| `Chat/create_room` | ❌ | createRoom failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/register_bot` | ❌ | registerBot failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/list_subscribe` | ❌ | no elements in sequence | +| `Chat/post_message` | ❌ | postMessage failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Chat/action_subscribe` | ❌ | no elements in sequence | +| `Chat/custom_message_render_subscribe` | ❌ | no elements in sequence | +| `Coin Payment/create_purse` | ❌ | createPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/query_purse` | ❌ | queryPurse failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/rebalance_purse` | ❌ | Subscription interrupted | +| `Coin Payment/delete_purse` | ❌ | Subscription interrupted | +| `Coin Payment/create_receivable` | ❌ | createReceivable failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/create_cheque` | ❌ | createCheque failed: { "error": { "tag": "HostFailure", "value": { "reason": "unavailable" } } } | +| `Coin Payment/deposit` | ❌ | Subscription interrupted | +| `Coin Payment/refund` | ❌ | Subscription interrupted | +| `Coin Payment/listen_for_payment` | ❌ | Subscription interrupted | +| `Entropy/derive` | ✅ | | +| `Local Storage/read` | ✅ | | +| `Local Storage/write` | ✅ | | +| `Local Storage/clear` | ✅ | | +| `Notifications/send_push_notification` | ✅ | | +| `Notifications/cancel_push_notification` | ✅ | | +| `Payment/balance_subscribe` | ❌ | Subscription interrupted | +| `Payment/top_up` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Payment/request` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Payment/status_subscribe` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | +| `Permissions/request_device_permission` | ✅ | | +| `Permissions/request_remote_permission` | ✅ | | +| `Preimage/lookup_subscribe` | ✅ | | +| `Preimage/submit` | ✅ | | +| `Resource Allocation/request` | ✅ | | +| `Signing/create_transaction` | ✅ | | +| `Signing/create_transaction_with_legacy_account` | ✅ | | +| `Signing/sign_raw_with_legacy_account` | ✅ | | +| `Signing/sign_payload_with_legacy_account` | ✅ | | +| `Signing/sign_raw` | ✅ | | +| `Signing/sign_payload` | ✅ | | +| `Statement Store/subscribe` | ✅ | | +| `Statement Store/create_proof` | ✅ | | +| `Statement Store/submit` | ✅ | | +| `Statement Store/create_proof_authorized` | ✅ | | +| `System/handshake` | ✅ | | +| `System/feature_supported` | ✅ | | +| `System/navigate_to` | ✅ | | +| `Theme/subscribe` | ✅ | | diff --git a/hosts/dotli b/hosts/dotli index 7921ce41..df33e179 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit 7921ce413a4a1661b36c3f5032d91287a8f160bf +Subproject commit df33e17966d65dc8bfa783a1daa5fe00472c7129 diff --git a/playground/CLAUDE.md b/playground/CLAUDE.md index db388e28..82d68b6e 100644 --- a/playground/CLAUDE.md +++ b/playground/CLAUDE.md @@ -7,9 +7,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co An interactive explorer for the TrUAPI, the Host API surface exposed to products running inside the Polkadot Desktop Browser webview. The app must be opened from within a Host environment. It talks to the host over iframe `postMessage` frames or the native webview `window.__HOST_API_PORT__` MessagePort. To develop locally, run `yarn dev` and open the app via `https://dot.li/localhost:3000` inside the Desktop Host. -For host-backed diagnosis/e2e runs, signer-bot settings may live in the -repo-root `.env`; if they are not present in the current worktree environment, -load or copy them from that file before treating signer-bot as unavailable. +For host-backed diagnosis/e2e runs, `make e2e-dotli` builds and launches the +repo's signing-host CLI. An explicitly exported `HOST_CLI_SIGNER_MNEMONIC` is +used when present; otherwise the CLI auto-manages a reusable isolated test +identity. Set `E2E_DOTLI_SIGNING_HOST_BASE_PATH` to use different state while +debugging. ## Commands diff --git a/playground/src/components/MethodView.tsx b/playground/src/components/MethodView.tsx index 9951a3e0..5eee6a15 100644 --- a/playground/src/components/MethodView.tsx +++ b/playground/src/components/MethodView.tsx @@ -9,7 +9,7 @@ import { methodTestId, revealInRail, serviceTestId } from "@/src/lib/rail"; import { services } from "@/src/lib/services"; import type { MethodInfo, ServiceInfo } from "@/src/lib/services"; -const CALL_TIMEOUT_MS = 30_000; +const CALL_TIMEOUT_MS = 45_000; const CARGO_DOC_BASE = process.env.NEXT_PUBLIC_CARGO_DOC_BASE ?? diff --git a/playground/src/lib/example-helpers.ts b/playground/src/lib/example-helpers.ts index 4789d917..47e436cc 100644 --- a/playground/src/lib/example-helpers.ts +++ b/playground/src/lib/example-helpers.ts @@ -119,6 +119,72 @@ export function createAccountIdForDotNsUsername( return new Promise>((resolve) => { let operationId: string | null = null; + let settled = false; + let eventQueue = Promise.resolve(); + const fail = (reason: unknown) => { + if (settled) return; + settled = true; + sub.unsubscribe(); + resolve(err(toError(reason))); + }; + const succeed = (account: HexString) => { + if (settled) return; + settled = true; + sub.unsubscribe(); + resolve(ok(account)); + }; + const handleItem = async (item: RemoteChainHeadFollowItem) => { + switch (item.tag) { + case "Initialized": { + const result = await truapi.chain.getHeadStorage({ + genesisHash: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + followSubscriptionId: sub.subscriptionId, + hash: item.value.finalizedBlockHashes[0], + items: [{ key, queryType: "Value" }], + }); + if (result.isErr()) { + fail(result.error); + return; + } + if (result.value.operation.tag !== "Started") { + fail(new Error("getHeadStorage operation limit reached")); + return; + } + operationId = result.value.operation.value.operationId; + return; + } + case "OperationStorageItems": + if (item.value.operationId === operationId) { + const account = findStorageValue(item.value.items, key); + if (!account) { + fail(`No account owns DotNS username "${dotNsUsername}"`); + return; + } + succeed(account); + } + return; + case "OperationStorageDone": + if (item.value.operationId === operationId) { + fail(`No account owns DotNS username "${dotNsUsername}"`); + } + return; + case "OperationError": + if (item.value.operationId === operationId) { + fail(`getHeadStorage failed: ${item.value.error}`); + } + return; + case "OperationInaccessible": + if (item.value.operationId === operationId) { + fail("getHeadStorage operation inaccessible"); + } + return; + case "Stop": + fail( + "chain head subscription stopped before username lookup finished", + ); + return; + } + }; const sub = truapi.chain .followHeadSubscribe({ request: { @@ -127,78 +193,24 @@ export function createAccountIdForDotNsUsername( }, }) .subscribe({ - next: async (item) => { - const fail = (reason: unknown) => { - sub.unsubscribe(); - resolve(err(toError(reason))); - }; - - try { - switch (item.tag) { - case "Initialized": { - const result = await truapi.chain.getHeadStorage({ - genesisHash: PASEO_NEXT_V2_INDIVIDUALITY.genesis, - followSubscriptionId: sub.subscriptionId, - hash: item.value.finalizedBlockHashes[0], - items: [{ key, queryType: "Value" }], - }); - if (result.isErr()) { - fail(result.error); - return; - } - if (result.value.operation.tag !== "Started") { - fail(new Error("getHeadStorage operation limit reached")); - return; - } - operationId = result.value.operation.value.operationId; - return; - } - case "OperationStorageItems": - if (item.value.operationId === operationId) { - const account = findStorageValue(item.value.items, key); - if (!account) { - fail(`No account owns DotNS username "${dotNsUsername}"`); - return; - } - sub.unsubscribe(); - resolve(ok(account)); - } - return; - case "OperationStorageDone": - if (item.value.operationId === operationId) { - fail(`No account owns DotNS username "${dotNsUsername}"`); - } - return; - case "OperationError": - if (item.value.operationId === operationId) { - fail(`getHeadStorage failed: ${item.value.error}`); - } - return; - case "OperationInaccessible": - if (item.value.operationId === operationId) { - fail("getHeadStorage operation inaccessible"); - } - return; - case "Stop": - fail( - "chain head subscription stopped before username lookup finished", - ); - return; - } - } catch (error) { - sub.unsubscribe(); - resolve(err(toError(error))); - } + next: (item) => { + // RxJS does not await async `next` handlers. Serialize follow + // events so storage items cannot overtake the unary response that + // tells us which operation ID to match. + eventQueue = eventQueue + .then(() => handleItem(item)) + .catch((error) => fail(error)); }, - error: (error) => resolve(err(toError(error))), - complete: () => - resolve( - err( + error: (error) => fail(error), + complete: () => { + eventQueue = eventQueue.then(() => + fail( new Error( "chain head subscription completed before username lookup finished", ), ), - ), + ); + }, }); }); }; diff --git a/playground/tests/e2e/dotli-diagnosis.ts b/playground/tests/e2e/dotli-diagnosis.ts index 5eb7c7a6..a7aa1387 100644 --- a/playground/tests/e2e/dotli-diagnosis.ts +++ b/playground/tests/e2e/dotli-diagnosis.ts @@ -13,12 +13,12 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { extractQrPayload } from "./dotli/helpers/extract-qr-payload"; import { - disconnect, - generateUsername, - health, - pair, - type PairResult, -} from "./dotli/helpers/signer-bot"; + formatSigningHostExit, + startSigningHostPair, + stopSigningHost, + type SigningHostCliConfig, + type SigningHostCliProcess, +} from "./dotli/helpers/signing-host-cli"; const currentDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(currentDir, "../../.."); @@ -36,18 +36,55 @@ const hostNetworks = const headless = process.env.HEADED === "1" ? false : true; const slowMo = process.env.SLOWMO ? Number(process.env.SLOWMO) : 0; const smokeOnly = process.env.E2E_DOTLI_SMOKE === "1"; -const defaultBotBase = "https://signing-bot-dev.novasama-tech.org/"; -const defaultBotNetwork = "paseo-next-v2"; const loginUserBadgeTimeoutMs = Number( - process.env.E2E_DOTLI_LOGIN_TIMEOUT_MS ?? "240000", + process.env.E2E_DOTLI_LOGIN_TIMEOUT_MS ?? "600000", ); - -const botToken = readEnv("SIGNER_BOT_SVC_TOKEN"); -const botBase = process.env.SIGNER_BOT_BASE_URL ?? defaultBotBase; -const botNetwork = process.env.SIGNER_BOT_NETWORK ?? defaultBotNetwork; -const botUsername = process.env.SIGNER_BOT_USERNAME; +const signingHostNetwork = + process.env.E2E_DOTLI_NETWORK ?? "paseo-next-v2"; +const signingHostConfig: SigningHostCliConfig = { + binary: resolve( + process.env.E2E_DOTLI_SIGNING_HOST_BIN ?? + resolve(repoRoot, "target/debug/truapi-host"), + ), + cwd: repoRoot, + basePath: resolve( + process.env.E2E_DOTLI_SIGNING_HOST_BASE_PATH ?? + resolve(repoRoot, ".e2e-dotli/signing-host"), + ), + network: signingHostNetwork, + liteUsernamePrefix: process.env.HOST_CLI_SIGNER_MNEMONIC?.trim() + ? undefined + : "dotlitest", +}; +const expectedHostGaps = [ + "Account/create_account_proof", + "Chat/create_room", + "Chat/register_bot", + "Chat/list_subscribe", + "Chat/post_message", + "Chat/action_subscribe", + "Chat/custom_message_render_subscribe", + "Coin Payment/create_purse", + "Coin Payment/query_purse", + "Coin Payment/rebalance_purse", + "Coin Payment/delete_purse", + "Coin Payment/create_receivable", + "Coin Payment/create_cheque", + "Coin Payment/deposit", + "Coin Payment/refund", + "Coin Payment/listen_for_payment", + "Payment/balance_subscribe", + "Payment/top_up", + "Payment/request", + "Payment/status_subscribe", + // These generated examples ask for truapi-playground.dot while this E2E + // loads the product as localhost:3000. Legacy payload/transaction methods + // intentionally accept only the current product's slot-zero key. + "Signing/create_transaction_with_legacy_account", + "Signing/sign_payload_with_legacy_account", +]; const allowedFailures = new Set( - (process.env.E2E_DOTLI_ALLOWED_FAILURES ?? "") + (process.env.E2E_DOTLI_ALLOWED_FAILURES ?? expectedHostGaps.join(",")) .split(",") .map((id) => id.trim()) .filter(Boolean), @@ -57,8 +94,14 @@ const serverProcesses: ChildProcess[] = []; const pageErrors: string[] = []; const browserLogs: string[] = []; const screenshots: string[] = []; +const signingHostLogs: string[] = []; let screenshotSeq = 0; +interface SignedInSession { + process: SigningHostCliProcess; + username: string; +} + type PlaygroundE2E = { waitForConnectionStatus?: ( status: "disconnected" | "connecting" | "connected", @@ -75,30 +118,6 @@ declare global { } } -function readEnv(name: string): string | undefined { - const value = process.env[name]; - if (!value && !smokeOnly) { - throw new Error( - `${name} is required for fully automated e2e-dotli. ` + - "This suite pairs through signer-bot; without it, a human phone scan is required.", - ); - } - return value; -} - -function requireBotEnv(): { - token: string; - base: string; - network: string; -} { - if (botToken === undefined) { - throw new Error( - "SIGNER_BOT_SVC_TOKEN is required outside E2E_DOTLI_SMOKE=1.", - ); - } - return { token: botToken, base: botBase, network: botNetwork }; -} - function startServer( label: string, command: string, @@ -259,38 +278,59 @@ async function openLoginQr(page: Page): Promise { return await extractQrPayload(page, "#auth-modal-qr canvas"); } -async function signInWithBot( - page: Page, - username = botUsername ?? generateUsername(), -): Promise { - const { token, base, network } = requireBotEnv(); +async function signInWithSigningHost(page: Page): Promise { const handshake = await openLoginQr(page); - console.log(`[e2e-dotli] pairing signer-bot user ${username}`); - const result = await pair(base, token, { - handshake, - username, - network, - }); - try { - await waitForSignedIn(page, result); - await captureStep(page, "signed-in"); - } catch (error) { - await disconnect(base, token, result.sessionId); - throw error; + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + console.log( + `[e2e-dotli] starting signing-host CLI pairing responder (${attempt}/${maxAttempts})`, + ); + const signingHost = startSigningHostPair(signingHostConfig, handshake); + try { + const username = await waitForSignedIn(page, signingHost); + await captureStep(page, "signed-in"); + return { process: signingHost, username }; + } catch (error) { + signingHostLogs.push(signingHost.output()); + await stopSigningHost(signingHost); + if ( + attempt === maxAttempts || + !isRetryableSigningHostPairError(error) + ) { + throw error; + } + console.warn( + `[e2e-dotli] transient signing-host pairing failure; retrying in 5s (${attempt}/${maxAttempts})`, + ); + await page.waitForTimeout(5_000); + } } - return result; + throw new Error("signing-host pairing retry exhausted"); +} + +function isRetryableSigningHostPairError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes("Invalid Transaction") || + message.includes("temporarily banned") || + message.includes("timed out waiting for author_submitAndWatchExtrinsic") + ); } -async function waitForSignedIn(page: Page, result: PairResult): Promise { +async function waitForSignedIn( + page: Page, + signingHost: SigningHostCliProcess, +): Promise { try { const existingFailure = await latestLoginFailureReason(page); if (existingFailure !== null) { throw new Error(`Login failed: ${existingFailure}`); } - await Promise.race([ + const outcome = await Promise.race([ page .locator("#auth-button .user-badge") - .waitFor({ state: "visible", timeout: loginUserBadgeTimeoutMs }), + .waitFor({ state: "visible", timeout: loginUserBadgeTimeoutMs }) + .then(() => ({ tag: "signed-in" as const })), page.evaluate( () => new Promise((_, reject) => { @@ -309,11 +349,25 @@ async function waitForSignedIn(page: Page, result: PairResult): Promise { window.addEventListener("dotli:truapi-auth-state", listener); }), ), + signingHost.completed.then((result) => ({ + tag: "signing-host-exit" as const, + result, + })), ]); + if (outcome.tag === "signing-host-exit") { + throw new Error(formatSigningHostExit(outcome.result, signingHost.output())); + } + const username = ( + await page.locator("#user-popover-username").innerText() + ).trim(); + if (username.length === 0) { + throw new Error("signed-in host did not expose the account username"); + } + return username; } catch (error) { await writeAuthDebug(page, { - stage: "post-pair-user-badge", - pairResult: redactedPairResult(result), + stage: "post-signing-host-pair-user-badge", + signingHostOutput: signingHost.output(), }); throw error; } @@ -334,19 +388,6 @@ async function latestLoginFailureReason(page: Page): Promise { }); } -function redactedPairResult(result: PairResult): PairResult { - return { - sessionId: result.sessionId, - user: { - username: result.user.username, - network: result.user.network, - address: result.user.address, - publicKeyHex: result.user.publicKeyHex, - attested: result.user.attested, - }, - }; -} - async function writeAuthDebug( page: Page, extra: Record, @@ -512,18 +553,22 @@ async function waitForPlaygroundE2EHook(page: Page): Promise { async function assertHostSignOutAndReconnect( page: Page, - previous: PairResult, -): Promise { + previous: SignedInSession, +): Promise { console.log("[e2e-dotli] validating host sign-out"); await signOutIfNeeded(page); await page .locator("#auth-button .user-badge") .waitFor({ state: "hidden", timeout: 20_000 }); + await stopSigningHost(previous.process); + signingHostLogs.push(previous.process.output()); await captureStep(page, "signed-out"); console.log("[e2e-dotli] validating signer reconnect"); - const reconnected = await signInWithBot(page, previous.user.username); - if (reconnected.user.publicKeyHex !== previous.user.publicKeyHex) { + const reconnected = await signInWithSigningHost(page); + if (reconnected.username !== previous.username) { + signingHostLogs.push(reconnected.process.output()); + await stopSigningHost(reconnected.process); throw new Error("signer reconnect returned a different account"); } return reconnected; @@ -648,11 +693,13 @@ async function main(): Promise { ); } if (!smokeOnly) { - const { base, network } = requireBotEnv(); - console.log(`[e2e-dotli] bot=${base} network=${network}`); - const probe = await health(base); - if (!probe.ok) { - throw new Error(`signer-bot unavailable: ${probe.error ?? probe.status}`); + console.log( + `[e2e-dotli] signing-host=${signingHostConfig.binary} network=${signingHostNetwork}`, + ); + if (!existsSync(signingHostConfig.binary)) { + throw new Error( + `signing-host CLI not found at ${signingHostConfig.binary}; run cargo build -p truapi-host-cli`, + ); } } else { console.log("[e2e-dotli] smoke mode: validating local stack and QR only"); @@ -660,7 +707,7 @@ async function main(): Promise { let browser: Awaited> | undefined; let context: BrowserContext | undefined; - let pairResult: PairResult | undefined; + let signedInSession: SignedInSession | undefined; let page: Page | undefined; try { await startLocalStack(); @@ -729,7 +776,7 @@ async function main(): Promise { const params = new URLSearchParams({ chainBackend: "rpc-gateway", e2e: String(Date.now()), - network: botNetwork, + network: signingHostNetwork, }); const url = `http://localhost:${hostPort}/localhost:${playgroundPort}?${params.toString()}`; await page.goto(url, { timeout: 60_000, waitUntil: "domcontentloaded" }); @@ -760,14 +807,17 @@ async function main(): Promise { return; } await waitForPlaygroundE2EHook(page); - pairResult = await signInWithBot(page); + signedInSession = await signInWithSigningHost(page); + signedInSession = await assertHostSignOutAndReconnect( + page, + signedInSession, + ); const stopClicker = startHostModalClicker(page); try { const { summary, report, copyReportClicked, failedMethods } = await runDiagnosis(page); const reportPath = resolve(outputDir, "diagnosis-report.md"); writeFileSync(reportPath, report); - pairResult = await assertHostSignOutAndReconnect(page, pairResult); const allowedFailedMethods = failedMethods.filter((method) => allowedFailures.has(method), ); @@ -787,7 +837,18 @@ async function main(): Promise { reportPath, copyReportClicked, screenshots, - user: redactedPairResult(pairResult).user, + user: { + username: signedInSession.username, + network: signingHostNetwork, + }, + signingHost: { + binary: signingHostConfig.binary, + basePath: signingHostConfig.basePath, + output: [ + ...signingHostLogs, + signedInSession.process.output(), + ], + }, sessionLifecycle: "host-sign-out-reconnect", pageErrors, browserLogs, @@ -821,9 +882,9 @@ async function main(): Promise { } throw error; } finally { - if (pairResult) { - const { token, base } = requireBotEnv(); - await disconnect(base, token, pairResult.sessionId); + if (signedInSession) { + signingHostLogs.push(signedInSession.process.output()); + await stopSigningHost(signedInSession.process); } await context?.close().catch(() => {}); await browser?.close().catch(() => {}); diff --git a/playground/tests/e2e/dotli/helpers/signer-bot.ts b/playground/tests/e2e/dotli/helpers/signer-bot.ts deleted file mode 100644 index 7560a4b7..00000000 --- a/playground/tests/e2e/dotli/helpers/signer-bot.ts +++ /dev/null @@ -1,278 +0,0 @@ -// Copyright 2026 Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: AGPL-3.0-only - -import { setTimeout as sleep } from "node:timers/promises"; - -const TRANSIENT = new Set([502, 503, 504]); - -// Per-attempt request timeout. First-time pair can include user creation and -// People-chain attestation, so give the one-shot handshake room to finish -// instead of aborting and retrying the same QR payload. -const PAIR_REQUEST_TIMEOUT_MS = Number( - process.env.SIGNER_BOT_PAIR_TIMEOUT_MS ?? "120000", -); -const HEALTH_REQUEST_TIMEOUT_MS = 5_000; - -function shellQuote(value: string): string { - if (value.length === 0) return "''"; - return `'${value.replace(/'/g, "'\\''")}'`; -} - -function buildPairCurl(url: string, body: unknown): string { - return [ - `curl -sS -X POST ${shellQuote(url)}`, - '-H "Authorization: Bearer ${SIGNER_BOT_SVC_TOKEN}"', - "-H 'Content-Type: application/json'", - "-H 'Accept: application/json'", - `--data-raw ${shellQuote(JSON.stringify(body))}`, - ].join(" \\\n "); -} - -const SECRET_FIELD_NAMES = new Set([ - "mnemonic", - "privatekey", - "secret", - "secretphrase", - "secretseed", - "secreturi", - "seed", - "suri", -]); - -const QUOTED_JSON_FIELD = /"((?:\\.|[^"\\])+)"\s*:\s*"((?:\\.|[^"\\])*)"/g; - -function normalizeSecretFieldName(name: string): string { - return name.replace(/[-_]/g, "").toLowerCase(); -} - -function isSecretFieldName(name: string): boolean { - return SECRET_FIELD_NAMES.has(normalizeSecretFieldName(name)); -} - -export function redactSignerBotResponse(text: string): string { - try { - return JSON.stringify(JSON.parse(text), (key, value) => - isSecretFieldName(key) ? "[redacted]" : value, - ); - } catch { - return text.replace(QUOTED_JSON_FIELD, (field, name: string) => - isSecretFieldName(name) ? `"${name}":"[redacted]"` : field, - ); - } -} - -async function fetchWithTimeout( - url: string, - init: RequestInit, - timeoutMs: number, -): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(url, { ...init, signal: controller.signal }); - } finally { - clearTimeout(timer); - } -} - -interface TextResponse { - response: Response; - text: string; -} - -async function fetchTextWithTimeout( - url: string, - init: RequestInit, - timeoutMs: number, -): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetch(url, { ...init, signal: controller.signal }); - const text = await response.text(); - return { response, text }; - } finally { - clearTimeout(timer); - } -} - -async function fetchTextRetry( - url: string, - init: RequestInit, - attempts = 4, - timeoutMs = PAIR_REQUEST_TIMEOUT_MS, -): Promise { - let last: unknown = null; - for (let i = 1; i <= attempts; i++) { - try { - const result = await fetchTextWithTimeout(url, init, timeoutMs); - const { response, text } = result; - if (response.ok || !TRANSIENT.has(response.status) || i === attempts) { - return result; - } - console.warn( - `[bot] ${init.method ?? "GET"} ${url} response ${response.status} ${response.statusText} (attempt ${i}/${attempts}): ${redactSignerBotResponse(text)}`, - ); - } catch (e) { - last = e; - if (i === attempts) throw e; - console.warn( - `[bot] ${init.method ?? "GET"} ${url} threw "${(e as Error).message}" (attempt ${i}/${attempts})`, - ); - } - await sleep(1_000 * 2 ** (i - 1)); - } - throw last ?? new Error("fetchTextRetry exhausted"); -} - -/** - * Generate a per-run username for the Nova signing bot. - * - * Each test run gets its own throwaway user so network state (allowances, - * permissions, derived product accounts) doesn't leak between PRs. The - * format is `dotlitests` followed by 6 lowercase letters, a namespace of - * roughly 3·10^8 with no collisions in practice. It stays inside the bot's - * strictest username regex (^[a-z]+$) so it works for both regular - * `username` and `liteUsername` fields if we ever want one. - */ -export function generateUsername(): string { - const alphabet = "abcdefghijklmnopqrstuvwxyz"; - let suffix = ""; - for (let i = 0; i < 6; i++) { - suffix += alphabet[Math.floor(Math.random() * alphabet.length)]; - } - return `dotlitests${suffix}`; -} - -export interface PairResult { - sessionId: string; - user: { - username: string; - network: string; - address: string; - publicKeyHex: string; - attested?: boolean; - }; -} - -/** - * Pair the bot with a dot.li session via the QR-derived handshake deeplink. - * - * The Nova bot's `/api/pair` is one-shot: given a handshake, it - * (a) creates the user if `username` is new, (b) attests the account on - * People chain so it has Statement Store allowance, (c) completes the - * SSO handshake, (d) starts auto-signing future SignRequests for that - * session. No separate provisioning / poll step needed. - * - * `network` should match dot.li's default network (`paseo-next-v2` at time - * of writing). The bot's `/api/networks` endpoint lists supported IDs. - */ -export async function pair( - base: string, - svcToken: string, - args: { handshake: string; username: string; network: string }, -): Promise { - const url = `${base.replace(/\/$/, "")}/api/pair`; - console.log(`[bot] /api/pair curl:\n${buildPairCurl(url, args)}`); - const init: RequestInit = { - method: "POST", - headers: { - Authorization: `Bearer ${svcToken}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(args), - }; - let result: TextResponse; - try { - result = await fetchTextRetry(url, init); - } catch (e) { - console.error( - `[bot] /api/pair response unavailable: ${(e as Error).message}`, - ); - throw e; - } - const { response: r, text } = result; - console.log( - `[bot] /api/pair raw response ${r.status} ${r.statusText}: ${redactSignerBotResponse(text) || ""}`, - ); - if (!r.ok) { - throw new Error( - `pair ${r.status}: ${redactSignerBotResponse(text) || ""}`, - ); - } - return JSON.parse(text) as PairResult; -} - -/** - * Tear down a bot session at the end of a test worker. - * - * Best-effort. Failure to disconnect is non-fatal, the bot times - * sessions out anyway. - */ -export async function disconnect( - base: string, - svcToken: string, - sessionId: string, -): Promise { - await disconnectStrict(base, svcToken, sessionId).catch(() => {}); -} - -export async function disconnectStrict( - base: string, - svcToken: string, - sessionId: string, -): Promise { - const response = await fetchWithTimeout( - `${base.replace(/\/$/, "")}/api/disconnect`, - { - method: "POST", - headers: { - Authorization: `Bearer ${svcToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ sessionId }), - }, - PAIR_REQUEST_TIMEOUT_MS, - ); - if (!response.ok) { - throw new Error(`disconnect ${response.status}: ${await response.text()}`); - } - const body = (await response.json()) as { disconnected?: boolean }; - if (body.disconnected !== true) { - throw new Error(`disconnect did not find session ${sessionId}`); - } -} - -export interface BotHealth { - ok: boolean; - status?: string; - uptime?: number; - error?: string; -} - -/** - * Lightweight bot reachability probe. Used by globalSetup to fail-fast - * when the bot is unreachable, distinguishing "Nova is down" from - * "dot.li is broken" in CI output. No auth required. - */ -export async function health(base: string): Promise { - try { - const r = await fetchWithTimeout( - `${base.replace(/\/$/, "")}/api/health`, - { method: "GET", headers: { Accept: "application/json" } }, - HEALTH_REQUEST_TIMEOUT_MS, - ); - if (!r.ok) { - return { ok: false, error: `${r.status} ${r.statusText}` }; - } - const body = (await r.json()) as { status?: string; uptime?: number }; - return { - ok: body.status === "ok", - status: body.status, - uptime: body.uptime, - }; - } catch (e) { - return { ok: false, error: (e as Error).message }; - } -} diff --git a/playground/tests/e2e/dotli/helpers/signing-host-cli.test.ts b/playground/tests/e2e/dotli/helpers/signing-host-cli.test.ts new file mode 100644 index 00000000..03f9f891 --- /dev/null +++ b/playground/tests/e2e/dotli/helpers/signing-host-cli.test.ts @@ -0,0 +1,69 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + formatSigningHostExit, + sanitizeSigningHostOutput, + signingHostPairArgs, + type SigningHostCliConfig, +} from "./signing-host-cli"; + +const config: SigningHostCliConfig = { + binary: "/repo/target/debug/truapi-host", + cwd: "/repo", + basePath: "/repo/.e2e-dotli/signing-host", + network: "paseo-next-v2", + liteUsernamePrefix: "dotlitest", +}; + +describe("signing-host CLI pairing", () => { + it("builds an isolated auto-accept pair command", () => { + assert.deepEqual( + signingHostPairArgs(config, "polkadotapp://pair?handshake=01"), + [ + "signing-host", + "--network", + "paseo-next-v2", + "--base-path", + "/repo/.e2e-dotli/signing-host", + "--frame-listen", + "127.0.0.1:0", + "--auto-accept", + "--lite-username-prefix", + "dotlitest", + "exec", + "/pair polkadotapp://pair?handshake=01", + ], + ); + }); + + it("omits managed-account flags for an explicit mnemonic", () => { + assert.ok( + !signingHostPairArgs( + { ...config, liteUsernamePrefix: undefined }, + "polkadotapp://pair?handshake=01", + ).includes("--lite-username-prefix"), + ); + }); + + it("redacts pairing deeplinks from logs and failures", () => { + const output = + "pairing polkadotapp://pair?handshake=secret\nrequest accepted"; + assert.equal( + sanitizeSigningHostOutput(output), + "pairing \nrequest accepted", + ); + assert.ok( + formatSigningHostExit({ code: 1, signal: null }, output).includes( + "pairing ", + ), + ); + assert.ok( + !formatSigningHostExit({ code: 1, signal: null }, output).includes( + "secret", + ), + ); + }); +}); diff --git a/playground/tests/e2e/dotli/helpers/signing-host-cli.ts b/playground/tests/e2e/dotli/helpers/signing-host-cli.ts new file mode 100644 index 00000000..aef8b144 --- /dev/null +++ b/playground/tests/e2e/dotli/helpers/signing-host-cli.ts @@ -0,0 +1,160 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { spawn, type ChildProcess } from "node:child_process"; +import type { Readable, Writable } from "node:stream"; + +const MAX_CAPTURED_OUTPUT_BYTES = 256 * 1024; +const PAIRING_DEEPLINK = /polkadotapp:\/\/pair\?[^\s'"]+/g; + +export interface SigningHostCliConfig { + binary: string; + cwd: string; + basePath: string; + network: string; + liteUsernamePrefix?: string; +} + +export interface SigningHostExit { + code: number | null; + signal: NodeJS.Signals | null; + error?: string; +} + +export interface SigningHostCliProcess { + child: ChildProcess; + completed: Promise; + output: () => string; +} + +export function signingHostPairArgs( + config: SigningHostCliConfig, + deeplink: string, +): string[] { + const args = [ + "signing-host", + "--network", + config.network, + "--base-path", + config.basePath, + "--frame-listen", + "127.0.0.1:0", + "--auto-accept", + ]; + if (config.liteUsernamePrefix !== undefined) { + args.push("--lite-username-prefix", config.liteUsernamePrefix); + } + args.push("exec", `/pair ${deeplink}`); + return args; +} + +export function sanitizeSigningHostOutput(text: string): string { + return text.replace(PAIRING_DEEPLINK, ""); +} + +export function startSigningHostPair( + config: SigningHostCliConfig, + deeplink: string, +): SigningHostCliProcess { + const child = spawn( + config.binary, + signingHostPairArgs(config, deeplink), + { + cwd: config.cwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let captured = ""; + const append = (line: string): void => { + captured = `${captured}${line}\n`; + if (Buffer.byteLength(captured) > MAX_CAPTURED_OUTPUT_BYTES) { + captured = captured.slice(-MAX_CAPTURED_OUTPUT_BYTES); + } + }; + pipeLines(child.stdout, process.stdout, "[signing-host]", append); + pipeLines(child.stderr, process.stderr, "[signing-host]", append); + + const completed = new Promise((resolve) => { + child.once("error", (error) => { + resolve({ code: null, signal: null, error: error.message }); + }); + child.once("exit", (code, signal) => { + resolve({ code, signal }); + }); + }); + + return { + child, + completed, + output: () => captured.trimEnd(), + }; +} + +export async function stopSigningHost( + process: SigningHostCliProcess, +): Promise { + if (process.child.exitCode !== null || process.child.signalCode !== null) { + return; + } + process.child.kill("SIGTERM"); + const stopped = await Promise.race([ + process.completed.then(() => true), + new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), 5_000); + timer.unref(); + }), + ]); + if (!stopped && process.child.exitCode === null) { + process.child.kill("SIGKILL"); + await process.completed; + } +} + +export function formatSigningHostExit( + result: SigningHostExit, + output: string, +): string { + const status = + result.error ?? + (result.code !== null + ? `exit code ${result.code}` + : `signal ${result.signal ?? "unknown"}`); + const detail = sanitizeSigningHostOutput(output).trim(); + return detail.length > 0 + ? `signing-host stopped before login (${status}):\n${detail}` + : `signing-host stopped before login (${status})`; +} + +function pipeLines( + stream: Readable | null, + destination: Writable, + prefix: string, + append: (line: string) => void, +): void { + if (stream === null) { + return; + } + stream.setEncoding("utf8"); + let buffered = ""; + const emit = (line: string): void => { + const sanitized = sanitizeSigningHostOutput(line); + append(sanitized); + destination.write(`${prefix} ${sanitized}\n`); + }; + stream.on("data", (chunk: string) => { + buffered += chunk; + const lines = buffered.split(/\r?\n/); + buffered = lines.pop() ?? ""; + for (const line of lines) { + if (line.length > 0) { + emit(line); + } + } + }); + stream.on("end", () => { + if (buffered.length > 0) { + emit(buffered); + } + }); +} diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index aaf409d2..a2664097 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -786,7 +786,8 @@ export interface PreimageHost { * Product-scoped key-value storage. * * The core namespaces product keys before calling this trait. Host - * implementations should treat `key` as an opaque OS-style storage key. + * implementations may treat `key` as opaque or decode it with + * `ProductStorageKey` when their physical storage is separated by product. */ export interface ProductStorage { /** diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml new file mode 100644 index 00000000..9c2bcc81 --- /dev/null +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "truapi-host-cli" +version = "0.1.0" +edition.workspace = true +description = "Headless TrUAPI hosts: a signing-host companion and a pairing host that pair over the real People-chain statement store, for end-to-end testing without an external signer service" +license = "MIT" + +[[bin]] +name = "truapi-host" +path = "src/main.rs" + +[lints.rust] +unsafe_code = "forbid" + +[dependencies] +truapi = { path = "../truapi" } +truapi-platform = { path = "../truapi-platform" } +truapi-server = { path = "../truapi-server" } +anyhow = "1" +arboard = { version = "3.6.1", default-features = false } +async-trait = "0.1" +bip39 = { version = "2", features = ["rand"] } +clap = { version = "4", features = ["derive", "env"] } +crossterm = { version = "0.29", features = ["event-stream"] } +futures = "0.3" +futures-util = "0.3" +fs2 = "0.4" +hex = "0.4" +parity-scale-codec = { version = "3", features = ["derive"] } +ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +rustls = { version = "0.23", default-features = false, features = ["ring"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +shlex = "1" +subxt-rpcs = { version = "0.50.1", default-features = false, features = ["jsonrpsee", "native"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "io-std", "net", "time", "signal", "process"] } +tokio-stream = { version = "0.1", features = ["sync"] } +tokio-tungstenite = { version = "0.24", features = ["connect", "rustls-tls-webpki-roots"] } +# TLS is compiled in for required People/Bulletin host traffic and optional +# `Chain/*` playground methods. Product live-chain routing is opt-in +# (`E2E_LIVE_CHAIN=1`); required host routes remain enabled. See src/chain.rs. +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +unicode-width = "0.2" + +[dev-dependencies] +tempfile = "3" diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md new file mode 100644 index 00000000..9db0de12 --- /dev/null +++ b/rust/crates/truapi-host-cli/README.md @@ -0,0 +1,387 @@ +# truapi-host-cli + +Headless TrUAPI hosts for local end-to-end testing, built on `truapi-server`. +They replace the external signing-bot service: two CLI processes take the two +host-spec §B roles and pair over the **real People-chain statement store** (the +same node an iOS/web client uses), so tests run against a real signer with no +Novasama-operated dependency. + +See [SPEC.md](SPEC.md) for the complete as-built v0.1 behavior and engineering +contract. + +Either host can be driven by a **product script** you write: a JS/TS file that +receives a global `truapi` (the `@parity/truapi` client, scoped to a product id) +and calls it like any product would. With `--script`, the CLI runs the script +and exits with its status. Without `--script`, both roles open a full-screen +terminal UI when stdin and stdout are TTYs. + +One binary, `truapi-host`: + +| Command | Role | +| --- | --- | +| `pairing-host` | Seedless host: serves product frames, emits pairing deeplinks, and can run product scripts. | +| `signing-host` | Wallet-local host: owns signer identity, can run product scripts, accepts pairing deeplinks, registers statement allowance on-chain, signs. | +| `identity-check` | Probe which derivation of a mnemonic carries a registered username. | +| `alloc-check` | Diagnose (or `--submit`) on-chain statement-store allowance: ring membership, chosen slot, and the `set_statement_store_account` extrinsic. | + +The repository's `make e2e-dotli` target builds this binary and runs the +dotli/playground Diagnosis suite with a non-interactive signing-host responder. +It verifies the initial pairing, remote signing, host sign-out, and +same-account reconnect without the external signer-bot service. + +## Quick start + +```bash +make headless install # build dependencies and install truapi-host once +truapi-host signing-host +``` + +The signing host opens an interactive terminal where you can paste a pairing +link, type `/pair `, run `/script`, or use `/help` to discover the +available commands. It uses `--mnemonic` / `HOST_CLI_SIGNER_MNEMONIC` if set. +Otherwise it auto-selects or creates a stored account under `--base-path` (default +`$XDG_STATE_HOME/truapi-host` or `~/.local/state/truapi-host`), attests it +through the identity backend, waits for ring readiness, and rotates when the +current account exhausts Statement Store slots. + +### Interactive terminal UI + +In a TTY, both hosts open the same scrollable transcript above a single command +bar. Host lifecycle events, tracing logs, every incoming SSO request, script +stdout/stderr, commands, and approval prompts all use that transcript, so +background output cannot overwrite input. On `signing-host`, `--deeplink URL` +opens the UI and starts the pairing response after initialization. + +Commands always start with `/`: + +| Command | Result | +| --- | --- | +| `/pair ` | Validate and answer a `polkadotapp://pair?...` deeplink (signing host). | +| `/script` | Reopen the session's last TypeScript scratch script (or create one), then run it. | +| `/script ` | Remember and run an existing JS/TS product script through the public frame endpoint. | +| `/login` | Start pairing for the selected product and copy its deeplink to the clipboard. | +| `/logout` | Disconnect the pairing host and discard its old pairing keypair. | +| `/log ` | Change tracing to `error`, `warn`, `info`, `debug`, or `trace`. | +| `/product` | Show the currently selected product. | +| `/product ` | Switch the product used by future scripts and frame connections. | +| `/session` | Show the current session name, path, and user id (signing host). | +| `/session ` | Switch to or create an isolated signing-host session. | +| `/session --list` | List user sessions for the current network. | +| `/help` | Show commands and keyboard shortcuts. | +| `/clear` | Clear the visible transcript. | +| `/copy` | Copy the retained transcript to the system clipboard. | +| `/quit` | Shut down cleanly. | + +Typing `/` opens autocomplete. Up/Down selects a completion; with the menu +closed it navigates process-local command history. Tab inserts a completion, +and `/script` completes filesystem paths. Ctrl-U/Ctrl-D scroll by half a +viewport, End restores auto-follow, Esc closes autocomplete, and Ctrl-C clears +input, cancels a running command, or exits when idle. Deeplinks are deliberately +not persisted in history across processes. + +On `pairing-host`, `/logout` cancels an in-flight pairing, disconnects the +current signing host, and removes the old pairing identity. The next product +login request or operator `/login` generates a new keypair and emits a fresh +link that can be answered by another signing host. `/login` uses the current +`/product` selection, copies the generated deeplink to the system clipboard, +and remains interactive while the TUI renders pairing progress. A clipboard +failure is reported without cancelling pairing. Logout does not clear product +storage, scripts, or the selected product. + +Both `pairing-host` and `signing-host` use the same interactive UI and command +bar. It uses a quiet, command-centered transcript: submitted +commands title full-width dividers, script stdout keeps the terminal's normal +foreground, stderr has a small error gutter, and lifecycle work updates +sentence-case status rows in place. A compact +`TrUAPI host · 👤 · 🌐 · 📦 ` status sits +below the writing bar. Long product names are ellipsized, while session and log +level stay out of that bar. A borderless, subtly backgrounded composer anchors +autocomplete and the `›` prompt while keeping the native cursor after the +input. When the input is empty, command guidance appears there as a placeholder +instead of occupying status space. Set `NO_COLOR=1` to remove semantic colors +and the surface fill without losing spacing, status symbols, or wording. + +Non-interactive `--script` and `exec` runs use the same sentence-case event +copy and status symbols without the full-screen chrome. This keeps captured +logs readable while pairing URLs remain directly extractable by automation. +`/copy` copies readable transcript text without UI chrome or complete pairing +links. Captured script output is plain text: Chalk sees piped stdout, and the +host strips terminal control sequences before adding child output to the +transcript. Raw ANSI styling such as bold is therefore not rendered in the +full-screen UI. + +Bare `/script` reopens the last script recorded for the active session, +including a path previously selected with `/script `. If that file is +missing or the session has no script yet, it creates a durable Bun TypeScript +file under the active host state's `scripts/` directory. Its starter imports +`chalk` to demonstrate that scripts can import npm packages directly and let +Bun install missing dependencies automatically, then calls +`truapi.account.getUserId()`. +The TUI temporarily yields the terminal to `$VISUAL`, then `$EDITOR`, or +`vi` when neither is set. After the editor exits successfully, the TUI is +restored and the saved script runs through the public frame endpoint. Editor +settings containing arguments, such as `EDITOR='code --wait'`, are supported. + +Managed sessions isolate signer accounts, product/core storage, and permissions. +Once a signer identity is known, its public session name is the Lite username +and its files live under +`//_signing_host`. Provisional and legacy named +sessions are promoted to that user-owned root, so an old name such as `pgtest` +does not remain the durable namespace. The selected username is remembered per +network but is not repeated in the status bar as a separate session field. +`default` remains only as a compatibility/bootstrap location until a username +is resolved. It is hidden from session completion and listing and cannot be +selected with `/session default`. User session names contain lowercase ASCII +letters, digits, `.`, `_`, or `-`; they cannot be paths. Switching prepares the +target while the old session remains active, then stops its pairing responder +and resets product WebSocket connections so clients reconnect against the new +runtime. +New auto-managed accounts use the session name as their Lite username prefix; +characters other than lowercase letters are omitted. For example, session +`pgtest` creates usernames beginning with `pgtest`. An explicit +`--lite-username-prefix` takes precedence, and `default` retains the historical +`headless` prefix. +The selected username and last script reference are cached in `session.json` +inside the displayed session path. Scratch scripts use a portable filename; +explicit scripts use an absolute path. On restart, an +already-provisioned local signer is activated from disk without an +identity-backend or ring-membership round trip, and bare `/script` restores that +session's editor context. A session with no signer yet reports +`` and the transcript prompts the user to run +`/session `. Inspecting with bare `/session` never starts network +onboarding; naming a different session creates and connects its user. + +Select or create a session at startup with: + +```bash +truapi-host signing-host --session alice +``` + +`--session` cannot be combined with `--account` or `--mnemonic`. A host +started with an explicit mnemonic reports an `ephemeral` session and does not +allow runtime switching. + +Only one operational command runs at once, but SSO traffic and approvals keep +flowing while it runs. Without a TTY, use one-shot `exec` mode (parent options +come first): + +```bash +truapi-host signing-host exec '/session' +truapi-host signing-host --auto-accept exec '/script ./js/scripts/ring-vrf-smoke.ts' +truapi-host signing-host exec '/pair polkadotapp://pair?handshake=...' +``` + +`exec` does not enable raw mode or emit terminal controls. Command results go +to stdout, diagnostics go to stderr, and the process exits when the command +finishes. Starting `signing-host` without `--script` or `exec` while either +stdin or stdout is not a TTY is an invocation error. The existing `--script` +one-shot mode remains supported. + +## Writing a product script + +A product script is top-level JavaScript or TypeScript (an ES module) run by +Bun. It can import npm dependencies directly; Bun installs missing packages +automatically. The runner injects three globals before running it: + +- **`truapi`** — the `@parity/truapi` client connected to the pairing host and + scoped to the host's `--product-id`. Call `truapi.account.requestLogin(...)`, + `truapi.signing.signRaw(...)`, `truapi.localStorage.write(...)`, etc. +- **`host`** — just `host.productId` and `host.productAccount(index?)`. That is + all it does: it keeps product accounts in sync with the host's `--product-id` + (hardcoding a mismatched id fails signing with `PermissionDenied`). Use + `console.log` and `throw` for everything else. +- **`assert`** — throw when its condition is false, using any following values + as the error message. + +Write it top-level and `throw` (or reject) to fail the run: + +```ts +const login = await truapi.account.requestLogin({ reason: undefined }); +if ( + !login.isOk() || + (login.value !== "Success" && login.value !== "AlreadyConnected") +) throw new Error("login failed"); + +const res = await truapi.signing.signRaw({ + account: host.productAccount(), + payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, +}); +res.match( + (v) => console.log("signature", v.signature), + (e) => { throw new Error(JSON.stringify(e)); }, +); +``` + +`--product-id` (a `.dot` name or `localhost` identifier; default +`headless-playground.dot`) sets the initial product. `/product ` changes it +for the lifetime of the process. Switching disconnects active product +WebSockets so clients reconnect with a new product context; the network, +pairing relationship, signing-host session, and wallet identity stay active. +Product-owned storage, permissions, and derived product accounts are scoped by +the selected id, so the newly selected product sees its own state. The next +`/script` also receives the new id through `host.productId`. + +Pairing-host state follows the same identity rule under +`//_pairing_host`. Before the first identity is +known it uses the small `/pairing-host` bootstrap; connecting moves +legacy bootstrap data to the first resolved user. After `/logout`, connecting +as a different user swaps to that user's KV/core namespace instead of carrying +the previous user's product data forward. + +Product-local KV is persisted independently under each identity root as +`storage/--.json`. Each document records its normalized +product id and raw product keys. On first use, the older combined +`product-storage.json` in that profile is split into those files and retained +as `product-storage.v1.json.migrated`. Product and core JSON writes use a +flushed temporary file and atomic rename. + +Five scripts ship under `js/scripts/`: + +- `battery.ts` — the generated full-surface gate. It discovers every method + from the same code-generated example manifest as the playground Diagnosis, + attempts all examples (including APIs the browser diagnosis classifies as + intentionally unsupported), prints test-reporter rows with timings and clean + failure details, writes the browser-shaped result matrix to + the role-specific report under `explorer/diagnosis-reports/`, and exits + nonzero if any example fails. A paired run writes `pairing-host-cli.md`; a + direct signing-host run writes `signing-host-cli.md`. Override the artifact + path with `TRUAPI_BATTERY_REPORT_PATH`. Run the complete generated Playground + surface directly against the signing host: + + ```bash + target/debug/truapi-host signing-host \ + --product-id truapi-playground.dot \ + --script rust/crates/truapi-host-cli/js/scripts/battery.ts \ + --auto-accept + ``` + + To exercise the paired topology, run the same script with `pairing-host`, + then answer its emitted link from a second terminal: + + ```bash + # Terminal 1 + target/debug/truapi-host pairing-host \ + --product-id truapi-playground.dot \ + --script rust/crates/truapi-host-cli/js/scripts/battery.ts \ + --auto-accept + + # Terminal 2 + target/debug/truapi-host signing-host \ + --deeplink '' \ + --auto-accept + ``` + +- `whoami.ts` — calls `getUserId` and prints `WHOAMI `; this + remains available as an explicit `/script ` example. +- `signing-smoke.ts` — a focused product-account signing check. +- `ring-vrf-smoke.ts` — calls `getAccountAlias` and `createAccountProof` + against the Paseo Next v2 LitePeople ring, then verifies both calls return + the same contextual alias. +- `preimage-smoke.ts` — a focused Bulletin preimage flow check. + +The generated examples are baked to the `truapi-playground.dot` product. With +live routing enabled, `Chain/stop_transaction` uses host-owned operation ids and +treats already-finished provider operations as stopped. `Preimage/*` also uses +the real Bulletin Next chain and asks the signing host to claim People-chain +long-term storage before returning the product-scoped Bulletin allowance key. +It needs the playground's deps (`cd playground && bun install`). Repeated live +runs can exhaust the signer's per-period Statement Store or Bulletin allocation +slots; the signing host rotates auto-managed signer accounts when Statement +Store slots are exhausted. + +## Confirmations + +Both hosts take `--auto-accept`. Without it, confirmations a web/iOS host would +show as a modal (sign requests, permission prompts, and cross-product Ring-VRF +requests) are rendered prominently in the signing-host transcript and answered +directly with `y` or `n` (typed `yes`/`no` plus Enter also works). Approval +cards summarize and redact signing payloads rather than dumping debug objects. +The current command draft is +restored afterward; Esc safely rejects. Concurrent approvals are serialized. +In non-interactive `exec` mode, a TTY gets a plain yes/no prompt and non-TTY +stdin safely rejects instead of hanging. Same-product Ring-VRF requests do not +prompt, matching the iOS signing host. Pass `--auto-accept` for unattended +runs; every auto-approved decision is still printed. + +## Logging + +Use the global `--log-level` option (`error`, `warn`, `info`, `debug`, or +`trace`) before or after the subcommand, or `/log ` in the terminal UI. +Every decoded inbound SSO request and every published response is visible +regardless of the selected level. Stable response entries include the request +name, statement and remote message ids, protocol outcome, and elapsed time; +encoded protocol errors include their reason. Response-publication failures +are shown separately. `debug` adds decoded request/response summaries and +`trace` adds complete payload and transport metadata. Undecodable requests are +warnings with the available identifiers so protocol-version mismatches can be +diagnosed. + +```bash +truapi-host signing-host --log-level trace --deeplink '' --auto-accept +``` + +Debug and trace output may contain product signing payloads. `RUST_LOG` takes +precedence at startup and remains available for module-specific filters, except +that the noisy `rustls` and `tungstenite::protocol` tracing targets are always +excluded from CLI log output. Without `RUST_LOG`, `--log-level` and `/log` +apply to TrUAPI targets while other third-party dependencies remain at `warn`. + +## Statement-store allowance + +The real statement store enforces per-account allowance. Before pairing, the +signing host grants it on-chain exactly as a real client does: it proves its +LitePeople ring membership with a bandersnatch ring-VRF and submits an unsigned +General (v5) `Resources.set_statement_store_account` extrinsic for each account +that submits statements — its own `//wallet//sso` account and the pairing host's +per-pairing device key. The shared native implementation lives in +`truapi-server/src/runtime/statement_allowance/` (metadata-driven +signed-extension encoding, ring fetch, slot scan, ring-VRF proof, extrinsic +assembly, submit). The signing account must be an attested LitePeople member, +and may sit in an old ring, so the signing host scans back from the current ring +index (slow, one-time per pairing). Auto-managed accounts are stored in +`accounts.json` under `--base-path`; mnemonics are plaintext local test secrets +and the file is written with `0600` permissions on Unix. `alloc-check` verifies +membership and can submit a test registration. + +## Manual use (two terminals) + +```bash +make headless install + +# Terminal 1 — pairing host runs a product script and prints its pairing link: +truapi-host pairing-host --product-id myapp.dot --script js/scripts/battery.ts --auto-accept + +# Terminal 2 — hand the deeplink to a signing host (registers allowance, signs). +# The wallet mnemonic comes from --mnemonic / $HOST_CLI_SIGNER_MNEMONIC when set; +# otherwise the CLI auto-selects or creates an attested account. +truapi-host signing-host --deeplink '' --auto-accept +HOST_CLI_SIGNER_MNEMONIC="spin battle …" truapi-host signing-host --deeplink '' --auto-accept + +# Inspect on-chain statement-store allowance for a mnemonic: +truapi-host alloc-check --mnemonic "spin battle …" --lookback 100 +``` + +Both hosts take `--network` (default `paseo-next-v2`). The network preset owns +the identity backend URL, People RPC, Bulletin RPC, and genesis hashes; there is +no public `--statement-store` flag. + +## Scope / gaps + +- **Chain methods** route to real `wss://` nodes from the selected `--network` + when `E2E_LIVE_CHAIN=1`; off by default. A rustls crypto provider is + installed at startup for the TLS connections. +- **Ring-VRF product-account aliases and proofs** are implemented by the + signing host via the `verifiable` crate (`get_account_alias` and + `create_account_proof`). +- **`get_user_id`** resolves the signing account's username from People-chain + `Resources.Consumers`. Auto-managed signing accounts register fresh lite + usernames via the identity backend (`src/attestation.rs`); first registration + is backend-async and can take minutes (ring onboarding). `truapi-host + identity-check --mnemonic ` probes which derivation carries a username. +- `set_statement_store_account` and Bulletin long-term-storage resource + allocation are implemented over SSO on native headless hosts. +- Everything else the browser host exercises passes: signing (raw, payload, + create-transaction, and their legacy variants), statement store, entropy, + aliases, preimage, storage, permissions, notifications, theme, system, chain + (with `E2E_LIVE_CHAIN=1`), and user id, subject to live chain availability + and allowance-slot capacity. diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md new file mode 100644 index 00000000..271139ba --- /dev/null +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -0,0 +1,1549 @@ +# TrUAPI Headless Host CLI v0.1 specification + +- Status: as-built behavior reference +- Binary: `truapi-host` +- Implementation: `rust/crates/truapi-host-cli/` +- Protocol implementation: `truapi` and `truapi-server` + +This document specifies the first complete version of the native headless +TrUAPI host CLI. It was derived from the Rust and TypeScript implementation, +the test suite, the checked-in compatibility reports, and observed runs of both +host roles. + +The crate [README](README.md) is the user guide. This document is the complete +behavioral and engineering reference. It describes what v0.1 does, including +its operational limits; it does not contain a roadmap or requirements for +unimplemented features. + +## 1. Purpose and scope + +`truapi-host` runs real TrUAPI host roles without a browser UI, desktop shell, +phone automation service, or external signing bot. It is intended for: + +- local product development; +- protocol and host diagnosis; +- direct signing-host tests; +- paired end-to-end tests; and +- generation of CLI host compatibility reports. + +It embeds the real `truapi-server` dispatcher and host logic. Product scripts +use the public `@parity/truapi` client and exchange the same SCALE protocol +messages as a product connected to another host. + +The CLI replaces the platform/operating-system seam with native implementations +for persistence, chain RPC, approvals, notifications, navigation, theme, and +terminal presentation. It also owns account onboarding, process orchestration, +the product-frame WebSocket bridge, and the Bun script runner. + +It is local test infrastructure, not: + +- a production wallet or secure custody product; +- a general-purpose mnemonic manager; +- a mock protocol server; +- a replacement for dot.li or Polkadot Desktop; +- a Chat, Coin Payment, or Payment backend; or +- an arbitrary-network RPC proxy. + +## 2. Roles and runtime topologies + +### 2.1 Pairing host + +The pairing host is seedless and product-facing: + +```text +product script or client + │ + │ TrUAPI SCALE frames over WebSocket + ▼ +pairing-host + │ + │ People-chain Statement Store / SSO + ▼ +signing-host + │ + └── wallet entropy, product accounts, signing, aliases, proofs +``` + +The pairing host: + +- serves product connections; +- starts SSO login and emits a `polkadotapp://pair?...` deeplink; +- persists the paired session and product state; +- receives a root entropy source from the signing host; +- delegates signing and other authority operations over SSO; and +- can run scripts before, during, or after pairing. + +It never stores the signing host's raw mnemonic or raw root entropy. + +### 2.2 Direct signing host + +The signing host can also be product-facing: + +```text +product script or client + │ + │ TrUAPI SCALE frames over WebSocket + ▼ +signing-host + │ + ├── local wallet entropy and product authority + ├── People-chain Statement Store + └── Bulletin and optional product-chain RPC +``` + +This path is used for focused diagnosis without SSO. It uses the same Rust +signing, account, entropy, Statement Store, Bulletin, and product runtime logic +as the paired path. + +### 2.3 Ownership boundaries + +`truapi-server` owns: + +- protocol dispatch and SCALE encoding; +- product and role semantics; +- product-account derivation; +- signing and transaction construction; +- product-scoped entropy derivation; +- Ring-VRF aliases and proofs; +- SSO request/response encoding and transport; +- Statement Store proof, submission, and allowance logic; +- Bulletin preimage submission and resource allocation; +- subscriptions and runtime errors; and +- typed unavailable behavior for unsupported services. + +`truapi-host-cli` owns: + +- argument and slash-command parsing; +- the single supported network preset; +- local signer selection and onboarding; +- local persistence and account-store locking; +- approvals and `--auto-accept`; +- the terminal UI and plain output; +- product-frame WebSocket listening; +- session and product switching; +- child editor and Bun processes; and +- CLI-specific diagnostics. + +Product scripts own: + +- the TrUAPI calls under test; +- assertions over typed results; +- test output; and +- success or failure through normal completion or a thrown error. + +## 3. Build, installation, and runtime dependencies + +### 3.1 Build + +From the repository root: + +```sh +make headless +``` + +This builds: + +- the Rust `truapi-host` binary; and +- the generated `@parity/truapi` TypeScript client used by the script runner. + +The direct Cargo equivalent for the binary is: + +```sh +cargo build -p truapi-host-cli +``` + +### 3.2 Installation + +```sh +make headless install +``` + +The `install` target depends on `headless` and runs: + +```sh +cargo install \ + --path rust/crates/truapi-host-cli \ + --bin truapi-host \ + --locked \ + --force +``` + +### 3.3 Runtime dependencies + +Host-only commands need the installed Rust binary. Product scripts additionally +need: + +- `bun` on `PATH`; +- `js/runner.ts`; and +- the repository's generated `@parity/truapi` TypeScript sources and their + dependencies. + +By default, the runner path is compiled from `CARGO_MANIFEST_DIR` and therefore +points into the source checkout. `TRUAPI_HOST_RUNNER` can select another +`runner.ts`. The v0.1 install is not a self-contained relocatable script +runtime: deleting or moving the checkout without supplying a replacement +runner breaks `/script` and `--script`. + +The binary has `--help` but no `--version` option. + +## 4. Top-level command line + +```text +truapi-host [--log-level ] +``` + +Commands: + +| Command | Purpose | +| --- | --- | +| `pairing-host` | Run the seedless product-facing host. | +| `signing-host` | Run the wallet-local signing host. | +| `identity-check` | Probe People-chain identity records for a mnemonic. | +| `alloc-check` | Inspect or submit Statement Store allowance registration. | + +### 4.1 Global logging option + +`--log-level` accepts: + +- `error` +- `warn` +- `info` +- `debug` +- `trace` + +The default is `info`. `TRUAPI_HOST_LOG` supplies an environment default. The +option is global and is accepted before or after a subcommand. + +If `RUST_LOG` contains a valid tracing filter, it takes precedence at startup. +The interactive `/log` command later replaces the active filter with the +selected CLI level. + +## 5. `pairing-host` + +```text +truapi-host pairing-host [options] +``` + +| Option | Default | Behavior | +| --- | --- | --- | +| `--script ` | none | Run one JS/TS product script and exit with its status. | +| `--product-id ` | `headless-playground.dot` | Initial product scope. | +| `--frame-listen ` | `127.0.0.1:9955` | Product WebSocket listener. Port `0` selects an available port. | +| `--base-path ` | section 12.1 | Root for network, identity, core, script, and product state. | +| `--network ` | `paseo-next-v2` | Select the complete endpoint/genesis preset. | +| `--auto-accept` | off | Approve platform confirmations automatically. | + +Without `--script`, both stdin and stdout must be terminals. The command enters +the full-screen terminal UI and remains active until `/quit`, idle Ctrl-C, or +terminal input ends. + +With `--script`, the command: + +1. builds the pairing runtime; +2. binds and reports the product-frame listener; +3. starts the frame accept loop; +4. starts Bun with inherited stdio; +5. keeps the host alive until Bun exits; +6. stops the accept loop; and +7. exits with the child status. + +The script must call `truapi.account.requestLogin()` or the operator must use +`/login` in interactive mode to initiate pairing. + +There is no pairing-host `exec` subcommand. + +## 6. `signing-host` + +```text +truapi-host signing-host [options] [exec ''] +``` + +| Option | Default | Behavior | +| --- | --- | --- | +| `--script ` | none | Run one direct product script and exit with its status. | +| `--product-id ` | `headless-playground.dot` | Initial product scope. | +| `--deeplink ` | none | Answer a pairing deeplink after initialization. | +| `--mnemonic ` | none | Use raw BIP-39 entropy as an ephemeral local signer. | +| `--account ` | none | Use one named account from the default account store. | +| `--session ` | remembered session | Restore or create a managed session. | +| `--lite-username-prefix ` | session-derived | Prefix for newly generated Lite username bases. | +| `--base-path ` | section 12.1 | Root for account, session, core, script, and product state. | +| `--network ` | `paseo-next-v2` | Select the complete endpoint/genesis preset. | +| `--frame-listen ` | `127.0.0.1:9956` | Direct product WebSocket listener. Port `0` is allowed. | +| `--auto-accept` | off | Approve platform confirmations automatically. | + +`HOST_CLI_SIGNER_MNEMONIC` supplies `--mnemonic` when the option is omitted. + +### 6.1 Argument conflicts + +The CLI rejects these combinations with invocation status `2` before runtime +startup: + +- `--script` with `exec`; +- `--mnemonic` with `--account`; +- `--mnemonic` with `--session`; +- `--mnemonic` with `--lite-username-prefix`; +- `--account` with `--session`; and +- `--account` with `--lite-username-prefix`. + +The same conflicts apply when the mnemonic came from +`HOST_CLI_SIGNER_MNEMONIC`. + +An explicit session name is validated before startup. Empty strings are treated +as absent after trimming. + +### 6.2 Interactive mode + +When neither `--script` nor `exec` is present, stdin and stdout must be +terminals. The signing host: + +1. resolves the selected session and any locally cached signer; +2. creates the signing runtime; +3. activates a cached signer without a network onboarding round trip; +4. binds and reports the product-frame listener; +5. starts `--deeplink`, when supplied, as an initial `/pair` operation; and +6. enters the command loop. + +Signer provisioning is otherwise lazy. Merely starting the UI, using `/help`, +using `/product`, or inspecting sessions does not create a new account. + +### 6.3 One-shot `--script` + +The host binds its product listener, optionally starts a background responder +for `--deeplink`, ensures and activates a signer, runs Bun with inherited stdio, +aborts the responder after the script, and exits with the child status. + +### 6.4 `signing-host exec` + +```sh +truapi-host signing-host [parent options] exec '' +``` + +`exec`: + +- parses exactly one slash command; +- starts the same signing runtime and product-frame listener; +- does not enter raw mode or the alternate screen; +- writes human output to normal stdout/stderr; +- optionally runs `--deeplink` in the background for the command lifetime; +- aborts that responder when the command completes; and +- exits after the command. + +Parent options must appear before `exec`. + +`exec '/script'` needs a TTY because it opens an editor. In non-TTY execution, +use `exec '/script '` instead. `/copy` is unavailable. `/clear` and +`/quit` are successful no-ops in one-shot mode. + +## 7. Product identifiers and switching + +Accepted product identifiers are: + +- a name ending in `.dot`; +- `localhost`; or +- a string beginning with `localhost:`. + +Identifiers are trimmed, Unicode-NFC normalized, and lowercased. For example, +`" Dotli.DOT "` becomes `dotli.dot`. + +Other identifiers, including an ordinary `example.com`, are rejected. + +The product id scopes: + +- product accounts and signatures; +- Ring-VRF context and cross-product policy; +- derived entropy; +- local product storage; +- permissions and authority checks; +- product-frame runtime context; and +- `host.productId` and `host.productAccount()` in scripts. + +`/product` prints the current normalized id. + +`/product ` changes only the process-local product selection. It does not +change: + +- the network; +- the active signer or paired user; +- the signing session; +- the SSO relationship; or +- another product's stored data. + +Changing the product invalidates all active product WebSockets. Clients must +reconnect; new connections receive the new product context. Selecting the +already-current normalized id does not reset connections. + +Product-scoped entropy is intentionally different for different product ids +even when the account and caller context bytes are identical. + +## 8. Slash commands + +Commands start with `/`. There are no `q`, `quit`, `exit`, or non-slash aliases. + +| Command | Pairing host | Signing host | Behavior | +| --- | :---: | :---: | --- | +| `/script` | yes | yes | Edit and run the remembered script, creating a scratch script when needed. | +| `/script ` | yes | yes | Remember and run an existing JS/TS script. | +| `/login` | yes | no | Start or join pairing for the current product and copy the new link. | +| `/logout` | yes | no | Disconnect and clear the old pairing identity/history. | +| `/pair ` | no | yes | Validate and answer a `polkadotapp://pair?...` link. | +| `/product` | yes | yes | Print the current product id. | +| `/product ` | yes | yes | Switch product and reset product connections. | +| `/session` | no | yes | Show current session, user, and path. | +| `/session ` | no | yes | Switch to or create and provision a session. | +| `/session --list` | no | yes | List network-scoped user sessions and mark the active one. | +| `/log ` | yes | yes | Replace the runtime log filter. | +| `/help` | yes | yes | Show role-specific commands and key bindings. | +| `/clear` | yes | yes | Clear the retained visible transcript. | +| `/copy` | yes | yes | Copy the retained, redacted transcript. TUI only. | +| `/quit` | yes | yes | Leave the command loop. | + +The shared parser recognizes every command, then the active role rejects +commands it cannot execute. `/pair` performs a fast prefix check; the Rust core +then fully decodes and validates the V2 handshake. + +Unknown commands, missing required arguments, invalid log levels, invalid +products, invalid session names, and arguments passed to no-argument commands +produce explicit errors. + +## 9. Terminal UI + +### 9.1 Layout + +Both roles use the same full-screen ratatui/crossterm surface: + +```text +scrollable transcript + +command completion list, when open +› command input or idle placeholder +TrUAPI host · 👤 · 🌐 · 📦 +``` + +The role label is omitted at narrow widths so user, network, and product remain +visible. Values are ellipsized to fit, with the product consuming the remaining +space after the user and network. Session and log level are not shown. Idle +command guidance appears as a dim placeholder inside the empty prompt instead +of consuming status-bar space. Operational hints temporarily use the right side +of the status line while a command, approval, completion menu, or scroll is +active. + +The composer: + +- has one column of horizontal padding when width permits; +- adds vertical padding on terminals at least seven rows high; +- blends a subtle surface color from `COLORFGBG`; +- uses true color when `COLORTERM` is `truecolor` or `24bit`; +- falls back to an ANSI-256 approximation; and +- becomes unstyled when `NO_COLOR` exists. + +### 9.2 Transcript + +The transcript contains: + +- host lifecycle events; +- submitted commands; +- script stdout and stderr; +- approvals; +- SSO request/response summaries; and +- tracing output allowed by the active log filter. + +Submitted commands become bold, full-width divider titles. `/pair` arguments +are rendered as `/pair `. + +Status symbols are: + +| Symbol | Meaning | +| --- | --- | +| `•` | informational | +| `◌` | running | +| `✓` | success | +| `!` | warning | +| `×` | failure | +| `–` | cancelled | + +Running activities are keyed and updated in place. For example, `Script +running` becomes `Script finished` or `Script failed`, and pairing progresses +from link generation through authentication to its final state. + +### 9.3 Input and completion + +- Typing `/` opens role-specific completion. +- Up/Down cycles completion while it is visible. +- Up/Down navigates process-local command history when completion is closed. +- Tab accepts the selected completion. +- Enter first accepts a differing selected completion; a later Enter submits. +- `/script` followed by a space completes filesystem entries. +- `/session` followed by a space completes known signing sessions and `--list`. +- Left/Right, Home/End, Backspace, and Delete edit by Unicode character. +- Long input scrolls horizontally and retains a native terminal cursor. +- Bracketed paste is enabled; pasted control characters are discarded. +- At most eight completion rows are visible. + +Command history is in memory only and disappears when the process exits. + +### 9.4 Scrolling and cancellation + +- Ctrl-U scrolls up by half the transcript viewport. +- Ctrl-D scrolls down by half the viewport. +- End moves the input cursor to the end and resumes latest-output view. +- Esc dismisses completion. +- Ctrl-C clears non-empty input. +- Idle Ctrl-C exits the command loop when input is empty. +- Busy Ctrl-C drops the active operation future. + +Only one operator command runs at once. Input may be prepared while a command +runs, but Enter reports that another command is active. Host events and +approval input continue to be processed during the operation. + +Captured script children use `kill_on_drop`, so cancelling an interactive +script terminates Bun. Pairing-host `/login` additionally calls the core's +pairing cancellation method. + +### 9.5 Approvals in the TUI + +An approval temporarily saves and clears the command draft. The operator can: + +- press `y` or `Y` with an empty input to approve; +- press `n`, `N`, or Esc to reject; or +- type `yes`/`no` and press Enter. + +Invalid typed answers show `Answer yes or no`. The saved draft is restored +after the decision. Approval requests are serialized by the platform prompt +lock. + +### 9.6 Clipboard and redaction + +`/copy` lazily opens the system clipboard and copies plain transcript text +without the full-screen UI. Complete pairing links are replaced by +``. + +Operator `/login` copies the first generated pairing link automatically. A +clipboard failure is reported as a warning and does not cancel pairing. +Product-driven `requestLogin()` does not automatically copy its link. + +### 9.7 Output safety and bounds + +Captured script and log text is sanitized before rendering: + +- CSI escape sequences are removed; +- OSC escape sequences are removed; +- other control characters are removed except newline and tab; and +- individual child-output lines are truncated at 16 KiB. + +Consequently Chalk color, bold, and other ANSI styling are not rendered inside +the TUI. One-shot `--script` inherits stdout and can render ANSI normally. + +The retained transcript is pruned from the oldest item when any limit is +exceeded: + +- 10,000 feed items; +- 10,000 logical lines; or +- 1 MiB of retained plain text. + +Adjacent output is chunked at 256 lines or 64 KiB. + +## 10. Product scripts + +### 10.1 Execution contract + +A product script is a JavaScript or TypeScript ES module executed by Bun. +Before importing it, the runner: + +1. reads its required environment; +2. opens the product-frame WebSocket, with a 15-second connection timeout; +3. creates the public `@parity/truapi` client; +4. injects the script globals; and +5. imports the absolute script URL. + +Top-level module code is awaited. If the module's default export is a function, +the runner calls and awaits it with the host context. + +The provider is disposed on success or failure. + +### 10.2 Injected globals + +```ts +declare const truapi: TrUApiClient; + +declare const host: { + productId: string; + productAccount(index?: number): ProductAccountId; +}; + +declare function assert( + condition: unknown, + ...message: unknown[] +): asserts condition; +``` + +`host.productAccount()` defaults to derivation index `0` and uses the exact +active product id. + +`assert` joins string arguments directly and formats other values with +`node:util.inspect` without color. A false condition throws either the joined +message or `assertion failed`. + +### 10.3 Internal child environment + +The Rust parent sets: + +| Variable | Meaning | +| --- | --- | +| `TRUAPI_FRAME_URL` | Bound product-frame WebSocket URL. | +| `TRUAPI_PRODUCT_ID` | Normalized active product id. | +| `TRUAPI_SCRIPT` | Canonical absolute script path. | +| `TRUAPI_CLI_HOST_ROLE` | `pairing-host` or `signing-host`. | + +These variables are runner internals, not CLI configuration inputs. + +### 10.4 Script status + +- Successful completion exits `0`. +- A thrown error or rejected promise is printed as `[script error] ...` and + exits `1`. +- Failure to open the product socket within 15 seconds exits `2`. +- Failure to locate the runner, canonicalize the script, or spawn Bun is a CLI + error. + +The CLI emits `Script running` before Bun starts and `Script finished` or +`Script failed` afterward. + +One-shot `--script` preserves the child's normal numeric status. Interactive +script failures are displayed and the TUI remains active. `exec '/script +'` reports the child code but returns the CLI's general error status when +the child failed. + +### 10.5 Remembered scripts and editor behavior + +`/script ` resolves a relative path against the CLI process's current +working directory, remembers the resulting absolute path in the current host +session, and runs it. + +A later bare `/script`: + +1. reuses the remembered file when it still exists; +2. otherwise creates a unique `script-