diff --git a/.gitignore b/.gitignore index 9dc237bb..f0359297 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ lerna-debug.log* node_modules target +# Local Bun side effects (repo lockfiles are npm/yarn) +bun.lock +bun.lockb + # Gradle (Android workspace at repo root) /.gradle/ /build/ @@ -56,3 +60,4 @@ js/packages/truapi/dist/generated/ js/packages/truapi-host/src/generated/ js/packages/truapi-host/dist/generated/ js/packages/truapi-host/dist/wasm/ +js/packages/truapi-host-wasm/ diff --git a/CLAUDE.md b/CLAUDE.md index 6a494b06..46d8d44b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ rust/crates/ truapi-macros/ #[wire(id = N)] proc-macro truapi-platform/ Host syscall traits (storage, navigation, consent, ...) truapi-server/ Rust runtime hosts implement; ships as WASM (browser/node) + truapi-host-cli/ Headless pairing-host + signing-host CLIs that pair over the real People-chain statement store; local e2e signing-bot replacement js/packages/ truapi/ @parity/truapi TS package; generated TS lives under ignored paths truapi-host/ @parity/truapi-host: WASM-backed host runtime. Subpath entries: diff --git a/Cargo.lock b/Cargo.lock index f3c480cb..a38a473f 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,194 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ark-bls12-381" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +dependencies = [ + "ahash", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", + "itertools 0.13.0", + "num-bigint", + "num-integer", + "num-traits", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ed-on-bls12-381-bandersnatch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1786b2e3832f6f0f7c8d62d5d5a282f6952a1ab99981c54cd52b6ac1d8f02df5" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-std", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "arrayvec 0.7.6", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ark-poly" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" +dependencies = [ + "ahash", + "ark-ff", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", + "rayon", +] + +[[package]] +name = "ark-scale" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985c81a9c7b23a72f62b7b20686d5326d2a9956806f37de9ee35cb1238faf0c0" +dependencies = [ + "ark-serialize", + "ark-std", + "parity-scale-codec", + "scale-info", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "arrayvec 0.7.6", + "digest 0.10.7", + "num-bigint", + "rayon", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.6", + "rayon", +] + +[[package]] +name = "ark-transcript" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c1c928edb9d8ff24cb5dcb7651d3a98494fff3099eee95c2404cd813a9139f" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "digest 0.10.7", + "rand_core 0.6.4", + "sha3", +] + +[[package]] +name = "ark-vrf" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b9bd02dbd2f282fe742d51f681adb2745a79dc8a025bc8d16cac9b255d55bf7" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ed-on-bls12-381-bandersnatch", + "ark-ff", + "ark-serialize", + "ark-std", + "digest 0.10.7", + "generic-array", + "rayon", + "sha2 0.10.9", + "w3f-ring-proof", + "zeroize", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -317,6 +514,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]] @@ -407,6 +608,19 @@ dependencies = [ "piper", ] +[[package]] +name = "bounded-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee8eddd066a8825ec5570528e6880471210fd5d88cb6abbe1cfdd51ca249c33" +dependencies = [ + "jam-codec", + "log", + "parity-scale-codec", + "scale-info", + "serde", +] + [[package]] name = "bs58" version = "0.5.1" @@ -468,6 +682,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -476,7 +696,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]] @@ -646,6 +877,34 @@ 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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -674,7 +933,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", ] @@ -686,7 +945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -716,7 +975,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", @@ -771,6 +1030,12 @@ dependencies = [ "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" @@ -893,12 +1158,24 @@ dependencies = [ "ed25519", "hashbrown 0.16.1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sha2 0.10.9", "subtle", "zeroize", ] +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.16.0" @@ -918,12 +1195,32 @@ dependencies = [ "generic-array", "group", "hkdf", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", ] +[[package]] +name = "enum-ordinalize" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -985,7 +1282,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", ] @@ -1018,7 +1315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" dependencies = [ "byteorder", - "rand", + "rand 0.8.6", "rustc-hex", "static_assertions", ] @@ -1228,10 +1525,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]] @@ -1240,7 +1540,8 @@ version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea1015b5a70616b688dc230cfe50c8af89d972cb132d5a622814d29773b10b9" dependencies = [ - "rand_core", + "rand 0.8.6", + "rand_core 0.6.4", ] [[package]] @@ -1306,7 +1607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1326,6 +1627,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", "foldhash 0.1.5", ] @@ -1424,12 +1726,94 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +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.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +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.7", +] + +[[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" @@ -1604,12 +1988,27 @@ dependencies = [ "generic-array", ] +[[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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1625,6 +2024,34 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jam-codec" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb948eace373d99de60501a02fb17125d30ac632570de20dccc74370cdd611b9" +dependencies = [ + "arrayvec 0.7.6", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "jam-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "jam-codec-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "319af585c4c8a6b5552a52b7787a1ab3e4d59df7614190b1f85b9b842488789d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "jni" version = "0.21.1" @@ -1782,7 +2209,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -1847,7 +2274,7 @@ dependencies = [ "libsecp256k1-core", "libsecp256k1-gen-ecmult", "libsecp256k1-gen-genmult", - "rand", + "rand 0.8.6", "serde", "sha2 0.9.9", "typenum", @@ -1918,6 +2345,21 @@ dependencies = [ "hashbrown 0.16.1", ] +[[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" @@ -1932,7 +2374,7 @@ checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" dependencies = [ "byteorder", "keccak", - "rand_core", + "rand_core 0.6.4", "zeroize", ] @@ -1969,7 +2411,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" dependencies = [ - "rand", + "rand 0.8.6", ] [[package]] @@ -2133,6 +2575,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -2224,7 +2672,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", ] @@ -2236,7 +2684,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", ] @@ -2329,12 +2777,68 @@ dependencies = [ ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +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 = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ - "unicode-ident", + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", ] [[package]] @@ -2366,7 +2870,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]] @@ -2376,7 +2891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2388,6 +2903,41 @@ 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 = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2397,6 +2947,61 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +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.7", +] + [[package]] name = "ring" version = "0.17.14" @@ -2478,6 +3083,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -2717,7 +3323,7 @@ dependencies = [ "curve25519-dalek", "getrandom_or_panic", "merlin", - "rand_core", + "rand_core 0.6.4", "serde_bytes", "sha2 0.10.9", "subtle", @@ -2840,6 +3446,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" @@ -2860,7 +3478,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", ] @@ -2872,7 +3490,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -2884,7 +3502,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", ] @@ -2987,7 +3605,7 @@ dependencies = [ "bip39", "blake2-rfc", "bs58", - "chacha20", + "chacha20 0.9.1", "crossbeam-queue", "derive_more 2.1.1", "ed25519-zebra", @@ -3000,7 +3618,7 @@ dependencies = [ "hashbrown 0.16.1", "hex", "hmac 0.12.1", - "itertools", + "itertools 0.14.0", "libm", "libsecp256k1", "merlin", @@ -3011,7 +3629,7 @@ dependencies = [ "pbkdf2", "pin-project", "poly1305", - "rand", + "rand 0.8.6", "rand_chacha", "ruzstd", "schnorrkel", @@ -3043,7 +3661,7 @@ dependencies = [ "bip39", "blake2-rfc", "bs58", - "chacha20", + "chacha20 0.9.1", "crossbeam-queue", "derive_more 2.1.1", "ed25519-zebra", @@ -3056,7 +3674,7 @@ dependencies = [ "hashbrown 0.16.1", "hex", "hmac 0.12.1", - "itertools", + "itertools 0.14.0", "libm", "libsecp256k1", "merlin", @@ -3067,7 +3685,7 @@ dependencies = [ "pbkdf2", "pin-project", "poly1305", - "rand", + "rand 0.8.6", "rand_chacha", "ruzstd", "schnorrkel", @@ -3105,12 +3723,12 @@ dependencies = [ "futures-util", "hashbrown 0.16.1", "hex", - "itertools", + "itertools 0.14.0", "log", "lru", "parking_lot", "pin-project", - "rand", + "rand 0.8.6", "rand_chacha", "serde", "serde_json", @@ -3142,7 +3760,7 @@ dependencies = [ "futures", "httparse", "log", - "rand", + "rand 0.8.6", "sha1", ] @@ -3351,6 +3969,7 @@ dependencies = [ "serde_json", "subxt-lightclient", "thiserror 2.0.18", + "tokio-util", "tracing", "url", "wasm-bindgen-futures", @@ -3394,6 +4013,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" @@ -3513,9 +4141,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", @@ -3551,6 +4181,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]] @@ -3597,6 +4244,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" @@ -3626,6 +4318,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]] @@ -3634,9 +4338,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]] @@ -3664,6 +4375,38 @@ dependencies = [ "truapi", ] +[[package]] +name = "truapi-host-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bip39", + "blake2-rfc", + "clap", + "frame-metadata", + "futures", + "futures-util", + "hex", + "parity-scale-codec", + "reqwest", + "rustls", + "scale-info", + "serde", + "serde_json", + "sp-crypto-hashing", + "subxt-rpcs", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "truapi", + "truapi-platform", + "truapi-server", + "verifiable", +] + [[package]] name = "truapi-macros" version = "0.1.0" @@ -3693,9 +4436,11 @@ version = "0.1.0" dependencies = [ "aes-gcm", "async-trait", + "blake2-rfc", "blake2b_simd", "console_error_panic_hook", "derive_more 2.1.1", + "frame-metadata", "futures", "futures-timer", "getrandom 0.2.17", @@ -3722,6 +4467,7 @@ dependencies = [ "truapi-platform", "unicode-normalization", "url", + "verifiable", "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test", @@ -3730,6 +4476,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" @@ -3738,7 +4510,7 @@ checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ "cfg-if", "digest 0.10.7", - "rand", + "rand 0.8.6", "static_assertions", ] @@ -3827,6 +4599,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" @@ -3839,12 +4617,83 @@ 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" +source = "git+https://github.com/paritytech/verifiable?rev=f65b39df04f2f9a453d78550438b189c96785285#f65b39df04f2f9a453d78550438b189c96785285" +dependencies = [ + "ark-scale", + "ark-serialize", + "ark-vrf", + "bounded-collections", + "parity-scale-codec", + "scale-info", + "sha2 0.10.9", + "smallvec", + "spin", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "w3f-pcs" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ea1046a1deb6d26c34ba2d1f1bab4222d695d126502ee765f80b021753cb674" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "merlin", + "rayon", +] + +[[package]] +name = "w3f-plonk-common" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30408cda37b81bd7257319942584c794c5784d00d749757bc664656749a1472a" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "getrandom_or_panic", + "rand_core 0.6.4", + "rayon", + "w3f-pcs", +] + +[[package]] +name = "w3f-ring-proof" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cbfc4cb881a934e6f33c25927bf955d0cb18e52b94528bbc5fa28dddedb4cd1" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "ark-transcript", + "rayon", + "w3f-pcs", + "w3f-plonk-common", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -3855,6 +4704,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" @@ -4095,6 +4953,24 @@ 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.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -4392,7 +5268,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 bdef6c07..5a7acccc 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 dev dev-bootstrap dev-link-check e2e-dotli matrix explorer +.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test dev dev-bootstrap dev-link-check e2e-dotli headless install matrix explorer TRUAPI_PKG := js/packages/truapi PLAYGROUND := playground @@ -31,6 +31,8 @@ export VITE_NETWORKS # preview by default. Override with `DOTLI_PREVIEW=preview` to test production # preview behavior. DOTLI_PREVIEW ?= preview:debug +TRUAPI_WASM_PROFILE ?= dev +E2E_DOTLI_WASM_PROFILE ?= release help: ## Show this help. @awk 'BEGIN { FS = ":.*##"; printf "Usage: make \n\nTargets:\n" } \ @@ -50,6 +52,13 @@ build: ## Build the Rust workspace and the TypeScript client. cd $(TRUAPI_PKG) && npm run build cd $(HOST_WASM_PKG) && npm run build +headless: ## Build everything the headless-host e2e needs; then run rust/crates/truapi-host-cli/e2e/run.sh. + 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 @@ -105,7 +114,7 @@ dev-bootstrap: ## Prepare ignored generated/build artifacts needed by dotli prev if [ ! -f "$(HOST_CALLBACKS_GENERATED)" ] || [ ! -f "$(HOST_WASM_ADAPTER_GENERATED)" ] || [ ! -f "$(HOST_WASM_WORKER_CALLBACKS_GENERATED)" ]; then ./scripts/codegen.sh; fi cd $(TRUAPI_PKG) && npm run build cd $(HOST_WASM_PKG) && npm run build - TRUAPI_WASM_PROFILE=dev $(MAKE) wasm + TRUAPI_WASM_PROFILE=$(TRUAPI_WASM_PROFILE) $(MAKE) wasm cd $(PLAYGROUND) && yarn install --frozen-lockfile cd $(DOTLI) && bun install --frozen-lockfile $(MAKE) dev-link-check @@ -138,8 +147,8 @@ e2e-dotli: ## Fully automated dotli + playground diagnosis e2e. Requires SIGNER_ 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; \ + 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; \ + TRUAPI_WASM_PROFILE=$(E2E_DOTLI_WASM_PROFILE) $(MAKE) dev-bootstrap; \ 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 a5c38f00..78a44a51 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ rust/crates/ truapi-macros/ #[wire(id = N)] proc-macro truapi-platform/ Host syscall traits used by truapi-server (storage, navigation, consent, ...) truapi-server/ Rust runtime that hosts implement: dispatcher, frames, SCALE, WASM surface + truapi-host-cli/ Headless pairing-host + signing-host CLIs that pair over the real People-chain statement store for local e2e (signing-bot replacement) js/packages/ truapi/ @parity/truapi TypeScript client truapi-host/ @parity/truapi-host: WASM-backed host runtime; entries `.` diff --git a/deny.toml b/deny.toml index 9305a03f..500c13c3 100644 --- a/deny.toml +++ b/deny.toml @@ -9,6 +9,8 @@ allow = [ "BSD-2-Clause", "BSD-3-Clause", "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", "Unicode-3.0", "Unlicense", "Zlib", @@ -18,6 +20,10 @@ confidence-threshold = 0.8 # uniffi is MPL-2.0: file-level weak copyleft, consumed unmodified as a # dependency, which MPL-2.0 permits without affecting the MIT outbound licence. # Scoped per crate so MPL-2.0 stays disallowed everywhere else. +# +# verifiable is GPL-3.0-or-later with the Classpath exception, consumed as an +# unmodified library dependency for ring-VRF proof construction. Keep the +# exception scoped to that crate. exceptions = [ { name = "uniffi", allow = ["MPL-2.0"] }, { name = "uniffi_bindgen", allow = ["MPL-2.0"] }, @@ -27,4 +33,5 @@ exceptions = [ { name = "uniffi_meta", allow = ["MPL-2.0"] }, { name = "uniffi_pipeline", allow = ["MPL-2.0"] }, { name = "uniffi_udl", allow = ["MPL-2.0"] }, + { name = "verifiable", allow = ["GPL-3.0-or-later WITH Classpath-exception-2.0"] }, ] diff --git a/explorer/diagnosis-reports/headless-pairing.md b/explorer/diagnosis-reports/headless-pairing.md new file mode 100644 index 00000000..fb77bf8f --- /dev/null +++ b/explorer/diagnosis-reports/headless-pairing.md @@ -0,0 +1,68 @@ +## Truapi Headless Pairing Host 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` | ⏭️ | | +| `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` | ⏭️ | | +| `Entropy/derive` | ✅ | | +| `Local Storage/read` | ✅ | | +| `Local Storage/write` | ✅ | | +| `Local Storage/clear` | ✅ | | +| `Notifications/send_push_notification` | ✅ | | +| `Notifications/cancel_push_notification` | ✅ | | +| `Payment/balance_subscribe` | ⏭️ | | +| `Payment/top_up` | ⏭️ | | +| `Payment/request` | ⏭️ | | +| `Payment/status_subscribe` | ⏭️ | | +| `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/headless-signing.md b/explorer/diagnosis-reports/headless-signing.md new file mode 100644 index 00000000..36667911 --- /dev/null +++ b/explorer/diagnosis-reports/headless-signing.md @@ -0,0 +1,68 @@ +## Truapi Headless Signing Host 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` | ⏭️ | | +| `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` | ⏭️ | | +| `Entropy/derive` | ✅ | | +| `Local Storage/read` | ✅ | | +| `Local Storage/write` | ✅ | | +| `Local Storage/clear` | ✅ | | +| `Notifications/send_push_notification` | ✅ | | +| `Notifications/cancel_push_notification` | ✅ | | +| `Payment/balance_subscribe` | ⏭️ | | +| `Payment/top_up` | ⏭️ | | +| `Payment/request` | ⏭️ | | +| `Payment/status_subscribe` | ⏭️ | | +| `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/headless.md b/explorer/diagnosis-reports/headless.md new file mode 100644 index 00000000..d3c5564d --- /dev/null +++ b/explorer/diagnosis-reports/headless.md @@ -0,0 +1,68 @@ +## Truapi Headless Pairing Host Diagnosis + +| Method | Status | Details | +| --- | --- | --- | +| `Account/connection_status_subscribe` | ✅ | connection status: Connected | +| `Account/get_account` | ✅ | account retrieved: { "account": { "publicKey": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13" } } other product account retrieved after approval: { "account": { "publicKey": "0x54931f317cc49b2448a7fd1c1bc816b038065eac9c19a8c53fe6cff21419560d" } } | +| `Account/get_account_alias` | ✅ | account alias: { "context": "0x05cc3451e5a525ff21f0a62ffe5e5c184fa4cd790bbaead4ef44ab8dc914ecee", "alias": "0x34cc10180a6fc6977f179b03ae39a261ae5cd44fab26d2086545e00b1b94368c" } | +| `Account/create_account_proof` | ⏭️ | | +| `Account/get_legacy_accounts` | ✅ | legacy accounts: { "accounts": [ { "publicKey": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13", "name": "pgherveou.06" } ] } | +| `Account/get_user_id` | ✅ | user id: { "primaryUsername": "pgherveouu" } | +| `Account/request_login` | ✅ | login completed: AlreadyConnected | +| `Chain/follow_head_subscribe` | ✅ | head follow event: { "tag": "Initialized", "value": { "finalizedBlockHashes": [ "0x24a8038e7cc8a27d6fec2ae9b77b16785a1d1c794c6a365fac0741c352262364", "0x46c922275f522cf267838ffefde7b9a06390e798c306cb84c866e5bde6d55c4c", "0xdc7034d48de1f314b6bff28cd8c20600f45426188720d99bba5a058a63a2cb03", "0x43c1... | +| `Chain/get_head_header` | ✅ | block header: { "header": "0xd7a5dcde73a638f6cf32ddf9742e8ed344f3d74a60238baee743f2afdd6a138d22506200d0ed8a003223ddb768e65599cb3c356a59f42b2cf9c705dab8fff471b7fe013837993da375488a851df8efd0f70c3ea683246f565d680046a0feddecb481c37c1006434d4c53100101010c0661757261202930dc0800000000045250535290018a90... | +| `Chain/get_head_body` | ✅ | block body: { "operation": { "tag": "Started", "value": { "operationId": "0" } } } | +| `Chain/get_head_storage` | ✅ | storage value: { "operation": { "tag": "Started", "value": { "operationId": "0" } } } | +| `Chain/call_head` | ✅ | runtime call result: { "operation": { "tag": "Started", "value": { "operationId": "0" } } } | +| `Chain/unpin_head` | ✅ | blocks unpinned | +| `Chain/continue_head` | ✅ | operation continued | +| `Chain/stop_head_operation` | ✅ | operation stopped | +| `Chain/get_spec_genesis_hash` | ✅ | genesis hash: { "genesisHash": "0xbf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f" } | +| `Chain/get_spec_chain_name` | ✅ | chain name: { "chainName": "Paseo Asset Hub Next" } | +| `Chain/get_spec_properties` | ✅ | chain properties: { "properties": "{\"tokenDecimals\":10,\"tokenSymbol\":\"PAS\"}" } | +| `Chain/broadcast_transaction` | ✅ | transaction broadcast: { "operationId": "kVxLWAWJZIuYd3KM" } | +| `Chain/stop_transaction` | ❌ | stopTransaction failed: { "error": { "tag": "HostFailure", "value": { "reason": "remote_chain_transaction_stop: User error: Invalid operation id (-32602)" } } } | +| `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` | ⏭️ | | +| `Entropy/derive` | ✅ | entropy derived: { "entropy": "0x9ad6f1f6ac64687863a3456a7ddcb06a94c0b9a950930bb8eea3b51743f70baa" } | +| `Local Storage/read` | ✅ | storage value read: | +| `Local Storage/write` | ✅ | storage write succeeded | +| `Local Storage/clear` | ✅ | storage clear succeeded | +| `Notifications/send_push_notification` | ✅ | notification sent: { "id": 1 } | +| `Notifications/cancel_push_notification` | ✅ | notification cancelled | +| `Payment/balance_subscribe` | ⏭️ | | +| `Payment/top_up` | ⏭️ | | +| `Payment/request` | ⏭️ | | +| `Payment/status_subscribe` | ⏭️ | | +| `Permissions/request_device_permission` | ✅ | device permission result: { "granted": true } | +| `Permissions/request_remote_permission` | ✅ | remote permission result: { "granted": true } | +| `Preimage/lookup_subscribe` | ❌ | submit failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "bulletin allowance is not available" } } } } } | +| `Preimage/submit` | ❌ | submit failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "bulletin allowance is not available" } } } } } | +| `Resource Allocation/request` | ✅ | resource allocation result: { "outcomes": [ "Allocated", "NotAvailable" ] } | +| `Signing/create_transaction` | ✅ | transaction created: { "transaction": "0xd9018400f41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef1301ae9c35d29363cd548f24c435c1a71af4971b119f32e376af9dc15e658d29c70e65baa9dd8430ecb7eee566f821ab87aba43d39821174de067478e45dff81c58a00000000000000000000000000000000000000" } | +| `Signing/create_transaction_with_legacy_account` | ✅ | selected legacy account: { "publicKey": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13", "name": "pgherveou.06" } transaction created: { "transaction": "0xd9018400f41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13012430b825adcbb1321b1310144a110809ae758cbc66b94d0... | +| `Signing/sign_raw_with_legacy_account` | ✅ | raw bytes signed: { "signature": "0x3af5d72c406e9279316f5ee707f4c320717ec72a55eb1ab80a04c36708079b3c486fc9102153c0dc5093d2630974302dc1ad6d129fe7024a137836a7331b8980" } | +| `Signing/sign_payload_with_legacy_account` | ✅ | payload signed: { "signature": "0x0061084213f1c30f5776f34ae6a84b14d5b1c3fc6ffca3b531986ae5456be523462d23f3da45e4f8c389c0bd55af3c3099be25da32bce68a8c5d605ccc1a4086" } | +| `Signing/sign_raw` | ✅ | raw bytes signed: { "signature": "0xd0afe46e7df6c2e032c3cb2fd923f96519aee31dda922770beba20c9d09e66389307bb1da61c53ea394e6babee235c852c1cd1a4af1d208153cdc06c767e2686" } | +| `Signing/sign_payload` | ✅ | payload signed: { "signature": "0x1eb6986df46f3347fb3054f8f2864f40eac4f03d43a05b2529a5b8e30fed1b4591da6ea79da89baf056bf4e94071d635ae0c32c5554d899d77cbe7e6a8d73986" } | +| `Statement Store/subscribe` | ✅ | submitting statement: { "expiry": "7661629886880022528n", "topics": [ "0xa962ad4e75e2fc9251c2fb585f98c83a69db2ce0cb99bbb7e0714591bdd9b1a6" ], "proof": { "tag": "Sr25519", "value": { "signature": "0xe6ec7fcffd0322d0bedc33ef99a491d1c6fd5a29b197d337e9bbb984309e544260098b3954cc936628f5deb82c51f5a95cc... | +| `Statement Store/create_proof` | ✅ | proof created: { "proof": { "tag": "Sr25519", "value": { "signature": "0x92b74641cd174206267af9a8bf11d09a20ab0a37924acc2018c386586b666f1e168831f7e126da6a139b7ef0809b2c286c7e89d4d7c5ff1cc800286f1b819486", "signer": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13" } } } | +| `Statement Store/submit` | ✅ | statement submitted | +| `Statement Store/create_proof_authorized` | ✅ | proof created: { "proof": { "tag": "Sr25519", "value": { "signature": "0x0e5ada3138607a38cf64e894cf50ccb2523b0de19a77f8bd7f984c39e6d71f38bc46b2fc7d2215610039f211f31e01bbf78b19d4caf25d7a8b481f2148bd618c", "signer": "0x60c724a9f56cb147403ae2ac1e7b55170ec8c29e127edc72dfb239431b03a379" } } } | +| `System/handshake` | ✅ | handshake succeeded | +| `System/feature_supported` | ✅ | feature supported: true | +| `System/navigate_to` | ✅ | navigation succeeded | +| `Theme/subscribe` | ✅ | theme received: { "name": { "tag": "Default" }, "variant": "Dark" } | diff --git a/hosts/dotli b/hosts/dotli index 07519a4e..df79ee14 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit 07519a4e3c70921d5272cea622ad264e4c91bb5e +Subproject commit df79ee147041a1b0db71e5773a9bc3aa3f8ac179 diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml new file mode 100644 index 00000000..511b45bb --- /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" +async-trait = "0.1" +bip39 = { version = "2", features = ["rand"] } +blake2-rfc = { version = "0.2", default-features = false } +clap = { version = "4", features = ["derive", "env"] } +frame-metadata = { version = "23", default-features = false, features = ["std", "current", "decode"] } +futures = "0.3" +futures-util = "0.3" +hex = "0.4" +parity-scale-codec = { version = "3", features = ["derive"] } +scale-info = { version = "2.11", default-features = false, features = ["decode"] } +sp-crypto-hashing = "0.1" +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" +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 so the pairing host *can* reach real `wss://` chain nodes +# for the `Chain/*` playground methods. Live-chain routing is opt-in +# (`E2E_LIVE_CHAIN=1`); it is off by default because streaming chainHead from a +# live node over the shared product connection is not yet robust and can drop +# that connection mid-run. See src/chain.rs. +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +# Ring-VRF prover for on-chain statement-store allowance registration +# (`set_statement_store_account`). `std` already implies `prover`, but list it +# explicitly since we call the prover-gated `open`/`create`. Native only. +verifiable = { git = "https://github.com/paritytech/verifiable", rev = "f65b39df04f2f9a453d78550438b189c96785285", default-features = false, features = ["std", "prover"] } diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md new file mode 100644 index 00000000..be1f8782 --- /dev/null +++ b/rust/crates/truapi-host-cli/README.md @@ -0,0 +1,162 @@ +# 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. + +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`, the host stays in an interactive +shell until you quit. + +One binary, `truapi-host`: + +| Command | Role | +| --- | --- | +| `pairing-host` | Seedless host: serves product frames, emits pairing deeplinks, and can run product scripts. | +| `signing-host` | Wallet-local host: owns signer identity, can run product scripts, accepts pairing deeplinks, registers statement allowance on-chain, signs. | +| `identity-check` | Probe which derivation of a mnemonic carries a registered username. | +| `alloc-check` | Diagnose (or `--submit`) on-chain statement-store allowance: ring membership, chosen slot, and the `set_statement_store_account` extrinsic. | + +## Quick start + +```bash +make headless install # build deps + install truapi-host (once) +rust/crates/truapi-host-cli/e2e/run.sh # run js/scripts/battery.ts end-to-end +rust/crates/truapi-host-cli/e2e/run.sh path/to/my-script.ts # or a custom script +``` + +`run.sh` starts a pairing host running the product script, hands the emitted +pairing deeplink to a signing host, and exits with the script's status. The +signing host uses `--mnemonic` / `HOST_CLI_SIGNER_MNEMONIC` if set. Otherwise it +auto-selects or creates a stored account under `--base-path` (default +`$XDG_STATE_HOME/truapi-host` or `~/.local/state/truapi-host`), attests it +through the identity backend, waits for ring readiness, and rotates when the +current account exhausts Statement Store slots. Override the product with +`PRODUCT_ID=...` and the pairing frame port with `FRAME=...`. + +## Writing a product script + +A product script is top-level code (an ES module). The runner injects two +globals before running it: + +- **`truapi`** — the `@parity/truapi` client connected to the pairing host and + scoped to the host's `--product-id`. Call `truapi.account.requestLogin(...)`, + `truapi.signing.signRaw(...)`, `truapi.localStorage.write(...)`, etc. +- **`host`** — just `host.productId` and `host.productAccount(index?)`. That is + all it does: it keeps product accounts in sync with the host's `--product-id` + (hardcoding a mismatched id fails signing with `PermissionDenied`). Use + `console.log` and `throw` for everything else. + +Write it top-level and `throw` (or reject) to fail the run: + +```ts +const login = await truapi.account.requestLogin({ reason: undefined }); +if (!login.isOk() || login.value !== "Success") 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`) scopes product-owned APIs like `truapi.localStorage.*` +and the accounts `host.productAccount()` returns. + +Two scripts ship under `js/scripts/`: + +- `battery.ts` — the curated signer gate (login + raw/payload signing, + create-transaction, entropy). This is `run.sh`'s default. +- `diagnosis.ts` — runs the playground's own generated example sources + (`runExample`) and writes a `web.md`-shape report to + `explorer/diagnosis-reports/headless.md`, gating on the signer-critical + methods. The generated examples are baked to the `truapi-playground.dot` + product, so run it with that product id: + + ```bash + PRODUCT_ID=truapi-playground.dot E2E_LIVE_CHAIN=1 \ + rust/crates/truapi-host-cli/e2e/run.sh rust/crates/truapi-host-cli/js/scripts/diagnosis.ts + ``` + + 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 now rotates auto-managed signer accounts when Statement Store + slots are exhausted. + +## Confirmations + +Both hosts take `--auto-accept`. Without it, every confirmation a web/iOS host +would show as a modal (sign requests, permission prompts) is printed on the CLI +and answered `y/n` on stdin. `run.sh` passes `--auto-accept` to both for +unattended runs. + +## 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 port lives in `src/alloc/` (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 PAIRING_DEEPLINK: +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** are implemented natively via the + `verifiable` crate (`get_account_alias`); on wasm they remain `Unavailable`. +- **`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/e2e/run.sh b/rust/crates/truapi-host-cli/e2e/run.sh new file mode 100755 index 00000000..daa82fe6 --- /dev/null +++ b/rust/crates/truapi-host-cli/e2e/run.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Headless end-to-end run: a pairing host drives a product script against a +# signing host, pairing over the real People-chain statement store. +# +# make headless # build once +# e2e/run.sh # runs js/scripts/battery.ts (default) +# e2e/run.sh path/to/script.ts # runs a custom product script +# +# Env: +# PRODUCT_ID product id the pairing host serves (default headless-playground.dot) +# HOST_CLI_SIGNER_MNEMONIC optional wallet mnemonic; when unset, signing-host auto-manages one +# TRUAPI_HOST_BASE_PATH optional root for generated accounts and host state +# FRAME frame-server address (default 127.0.0.1:9955) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +BIN="$ROOT/target/debug/truapi-host" + +# Load HOST_CLI_SIGNER_MNEMONIC / TRUAPI_HOST_BASE_PATH (and any other vars) +# from a gitignored e2e/.env if present. +ENV_FILE="$(dirname "$0")/.env" +[ -f "$ENV_FILE" ] && { set -a; . "$ENV_FILE"; set +a; } + +SCRIPT="${1:-$ROOT/rust/crates/truapi-host-cli/js/scripts/battery.ts}" +PRODUCT_ID="${PRODUCT_ID:-headless-playground.dot}" +FRAME="${FRAME:-127.0.0.1:9955}" + +[ -x "$BIN" ] || { echo "missing $BIN — run: make headless" >&2; exit 2; } + +LOG="$(mktemp)" +SIGNER_PID="" +PAIR_PID="" +stop_pairing_host() { + [ -n "$PAIR_PID" ] || return 0 + pkill -TERM -P "$PAIR_PID" 2>/dev/null || true + kill -TERM "$PAIR_PID" 2>/dev/null || true + sleep 0.5 + pkill -KILL -P "$PAIR_PID" 2>/dev/null || true + kill -KILL "$PAIR_PID" 2>/dev/null || true +} +cleanup() { + [ -n "$SIGNER_PID" ] && kill "$SIGNER_PID" 2>/dev/null || true + stop_pairing_host + rm -f "$LOG" +} +trap cleanup EXIT + +# The pairing host runs the product script; the script's +# `truapi.account.requestLogin` makes the host emit a pairing deeplink, which we +# hand to a signing host. The pairing host exits with the script's status. +"$BIN" pairing-host --product-id "$PRODUCT_ID" --script "$SCRIPT" \ + --frame-listen "$FRAME" --auto-accept > >(tee "$LOG") 2>&1 & +PAIR_PID=$! + +deeplink="" +for _ in $(seq 1 600); do + deeplink="$(grep -m1 -oE 'PAIRING_DEEPLINK .+' "$LOG" | cut -d' ' -f2- || true)" + [ -n "$deeplink" ] && break + kill -0 "$PAIR_PID" 2>/dev/null || break + sleep 0.5 +done +[ -n "$deeplink" ] || { echo "pairing host did not emit a deeplink" >&2; exit 1; } + +# The signing host reads HOST_CLI_SIGNER_MNEMONIC from the env when set. +# Otherwise it auto-selects or creates an attested account under its base path. +"$BIN" signing-host --deeplink "$deeplink" --auto-accept & +SIGNER_PID=$! + +pid_running() { + local stat + stat="$(ps -p "$1" -o stat= 2>/dev/null || true)" + [ -n "$stat" ] && [ "${stat#Z}" = "$stat" ] +} + +while :; do + if ! pid_running "$PAIR_PID"; then + wait "$PAIR_PID" + exit $? + fi + if ! pid_running "$SIGNER_PID"; then + stop_pairing_host + exit 1 + fi + sleep 0.5 +done diff --git a/rust/crates/truapi-host-cli/js/diagnosis.ts b/rust/crates/truapi-host-cli/js/diagnosis.ts new file mode 100644 index 00000000..b216cadd --- /dev/null +++ b/rust/crates/truapi-host-cli/js/diagnosis.ts @@ -0,0 +1,97 @@ +// Runs the playground's own generated example sources against a headless +// pairing host, using the playground's `runExample` so these are literally the +// tests the playground diagnosis runs. Pass/fail is decided by the example +// body (it resolves on success, throws via `assert` on failure), exactly as in +// `playground/src/lib/auto-test.ts`. +import { + runExample, + type LogEntry, +} from "../../../../playground/src/lib/example-runner.ts"; +import { services } from "../../../../js/packages/truapi/src/playground/codegen/services.ts"; +import type { TrUApiClient } from "../../../../js/packages/truapi/src/index.ts"; + +// Mirrors auto-test.ts. +const UNARY_TIMEOUT_MS = 10_000; +const SIGNING_TIMEOUT_MS = 30_000; +const SSO_TIMEOUT_MS = 180_000; +const SKIPPED_SERVICES = new Set(["Chat", "Coin Payment", "Payment"]); +const SKIPPED_METHODS = new Set(["Account/create_account_proof"]); +const LONG_TIMEOUT_METHODS = new Set([ + "Account/get_account_alias", + "Resource Allocation/request", + "Signing/sign_payload", + "Signing/sign_raw", + "Signing/sign_raw_with_legacy_account", + "Signing/sign_payload_with_legacy_account", + "Signing/create_transaction", + "Signing/create_transaction_with_legacy_account", + "Preimage/submit", +]); +const METHOD_TIMEOUT_MS = new Map([ + ["Account/get_account_alias", SSO_TIMEOUT_MS], + ["Resource Allocation/request", SSO_TIMEOUT_MS], + ["Preimage/lookup_subscribe", SSO_TIMEOUT_MS], + ["Preimage/submit", SSO_TIMEOUT_MS], + ["Signing/create_transaction", SSO_TIMEOUT_MS], +]); + +export type DiagnosisStatus = "pass" | "fail" | "skipped"; +export interface DiagnosisRow { + id: string; + status: DiagnosisStatus; + output: string; +} + +async function runOne( + client: TrUApiClient, + serviceName: string, + method: { name: string; exampleSource?: string }, +): Promise { + const id = `${serviceName}/${method.name}`; + if (SKIPPED_SERVICES.has(serviceName) || SKIPPED_METHODS.has(id)) { + return { id, status: "skipped", output: "" }; + } + if (!method.exampleSource) { + return { id, status: "fail", output: "no runnable example" }; + } + const timeoutMs = + METHOD_TIMEOUT_MS.get(id) ?? + (LONG_TIMEOUT_METHODS.has(id) ? SIGNING_TIMEOUT_MS : UNARY_TIMEOUT_MS); + + const logs: LogEntry[] = []; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs / 1000}s`)), timeoutMs); + }); + let run: Awaited> | undefined; + try { + run = await Promise.race([ + runExample({ source: method.exampleSource, client, onLog: (e) => logs.push(e) }), + timeout, + ]); + await Promise.race([run.promise, timeout]); + return { id, status: "pass", output: joinLogs(logs) ?? "ok" }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const log = joinLogs(logs); + return { id, status: "fail", output: log ? `${log}\n${message}` : message }; + } finally { + if (timer !== undefined) clearTimeout(timer); + run?.cancel(); + } +} + +function joinLogs(logs: LogEntry[]): string | undefined { + return logs.length === 0 ? undefined : logs.map((l) => l.text).join("\n"); +} + +/** Run every generated example sequentially, like the playground diagnosis. */ +export async function runDiagnosis(client: TrUApiClient): Promise { + const rows: DiagnosisRow[] = []; + for (const service of services) { + for (const method of service.methods) { + rows.push(await runOne(client, service.name, method)); + } + } + return rows; +} diff --git a/rust/crates/truapi-host-cli/js/runner.ts b/rust/crates/truapi-host-cli/js/runner.ts new file mode 100644 index 00000000..4bcce35a --- /dev/null +++ b/rust/crates/truapi-host-cli/js/runner.ts @@ -0,0 +1,88 @@ +// Host-script runner: the Rust CLI spawns this to drive a headless host from a +// user-provided JavaScript/TypeScript file. +// +// The pairing host serves the product frame protocol on a WebSocket; this +// runner connects the real `@parity/truapi` client to it, injects it as the +// global `truapi` (scoped to the host's product id), and evaluates the user +// script. The script is the product: it calls `truapi.account.requestLogin()`, +// `truapi.signing.*`, `truapi.localStorage.*`, etc. A thrown error or rejected +// promise exits non-zero, so `truapi-host pairing-host --script …` is the test. +// +// Env (set by the Rust CLI): +// TRUAPI_FRAME_URL ws:// URL of the pairing host's frame server +// TRUAPI_PRODUCT_ID product id the host serves (scopes storage etc.) +// TRUAPI_SCRIPT absolute path to the user script +import { pathToFileURL } from "node:url"; +import { + createClient, + createTransport, + type ProductAccountId, + type TrUApiClient, +} from "../../../../js/packages/truapi/src/index.ts"; +import { wsProvider } from "./ws-provider.ts"; + +/// The host context injected alongside `truapi`. It only exposes what a script +/// can't get from `truapi` alone: the product id the host serves, so product +/// accounts stay in sync with `--product-id` (hardcoding a mismatched id fails +/// signing with `PermissionDenied`). Use `console.log` / `throw` for the rest. +export interface HostContext { + /** The product id this host serves (its `--product-id`). */ + productId: string; + /** A product account id for `derivationIndex` (default 0) under this product. */ + productAccount(index?: number): ProductAccountId; +} + +declare global { + // eslint-disable-next-line no-var + var truapi: TrUApiClient; + // eslint-disable-next-line no-var + var host: HostContext; +} + +const OPEN_TIMEOUT_MS = 15_000; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} must be set`); + return value; +} + +async function main() { + const frameUrl = requireEnv("TRUAPI_FRAME_URL"); + const productId = requireEnv("TRUAPI_PRODUCT_ID"); + const scriptPath = requireEnv("TRUAPI_SCRIPT"); + + const provider = wsProvider(frameUrl); + const client = createClient(createTransport(provider)); + + const context: HostContext = { + productId, + productAccount: (index = 0) => ({ dotNsIdentifier: productId, derivationIndex: index }), + }; + globalThis.truapi = client; + globalThis.host = context; + + const timer = setTimeout(() => { + console.error(`[runner] timed out connecting to ${frameUrl}`); + process.exit(2); + }, OPEN_TIMEOUT_MS); + await provider.opened; + clearTimeout(timer); + + try { + const module = await import(pathToFileURL(scriptPath).href); + if (typeof module.default === "function") { + await module.default(context); + } + } finally { + provider.dispose(); + } +} + +main().then( + () => process.exit(0), + (error) => { + console.error(`[script error] ${error instanceof Error ? error.stack : String(error)}`); + process.exit(1); + }, +); diff --git a/rust/crates/truapi-host-cli/js/scripts/battery.ts b/rust/crates/truapi-host-cli/js/scripts/battery.ts new file mode 100644 index 00000000..97d00903 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/battery.ts @@ -0,0 +1,145 @@ +/// +// Curated signer battery, as a product script for the pairing host. +// +// Run via: truapi-host pairing-host --product-id

--script js/scripts/battery.ts +// The runner injects `truapi` (the @parity/truapi client, scoped to the product) +// and `host` (`productId`, `productAccount(i)`). This is top-level product code: +// it logs in with the paired signing host, exercises the signer-backed methods +// the playground diagnosis covers, and throws on any failure so the host command +// exits non-zero. + +export {}; // module marker so top-level `await` is allowed + +const GENESIS_HASH = `0x${"11".repeat(32)}` as const; +const account = host.productAccount(); + +interface Case { + name: string; + ok: boolean; + detail: string; +} + +const results: Case[] = []; + +async function record(name: string, fn: () => Promise<{ ok: boolean; detail: string }>) { + try { + results.push({ name, ...(await fn()) }); + } catch (error) { + results.push({ name, ok: false, detail: `threw: ${String(error)}` }); + } +} + +const login = await truapi.account.requestLogin({ reason: undefined }); +results.push({ + name: "account.requestLogin", + ok: login.isOk() && login.value === "Success", + detail: login.isOk() ? String(login.value) : JSON.stringify(login.error), +}); +if (!(login.isOk() && login.value === "Success")) { + report(); + throw new Error("login did not succeed"); +} + +await record("account.getAccount", async () => { + const result = await truapi.account.getAccount({ productAccountId: account }); + return result.match( + (value) => ({ + ok: value.account.publicKey.startsWith("0x") && value.account.publicKey.length > 4, + detail: value.account.publicKey.slice(0, 18), + }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); + +await record("signing.signRaw(bytes)", async () => { + const result = await truapi.signing.signRaw({ + account, + payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, + }); + return result.match( + (value) => ({ + ok: value.signature.length === 130 || value.signature.length === 132, + detail: value.signature.slice(0, 18), + }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); + +await record("signing.signRaw(message)", async () => { + const result = await truapi.signing.signRaw({ + account, + payload: { tag: "Payload", value: { payload: "hello from the headless battery" } }, + }); + return result.match( + (value) => ({ ok: value.signature.startsWith("0x"), detail: value.signature.slice(0, 18) }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); + +await record("signing.signPayload", async () => { + const result = await truapi.signing.signPayload({ + account, + payload: { + blockHash: GENESIS_HASH, + blockNumber: "0x01", + era: "0x00", + genesisHash: GENESIS_HASH, + method: "0x0400", + nonce: "0x00", + specVersion: "0x01000000", + tip: "0x00", + transactionVersion: "0x01000000", + signedExtensions: [], + version: 4, + assetId: undefined, + metadataHash: undefined, + mode: undefined, + withSignedTransaction: undefined, + }, + }); + return result.match( + (value) => ({ ok: value.signature.startsWith("0x"), detail: value.signature.slice(0, 18) }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); + +await record("signing.createTransaction", async () => { + const result = await truapi.signing.createTransaction({ + signer: account, + genesisHash: GENESIS_HASH, + callData: "0x0000", + extensions: [{ id: "CheckNonce", extra: "0x04", additionalSigned: "0x" }], + txExtVersion: 0, + }); + return result.match( + (value) => ({ + ok: value.transaction.startsWith("0x") && value.transaction.length > 4, + detail: `${value.transaction.length} chars`, + }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); + +await record("entropy.derive", async () => { + const result = await truapi.entropy.derive({ context: "0x6d792d6b6579" }); + return result.match( + (value) => ({ ok: value.entropy.startsWith("0x"), detail: value.entropy.slice(0, 18) }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); + +report(); +const failures = results.filter((r) => !r.ok); +if (failures.length > 0) { + throw new Error(`GATE FAILED: ${failures.map((r) => r.name).join(", ")}`); +} +console.log(`GATE PASSED: ${results.length} signer-critical cases`); + +function report() { + console.log("\n=== Headless host signer battery ==="); + for (const r of results) { + console.log(`${r.ok ? "PASS" : "FAIL"} ${r.name.padEnd(28)} ${r.detail}`); + } + const pass = results.filter((r) => r.ok).length; + console.log(`--------------------------------\n${pass}/${results.length} passed`); +} diff --git a/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts b/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts new file mode 100644 index 00000000..a31fa390 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts @@ -0,0 +1,82 @@ +/// +// Full playground diagnosis, as a product script for the pairing host. +// +// Run via: truapi-host pairing-host --product-id truapi-playground.dot --script js/scripts/diagnosis.ts +// The generated example sources hardcode the `truapi-playground.dot` product, so +// the pairing host must serve that product id (else signing methods fail with +// PermissionDenied). Top-level product code: logs in, runs the examples against +// the paired signing host, writes a web.md-shape report to +// explorer/diagnosis-reports/headless.md, and gates on the signer-critical +// methods (chain-node methods and deferred features are reported, not gated, +// unless a live chain node is routed in). +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runDiagnosis, type DiagnosisRow } from "../diagnosis.ts"; + +// Signer-critical, chain-node-independent methods that must pass. +const MUST_PASS = new Set([ + "Account/request_login", + "Account/connection_status_subscribe", + "Account/get_account", + "Account/get_legacy_accounts", + "Signing/sign_raw", + "Signing/sign_payload", + "Signing/sign_raw_with_legacy_account", + "Signing/sign_payload_with_legacy_account", + "Resource Allocation/request", + "Statement Store/create_proof", + "Statement Store/create_proof_authorized", + "Statement Store/submit", + "Entropy/derive", +]); + +const DEFAULT_REPORT_PATH = fileURLToPath( + new URL("../../../../../explorer/diagnosis-reports/headless.md", import.meta.url), +); +const REPORT_PATH = process.env.TRUAPI_DIAGNOSIS_REPORT_PATH || DEFAULT_REPORT_PATH; +const REPORT_TITLE = process.env.TRUAPI_DIAGNOSIS_TITLE || "Truapi Headless Pairing Host Diagnosis"; + +const login = await truapi.account.requestLogin({ reason: undefined }); +if (!login.isOk() || !["Success", "AlreadyConnected"].includes(String(login.value))) { + throw new Error(`login failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`); +} + +const rows = await runDiagnosis(truapi); +mkdirSync(dirname(REPORT_PATH), { recursive: true }); +writeFileSync(REPORT_PATH, renderReport(rows)); + +console.log("\n" + renderReport(rows)); +const pass = rows.filter((r) => r.status === "pass").length; +const fail = rows.filter((r) => r.status === "fail").length; +const skip = rows.filter((r) => r.status === "skipped").length; +console.log(`\nwrote ${REPORT_PATH}`); +console.log(`${pass} passed, ${fail} failed, ${skip} skipped (of ${rows.length})`); + +const critical = rows.filter((r) => MUST_PASS.has(r.id) && r.status === "fail"); +if (critical.length > 0) { + throw new Error(`GATE FAILED: ${critical.map((r) => r.id).join(", ")}`); +} +console.log("GATE PASSED: all signer-critical methods pass"); + +// web.md-shape table so the headless run can be diffed against the browser host. +function renderReport(rows: DiagnosisRow[]): string { + const icon = (s: DiagnosisRow["status"]) => + s === "pass" ? "✅" : s === "skipped" ? "⏭️" : "❌"; + return ( + [ + `## ${REPORT_TITLE}`, + "", + "| Method | Status | Details |", + "| --- | --- | --- |", + ...rows.map( + (r) => `| \`${r.id}\` | ${icon(r.status)} | ${r.status === "fail" ? cleanDetail(r.output) : ""} |`, + ), + ].join("\n") + "\n" + ); +} + +function cleanDetail(output: string): string { + const collapsed = output.replace(/\s+/g, " ").trim(); + return collapsed.length > 300 ? collapsed.slice(0, 297) + "..." : collapsed; +} diff --git a/rust/crates/truapi-host-cli/js/scripts/preimage-smoke.ts b/rust/crates/truapi-host-cli/js/scripts/preimage-smoke.ts new file mode 100644 index 00000000..7ad5c39a --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/preimage-smoke.ts @@ -0,0 +1,31 @@ +/// +// Focused live-chain preimage smoke for debugging Bulletin submission. + +const login = await truapi.account.requestLogin({ reason: undefined }); +if (!login.isOk() || !["Success", "AlreadyConnected"].includes(String(login.value))) { + throw new Error(`requestLogin failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`); +} + +const bytes = crypto.getRandomValues(new Uint8Array(4)); +const value = `0x${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}` as `0x${string}`; +console.log(`PREIMAGE_VALUE ${value}`); + +const submitted = await truapi.preimage.submit(value); +console.log(`PREIMAGE_SUBMIT ${JSON.stringify(submitted)}`); +if (!submitted.isOk()) { + throw new Error(`preimage submit failed: ${JSON.stringify(submitted.error)}`); +} + +const item = await new Promise((resolve, reject) => { + let sub: { unsubscribe: () => void } | undefined; + sub = truapi.preimage.lookupSubscribe({ request: { key: submitted.value } }).subscribe({ + next(value: unknown) { + sub?.unsubscribe(); + resolve(value); + }, + error(error: unknown) { + reject(error); + }, + }); +}); +console.log(`PREIMAGE_LOOKUP ${JSON.stringify(item)}`); diff --git a/rust/crates/truapi-host-cli/js/scripts/signing-smoke.ts b/rust/crates/truapi-host-cli/js/scripts/signing-smoke.ts new file mode 100644 index 00000000..a8a89b04 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/signing-smoke.ts @@ -0,0 +1,29 @@ +/// +export {}; + +const login = await truapi.account.requestLogin({ reason: undefined }); +if (!login.isOk() || login.value !== "AlreadyConnected") { + throw new Error(`requestLogin failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`); +} + +const account = host.productAccount(); +const accountResult = await truapi.account.getAccount({ productAccountId: account }); +accountResult.match( + (value) => console.log(`ACCOUNT ${value.account.publicKey.slice(0, 18)}`), + (error) => { + throw new Error(`getAccount failed: ${JSON.stringify(error)}`); + }, +); + +const signatureResult = await truapi.signing.signRaw({ + account, + payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, +}); +signatureResult.match( + (value) => console.log(`SIGNATURE ${value.signature.slice(0, 18)}`), + (error) => { + throw new Error(`signRaw failed: ${JSON.stringify(error)}`); + }, +); + +console.log("SIGNING_SMOKE_OK"); diff --git a/rust/crates/truapi-host-cli/js/ws-provider.ts b/rust/crates/truapi-host-cli/js/ws-provider.ts new file mode 100644 index 00000000..7cd7a5e4 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/ws-provider.ts @@ -0,0 +1,57 @@ +// WebSocket `WireProvider` for @parity/truapi: one binary WS message per +// SCALE protocol frame, sends buffered until the socket opens. +import type { WireProvider } from "../../../../js/packages/truapi/src/index.ts"; + +export function wsProvider(url: string): WireProvider & { opened: Promise } { + const ws = new WebSocket(url); + ws.binaryType = "arraybuffer"; + const listeners = new Set<(message: Uint8Array) => void>(); + const closeListeners = new Set<(error: Error) => void>(); + const pending: Uint8Array[] = []; + let open = false; + + let resolveOpened: () => void; + let rejectOpened: (error: Error) => void; + const opened = new Promise((resolve, reject) => { + resolveOpened = resolve; + rejectOpened = reject; + }); + + ws.addEventListener("open", () => { + open = true; + for (const frame of pending.splice(0)) ws.send(frame); + resolveOpened(); + }); + ws.addEventListener("message", (event) => { + const bytes = new Uint8Array(event.data as ArrayBuffer); + for (const listener of listeners) listener(bytes); + }); + ws.addEventListener("close", () => { + const error = new Error("websocket closed"); + for (const listener of closeListeners) listener(error); + }); + ws.addEventListener("error", () => { + const error = new Error("websocket error"); + rejectOpened(error); + for (const listener of closeListeners) listener(error); + }); + + return { + opened, + postMessage(message: Uint8Array) { + if (open) ws.send(message); + else pending.push(message); + }, + subscribe(cb: (message: Uint8Array) => void) { + listeners.add(cb); + return () => listeners.delete(cb); + }, + subscribeClose(cb: (error: Error) => void) { + closeListeners.add(cb); + return () => closeListeners.delete(cb); + }, + dispose() { + ws.close(); + }, + }; +} diff --git a/rust/crates/truapi-host-cli/src/accounts.rs b/rust/crates/truapi-host-cli/src/accounts.rs new file mode 100644 index 00000000..5907749b --- /dev/null +++ b/rust/crates/truapi-host-cli/src/accounts.rs @@ -0,0 +1,417 @@ +use std::collections::BTreeSet; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; +use bip39::Mnemonic; +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; +use truapi_server::host_logic::product_account::{ + derive_sr25519_hard_path, product_public_key_to_address, +}; + +use crate::alloc; +use crate::attestation; +use crate::network::NetworkConfig; + +const ACCOUNT_STORE_FILE: &str = "accounts.json"; +const DEFAULT_USERNAME_PREFIX: &str = "headless"; + +#[derive(Debug, Clone)] +pub struct ResolvedSigner { + pub entropy: Vec, + pub account_name: Option, + pub lite_username: Option, + pub auto_managed: bool, +} + +#[derive(Debug, Clone)] +pub struct ResolveSignerConfig<'a> { + pub base_path: &'a Path, + pub network: NetworkConfig, + pub mnemonic: Option, + pub account: Option, + pub lite_username_prefix: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountRecord { + pub name: String, + pub network: String, + pub mnemonic: String, + pub lite_username: String, + pub public_key_hex: String, + pub address: String, + pub created_at_unix: u64, + #[serde(default)] + pub attested: bool, + #[serde(default)] + exhausted_statement_periods: BTreeSet, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct AccountStoreData { + version: u32, + accounts: Vec, +} + +pub struct AccountStore { + path: PathBuf, + data: AccountStoreData, +} + +impl AccountStore { + pub fn load(base_path: &Path) -> Result { + let path = base_path.join(ACCOUNT_STORE_FILE); + let data = match fs::read_to_string(&path) { + Ok(text) => { + serde_json::from_str(&text).with_context(|| format!("decode {}", path.display()))? + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => AccountStoreData { + version: 1, + accounts: Vec::new(), + }, + Err(err) => return Err(err).with_context(|| format!("read {}", path.display())), + }; + Ok(Self { path, data }) + } + + pub fn save(&self) -> Result<()> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let text = serde_json::to_string_pretty(&self.data)?; + write_secret_file(&self.path, text.as_bytes()) + .with_context(|| format!("write {}", self.path.display())) + } + + pub fn get(&self, network_id: &str, name: &str) -> Option<&AccountRecord> { + self.data + .accounts + .iter() + .find(|record| record.network == network_id && record.name == name) + } + + fn upsert(&mut self, record: AccountRecord) { + if let Some(existing) = self + .data + .accounts + .iter_mut() + .find(|existing| existing.network == record.network && existing.name == record.name) + { + *existing = record; + return; + } + self.data.accounts.push(record); + } + + fn update_attested(&mut self, network_id: &str, name: &str) { + if let Some(record) = self + .data + .accounts + .iter_mut() + .find(|record| record.network == network_id && record.name == name) + { + record.attested = true; + } + } + + fn auto_candidate(&self, network_id: &str, period: u32) -> Option { + self.data + .accounts + .iter() + .find(|record| { + record.network == network_id + && record.attested + && !record.exhausted_statement_periods.contains(&period) + }) + .cloned() + } + + fn next_auto_name(&self, network_id: &str) -> String { + let mut index = 1usize; + loop { + let name = format!("auto-{index}"); + if !self + .data + .accounts + .iter() + .any(|record| record.network == network_id && record.name == name) + { + return name; + } + index += 1; + } + } + + pub fn mark_exhausted(&mut self, network_id: &str, name: &str, period: u32) -> Result<()> { + let Some(record) = self + .data + .accounts + .iter_mut() + .find(|record| record.network == network_id && record.name == name) + else { + return Ok(()); + }; + record.exhausted_statement_periods.insert(period); + self.save() + } +} + +pub async fn resolve_signer(config: ResolveSignerConfig<'_>) -> Result { + if let Some(mnemonic) = config.mnemonic { + let entropy = mnemonic_entropy(&mnemonic)?; + return Ok(ResolvedSigner { + entropy, + account_name: None, + lite_username: None, + auto_managed: false, + }); + } + + let mut store = AccountStore::load(config.base_path)?; + if let Some(name) = config.account { + let record = store + .get(config.network.id, &name) + .cloned() + .with_context(|| format!("account {name:?} not found for {}", config.network.id))?; + ensure_record_ready(&mut store, config.network, &record).await?; + return resolved_from_record(record, false); + } + + let period = current_statement_period()?; + if let Some(record) = store.auto_candidate(config.network.id, period) { + ensure_record_ready(&mut store, config.network, &record).await?; + return resolved_from_record(record, true); + } + + let record = create_auto_account( + &mut store, + config.network, + config + .lite_username_prefix + .as_deref() + .unwrap_or(DEFAULT_USERNAME_PREFIX), + ) + .await?; + resolved_from_record(record, true) +} + +pub fn mark_account_exhausted( + base_path: &Path, + network_id: &str, + name: &str, + period: u32, +) -> Result<()> { + AccountStore::load(base_path)?.mark_exhausted(network_id, name, period) +} + +pub fn current_statement_period() -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock before UNIX epoch")? + .as_secs(); + Ok(alloc::slot::current_period(now)) +} + +async fn create_auto_account( + store: &mut AccountStore, + network: NetworkConfig, + username_prefix: &str, +) -> Result { + validate_username_prefix(username_prefix)?; + let name = store.next_auto_name(network.id); + let mnemonic = Mnemonic::generate(12) + .context("generate BIP-39 mnemonic")? + .to_string(); + let identity = identity_from_mnemonic(&mnemonic)?; + + for attempt in 0..8 { + let lite_username = generated_username(username_prefix, attempt); + if !attestation::lite_username_available(network.identity_backend_base, &lite_username) + .await + .with_context(|| format!("check lite username {lite_username:?} availability"))? + { + continue; + } + + let mut record = AccountRecord { + name: name.clone(), + network: network.id.to_string(), + mnemonic: mnemonic.clone(), + lite_username, + public_key_hex: format!("0x{}", hex::encode(identity.public_key)), + address: identity.address.clone(), + created_at_unix: now_unix(), + attested: false, + exhausted_statement_periods: BTreeSet::new(), + }; + store.upsert(record.clone()); + store.save()?; + + info!( + account = %record.name, + network = %record.network, + lite_username = %record.lite_username, + address = %record.address, + "created auto signer account" + ); + + attest_record(network, &record).await?; + wait_for_ring_membership(network.people_ws, &identity.entropy).await?; + record.attested = true; + store.upsert(record.clone()); + store.save()?; + return Ok(record); + } + + bail!("could not find an available lite username for prefix {username_prefix:?}"); +} + +async fn ensure_record_ready( + store: &mut AccountStore, + network: NetworkConfig, + record: &AccountRecord, +) -> Result<()> { + let identity = identity_from_mnemonic(&record.mnemonic)?; + if !record.attested { + attest_record(network, record).await?; + store.update_attested(network.id, &record.name); + store.save()?; + } + wait_for_ring_membership(network.people_ws, &identity.entropy).await +} + +async fn attest_record(network: NetworkConfig, record: &AccountRecord) -> Result<()> { + let entropy = mnemonic_entropy(&record.mnemonic)?; + let registered = attestation::attest(&attestation::AttestConfig { + backend_base: network.identity_backend_base.to_string(), + people_ws: network.people_ws.to_string(), + entropy, + username_base: record.lite_username.clone(), + }) + .await + .with_context(|| format!("attest account {}", record.name))?; + info!( + account = %record.name, + lite_username = %record.lite_username, + registered, + "signer account attested" + ); + Ok(()) +} + +async fn wait_for_ring_membership(people_ws: &str, entropy: &[u8]) -> Result<()> { + const MAX_ATTEMPTS: usize = 90; + const SLEEP: Duration = Duration::from_secs(4); + + let bandersnatch = alloc::bandersnatch_entropy(entropy); + for attempt in 1..=MAX_ATTEMPTS { + let rpc = alloc::rpc::RpcClient::connect(people_ws).await?; + let metadata = alloc::fetch_metadata(&rpc) + .await + .map_err(anyhow::Error::msg)?; + let current = alloc::ring::read_current_ring_index(&rpc) + .await + .map_err(anyhow::Error::msg)?; + if alloc::find_including_ring(&rpc, &metadata, bandersnatch, current) + .await + .map_err(anyhow::Error::msg)? + .is_some() + { + return Ok(()); + } + if attempt < MAX_ATTEMPTS { + warn!( + attempt, + max_attempts = MAX_ATTEMPTS, + "signer account not in a LitePeople ring yet" + ); + tokio::time::sleep(SLEEP).await; + } + } + bail!("signer account did not appear in a LitePeople ring"); +} + +fn resolved_from_record(record: AccountRecord, auto_managed: bool) -> Result { + let entropy = mnemonic_entropy(&record.mnemonic)?; + Ok(ResolvedSigner { + entropy, + account_name: Some(record.name), + lite_username: Some(record.lite_username), + auto_managed, + }) +} + +struct SignerIdentity { + entropy: Vec, + public_key: [u8; 32], + address: String, +} + +fn identity_from_mnemonic(mnemonic: &str) -> Result { + let entropy = mnemonic_entropy(mnemonic)?; + let candidate = derive_sr25519_hard_path(&entropy, &["wallet", "sso"]) + .map_err(|err| anyhow::anyhow!("//wallet//sso derivation failed: {err}"))?; + let public_key = candidate.public.to_bytes(); + Ok(SignerIdentity { + entropy, + public_key, + address: product_public_key_to_address(public_key), + }) +} + +fn mnemonic_entropy(mnemonic: &str) -> Result> { + Ok(Mnemonic::parse(mnemonic.trim()) + .context("invalid BIP-39 mnemonic")? + .to_entropy()) +} + +fn validate_username_prefix(prefix: &str) -> Result<()> { + if prefix.is_empty() || !prefix.bytes().all(|byte| byte.is_ascii_lowercase()) { + bail!("--lite-username-prefix must contain lowercase ASCII letters only"); + } + Ok(()) +} + +fn generated_username(prefix: &str, attempt: usize) -> String { + let mut username = prefix.to_string(); + let mut seed = now_unix() + ^ u64::from(std::process::id()) + ^ ((attempt as u64) << 32) + ^ (prefix.len() as u64); + while username.len() < prefix.len().max(6) + 6 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + username.push((b'a' + (seed % 26) as u8) as char); + } + username +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn write_secret_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(path)?; + file.write_all(bytes)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + file.sync_all() + } + #[cfg(not(unix))] + { + fs::write(path, bytes) + } +} diff --git a/rust/crates/truapi-host-cli/src/alloc.rs b/rust/crates/truapi-host-cli/src/alloc.rs new file mode 100644 index 00000000..30b54c9c --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc.rs @@ -0,0 +1,204 @@ +//! On-chain statement-store allowance registration (`set_statement_store_account`). +//! +//! Mirrors how an iOS/web client obtains statement-store allowance from the real +//! People chain: build the `Resources.set_statement_store_account` call, prove +//! LitePeople ring membership with a bandersnatch ring-VRF, and submit the +//! resulting unsigned General (v5) extrinsic. Native only (needs the +//! `verifiable` prover and live chain reads). + +pub mod dynamic; +pub mod extension; +pub mod extrinsic; +pub mod proof; +pub mod ring; +pub mod rpc; +pub mod slot; + +use blake2_rfc::blake2b::blake2b; +use parity_scale_codec::Decode; +use serde_json::{Value, json}; + +use extension::{ChainState, Metadata}; +use ring::RingParams; +use rpc::RpcClient; +use slot::SlotSelection; + +/// Bandersnatch entropy for a bip39 entropy: `blake2b256(bip39_entropy)`. +pub fn bandersnatch_entropy(bip39_entropy: &[u8]) -> [u8; 32] { + blake2b(32, &[], bip39_entropy) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +/// Fetch and decode the runtime metadata (`state_getMetadata`). +pub async fn fetch_metadata(rpc: &RpcClient) -> Result { + let value = rpc + .call("state_getMetadata", json!([])) + .await + .map_err(|e| e.to_string())?; + let hex_str = value + .as_str() + .ok_or_else(|| "state_getMetadata returned non-string".to_string())?; + let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(hex_str)) + .map_err(|e| format!("metadata hex: {e}"))?; + // `state_getMetadata` may return either the raw `RuntimeMetadataPrefixed` + // (starts with the `meta` magic) or an OpaqueMetadata wrapper + // (`Vec` = compact(len) ‖ bytes). Strip the wrapper only when present. + const META_MAGIC: [u8; 4] = *b"meta"; + if bytes.get(..4) == Some(&META_MAGIC) { + Metadata::decode(&bytes) + } else { + let inner = + Vec::::decode(&mut &bytes[..]).map_err(|e| format!("opaque metadata: {e}"))?; + Metadata::decode(&inner) + } +} + +/// Fetch the chain state needed to fill the signed extensions. +pub async fn fetch_chain_state(rpc: &RpcClient) -> Result { + let genesis_hex = rpc + .call("chain_getBlockHash", json!([0])) + .await + .map_err(|e| e.to_string())?; + let genesis_str = genesis_hex + .as_str() + .ok_or_else(|| "chain_getBlockHash returned non-string".to_string())?; + let genesis = hex::decode(genesis_str.strip_prefix("0x").unwrap_or(genesis_str)) + .map_err(|e| format!("genesis hex: {e}"))?; + let genesis_hash: [u8; 32] = genesis + .try_into() + .map_err(|_| "genesis hash is not 32 bytes".to_string())?; + + let runtime = rpc + .call("state_getRuntimeVersion", json!([])) + .await + .map_err(|e| e.to_string())?; + let spec_version = json_u32(&runtime, "specVersion")?; + let transaction_version = json_u32(&runtime, "transactionVersion")?; + + Ok(ChainState { + spec_version, + transaction_version, + genesis_hash, + nonce: 0, + }) +} + +/// Read a u32 field from a JSON object. +fn json_u32(value: &Value, field: &str) -> Result { + value + .get(field) + .and_then(Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .ok_or_else(|| format!("missing/invalid {field}")) +} + +/// Result of a statement-store allowance registration attempt. +pub enum RegistrationOutcome { + /// The extrinsic reached a block; the target now holds slot `seq`. + Registered { + /// Block hash the extrinsic landed in. + block_hash: String, + /// Claimed slot sequence. + seq: u32, + /// Ring index the proof was built against. + ring_index: u32, + }, + /// The target already held a slot this period; nothing submitted. + AlreadyAllocated { + /// Existing slot sequence. + seq: u32, + }, +} + +/// Find the newest ring (scanning up to `lookback` back from the current index) +/// that includes our member key. Reads the ring exponent once and stops at the +/// first match. +pub async fn find_including_ring( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + lookback: u32, +) -> Result, String> { + let member = proof::member_key(entropy); + let exponent = ring::read_ring_exponent(rpc, metadata).await?; + let current = ring::read_current_ring_index(rpc).await?; + let oldest = current.saturating_sub(lookback); + for ring_index in (oldest..=current).rev() { + let members = ring::read_ring_members_at(rpc, ring_index).await?; + if members.contains(&member) { + return Ok(Some(RingParams { + members, + exponent, + ring_index, + })); + } + } + Ok(None) +} + +/// Register statement-store allowance for `target`, proving membership in the +/// already-located `ring`, at UTC-day `period`. +pub async fn register_statement_account( + rpc: &RpcClient, + metadata: &Metadata, + chain_state: &ChainState, + entropy: [u8; 32], + target: &[u8; 32], + period: u32, + ring: &RingParams, +) -> Result { + let mut skipped_duplicate_slots = Vec::new(); + loop { + let seq = match slot::scan_slot_excluding( + rpc, + metadata, + entropy, + period, + target, + &skipped_duplicate_slots, + ) + .await? + { + SlotSelection::AlreadyAllocated(seq) => { + return Ok(RegistrationOutcome::AlreadyAllocated { seq }); + } + SlotSelection::Free(seq) => seq, + }; + + let context = slot::derive_slot_context(period, seq); + let call = extrinsic::build_set_statement_store_account_call(period, seq, target); + let message = extension::build_proof_message(metadata, &call, chain_state)?; + let domain = proof::domain_for_ring_exponent(ring.exponent)?; + let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; + let as_resources_extra = extrinsic::build_as_resources_extra(&ring_proof, ring.ring_index); + let extrinsic = + extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; + + match rpc.submit_and_watch(&extrinsic).await { + Ok(block_hash) => { + return Ok(RegistrationOutcome::Registered { + block_hash, + seq, + ring_index: ring.ring_index, + }); + } + Err(err) => { + let err = err.to_string(); + if duplicate_submit_error(&err) { + skipped_duplicate_slots.push(seq); + continue; + } + return Err(err); + } + } + } +} + +fn duplicate_submit_error(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("priority is too low") + || message.contains("already imported") + || message.contains("temporarily banned") +} diff --git a/rust/crates/truapi-host-cli/src/alloc/dynamic.rs b/rust/crates/truapi-host-cli/src/alloc/dynamic.rs new file mode 100644 index 00000000..0fe11728 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/dynamic.rs @@ -0,0 +1,132 @@ +//! Minimal metadata-driven SCALE walker. +//! +//! Just enough to read one field out of a storage struct without a full dynamic +//! codec: `skip` advances a cursor past one value of a given type, and +//! `read_field_variant_name` walks a composite to a named field and returns its +//! enum variant name (used for `CollectionInfo.ring_size` -> `R2e9`/`R2e10`/`R2e14`). + +use parity_scale_codec::{Compact, Decode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + +/// Advance `input` past exactly one SCALE-encoded value of `type_id`. +pub fn skip(registry: &PortableRegistry, type_id: u32, input: &mut &[u8]) -> Result<(), String> { + let ty = registry + .resolve(type_id) + .ok_or_else(|| format!("unknown type id {type_id}"))?; + match &ty.type_def { + TypeDef::Composite(c) => { + for field in &c.fields { + skip(registry, field.ty.id, input)?; + } + } + TypeDef::Tuple(t) => { + for field in &t.fields { + skip(registry, field.id, input)?; + } + } + TypeDef::Array(a) => { + for _ in 0..a.len { + skip(registry, a.type_param.id, input)?; + } + } + TypeDef::Sequence(s) => { + let len = read_compact(input)?; + for _ in 0..len { + skip(registry, s.type_param.id, input)?; + } + } + TypeDef::Variant(v) => { + let index = read_u8(input)?; + let variant = v + .variants + .iter() + .find(|var| var.index == index) + .ok_or_else(|| format!("unknown variant index {index}"))?; + for field in &variant.fields { + skip(registry, field.ty.id, input)?; + } + } + TypeDef::Compact(_) => { + read_compact(input)?; + } + TypeDef::BitSequence(_) => { + let bits = read_compact(input)?; + advance(input, bits.div_ceil(8))?; + } + TypeDef::Primitive(p) => { + let len = match p { + TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => 1, + TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => 2, + TypeDefPrimitive::Char | TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => 4, + TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => 8, + TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => 16, + TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => 32, + // Length-prefixed UTF-8: compact byte length then the bytes. + TypeDefPrimitive::Str => read_compact(input)?, + }; + advance(input, len)?; + } + } + Ok(()) +} + +/// Walk composite `struct_type_id` to `field_name` and return the enum variant +/// name selected there (the field must be a fieldless/simple enum). +pub fn read_field_variant_name( + registry: &PortableRegistry, + struct_type_id: u32, + field_name: &str, + bytes: &[u8], +) -> Result { + let ty = registry + .resolve(struct_type_id) + .ok_or_else(|| format!("unknown type id {struct_type_id}"))?; + let TypeDef::Composite(composite) = &ty.type_def else { + return Err(format!("type {struct_type_id} is not a composite")); + }; + + let mut input = bytes; + for field in &composite.fields { + if field.name.as_deref() == Some(field_name) { + let field_ty = registry + .resolve(field.ty.id) + .ok_or_else(|| format!("unknown field type id {}", field.ty.id))?; + let TypeDef::Variant(variant) = &field_ty.type_def else { + return Err(format!("field `{field_name}` is not an enum")); + }; + let index = read_u8(&mut input)?; + return variant + .variants + .iter() + .find(|var| var.index == index) + .map(|var| var.name.clone()) + .ok_or_else(|| format!("unknown variant index {index} for `{field_name}`")); + } + skip(registry, field.ty.id, &mut input)?; + } + Err(format!("field `{field_name}` not found")) +} + +/// Decode a SCALE compact-encoded length, advancing `input`. +fn read_compact(input: &mut &[u8]) -> Result { + let Compact(value) = Compact::::decode(input).map_err(|err| format!("compact: {err}"))?; + usize::try_from(value).map_err(|_| "compact length overflow".to_string()) +} + +/// Read one byte, advancing `input`. +fn read_u8(input: &mut &[u8]) -> Result { + let (&first, rest) = input + .split_first() + .ok_or_else(|| "unexpected end".to_string())?; + *input = rest; + Ok(first) +} + +/// Advance `input` by `n` bytes. +fn advance(input: &mut &[u8], n: usize) -> Result<(), String> { + if input.len() < n { + return Err(format!("need {n} bytes, have {}", input.len())); + } + *input = &input[n..]; + Ok(()) +} diff --git a/rust/crates/truapi-host-cli/src/alloc/extension.rs b/rust/crates/truapi-host-cli/src/alloc/extension.rs new file mode 100644 index 00000000..c2e925b1 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/extension.rs @@ -0,0 +1,370 @@ +//! Signed-extension encoding for the unsigned General (v5) `AsResources` +//! extrinsic, driven by live chain metadata. +//! +//! The extension **order** and per-extension type ids come from the runtime +//! metadata (`state_getMetadata`, V14/V15); the per-extension `extra` / +//! `additional_signed` bytes come from a name-keyed encoder mirroring +//! signing-bot `src/core/create-transaction.ts` `encodeSignedExtensions`, with a +//! generic default for the personhood extensions (all `Option`/void). +//! +//! Two concatenations are derived from the same encoded list: +//! - the ring-VRF proof message (`build_proof_message`) over the extensions +//! strictly *after* `AsResources` (host-spec inherited implication), and +//! - the full extrinsic body's `Σ extra` (see `extrinsic.rs`), over *all* +//! extensions with `AsResources` carrying `Some(AsResourcesInfo)`. + +use std::collections::HashMap; + +use blake2_rfc::blake2b::blake2b; +use frame_metadata::RuntimeMetadata; +use frame_metadata::RuntimeMetadataPrefixed; +use parity_scale_codec::{Compact, Decode, Encode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + +/// Signed-extension identifier that carries the `AsResources` authorization. +pub const AS_RESOURCES: &str = "AsResources"; + +/// Chain state needed to fill the standard signed extensions. +#[derive(Debug, Clone, Copy)] +pub struct ChainState { + /// Runtime `specVersion` (CheckSpecVersion implicit). + pub spec_version: u32, + /// Runtime `transactionVersion` (CheckTxVersion implicit). + pub transaction_version: u32, + /// Genesis block hash (CheckGenesis / CheckMortality implicit). + pub genesis_hash: [u8; 32], + /// Account nonce (CheckNonce extra); ignored by the unsigned path. + pub nonce: u32, +} + +/// A signed extension's identifier plus the type ids of its `extra` and +/// `additional_signed` fields, in metadata order. +struct ExtensionDef { + identifier: String, + extra_type: u32, + additional_signed_type: u32, +} + +/// A signed extension encoded to its `extra` and `additional_signed` bytes. +pub struct EncodedExtension { + /// SCALE-encoded `extra` (goes into the extrinsic body). + pub extra: Vec, + /// SCALE-encoded `additional_signed` (the implicit, part of the signed data). + pub additional_signed: Vec, +} + +/// Decoded metadata: the ordered signed-extension defs, the type registry, and +/// each storage entry's value type id (`(pallet, entry) -> type id`). +pub struct Metadata { + extensions: Vec, + registry: PortableRegistry, + storage_values: HashMap<(String, String), u32>, + constants: HashMap<(String, String), Vec>, +} + +/// Collect extensions, type registry, storage value types, and pallet constants +/// from a decoded V14/V15 metadata; `$set` is the version's `StorageEntryType`. +macro_rules! collect_metadata { + ($m:expr, $set:path) => {{ + let extensions = $m + .extrinsic + .signed_extensions + .iter() + .map(|e| ExtensionDef { + identifier: e.identifier.clone(), + extra_type: e.ty.id, + additional_signed_type: e.additional_signed.id, + }) + .collect(); + let mut storage_values = HashMap::new(); + let mut constants = HashMap::new(); + for pallet in &$m.pallets { + for constant in &pallet.constants { + constants.insert( + (pallet.name.clone(), constant.name.clone()), + constant.value.clone(), + ); + } + let Some(storage) = &pallet.storage else { + continue; + }; + for entry in &storage.entries { + use $set as EntryType; + let value_type = match &entry.ty { + EntryType::Plain(ty) => ty.id, + EntryType::Map { value, .. } => value.id, + }; + storage_values.insert((pallet.name.clone(), entry.name.clone()), value_type); + } + } + (extensions, $m.types, storage_values, constants) + }}; +} + +impl Metadata { + /// Decode `state_getMetadata` bytes (a `RuntimeMetadataPrefixed`, V14 or + /// V15) into the ordered signed-extension defs, type registry, and storage + /// value types. + pub fn decode(bytes: &[u8]) -> Result { + let prefixed = RuntimeMetadataPrefixed::decode(&mut &bytes[..]) + .map_err(|err| format!("metadata decode failed: {err}"))?; + let (extensions, registry, storage_values, constants) = match prefixed.1 { + RuntimeMetadata::V14(m) => collect_metadata!(m, frame_metadata::v14::StorageEntryType), + RuntimeMetadata::V15(m) => collect_metadata!(m, frame_metadata::v15::StorageEntryType), + other => return Err(format!("unsupported metadata version {}", other.version())), + }; + Ok(Self { + extensions, + registry, + storage_values, + constants, + }) + } + + /// The type registry, for dynamic decoding of storage values. + pub fn registry(&self) -> &PortableRegistry { + &self.registry + } + + /// The value type id of storage entry `pallet::entry`, if present. + pub fn storage_value_type(&self, pallet: &str, entry: &str) -> Option { + self.storage_values + .get(&(pallet.to_string(), entry.to_string())) + .copied() + } + + /// The SCALE-encoded value bytes of pallet constant `pallet::name`. + pub fn constant(&self, pallet: &str, name: &str) -> Option<&[u8]> { + self.constants + .get(&(pallet.to_string(), name.to_string())) + .map(Vec::as_slice) + } + + /// Encode every signed extension in metadata order. + pub fn encode_signed_extensions(&self, state: &ChainState) -> Vec { + self.extensions + .iter() + .map(|ext| { + let (extra, additional_signed) = self.encode_one(ext, state); + EncodedExtension { + extra, + additional_signed, + } + }) + .collect() + } + + /// The signed-extension identifiers, in metadata order. + #[cfg(test)] + pub fn extension_ids(&self) -> Vec<&str> { + self.extensions + .iter() + .map(|e| e.identifier.as_str()) + .collect() + } + + /// Encode a single extension's `(extra, additional_signed)`, mirroring the + /// signing-bot switch; unknown personhood extensions fall back to the + /// metadata type default (`Option` -> None, void -> empty). + fn encode_one(&self, ext: &ExtensionDef, state: &ChainState) -> (Vec, Vec) { + match ext.identifier.as_str() { + "CheckNonce" => (Compact(state.nonce).encode(), Vec::new()), + "CheckSpecVersion" => (Vec::new(), state.spec_version.to_le_bytes().to_vec()), + "CheckTxVersion" => (Vec::new(), state.transaction_version.to_le_bytes().to_vec()), + "CheckGenesis" => (Vec::new(), state.genesis_hash.to_vec()), + // extra = Era::Immortal (0x00); implicit = genesis hash. + "CheckMortality" => (vec![0x00], state.genesis_hash.to_vec()), + // extra = first variant `Disabled` (void) = 0x00. + "VerifyMultiSignature" => (vec![0x00], Vec::new()), + // extra = { tip: compact(0), asset_id: None } = 0x00 0x00. + "ChargeAssetTxPayment" => (vec![0x00, 0x00], Vec::new()), + // extra = bool false = 0x00. + "RestrictOrigins" => (vec![0x00], Vec::new()), + _ => ( + self.encode_default(ext.extra_type), + self.encode_default(ext.additional_signed_type), + ), + } + } + + /// Encode the "disabled" default value for a metadata type: `Option` -> None + /// (`0x00`), void/empty tuple -> empty, enums -> first variant, primitives + /// -> zero. Matches signing-bot `defaultValueForType`. + fn encode_default(&self, type_id: u32) -> Vec { + let Some(ty) = self.registry.resolve(type_id) else { + return Vec::new(); + }; + match &ty.type_def { + TypeDef::Composite(c) => c + .fields + .iter() + .flat_map(|f| self.encode_default(f.ty.id)) + .collect(), + TypeDef::Tuple(t) => t + .fields + .iter() + .flat_map(|f| self.encode_default(f.id)) + .collect(), + TypeDef::Variant(v) => { + // Option encodes None as 0x00. + if ty.path.segments.last().map(String::as_str) == Some("Option") { + return vec![0x00]; + } + match v.variants.iter().min_by_key(|var| var.index) { + None => Vec::new(), + Some(first) => { + let mut out = vec![first.index]; + for field in &first.fields { + out.extend(self.encode_default(field.ty.id)); + } + out + } + } + } + TypeDef::Array(a) => { + let elem = self.encode_default(a.type_param.id); + elem.repeat(a.len as usize) + } + // Sequences / strings / bit-sequences encode an empty run as compact(0). + TypeDef::Sequence(_) | TypeDef::BitSequence(_) => vec![0x00], + TypeDef::Compact(_) => vec![0x00], + TypeDef::Primitive(p) => match p { + TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => vec![0], + TypeDefPrimitive::Char | TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => { + vec![0; 4] + } + TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => vec![0; 2], + TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => vec![0; 8], + TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => vec![0; 16], + TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => vec![0; 32], + // Length-prefixed string: empty = compact(0). + TypeDefPrimitive::Str => vec![0x00], + }, + } + } + + /// Index of `AsResources` in the extension list, if present. + pub fn as_resources_index(&self) -> Option { + self.extensions + .iter() + .position(|e| e.identifier == AS_RESOURCES) + } +} + +/// Build the ring-VRF proof message for an `AsResources`-authorized call: +/// `blake2b256(0x00 ‖ call ‖ Σ tail.extra ‖ Σ tail.additional_signed)`, where +/// the tail is the extensions ordered strictly after `AsResources`. The leading +/// `0x00` is the General-transaction extension-version byte. +pub fn build_proof_message( + metadata: &Metadata, + call_data: &[u8], + state: &ChainState, +) -> Result<[u8; 32], String> { + let all = metadata.encode_signed_extensions(state); + let tail_start = metadata + .as_resources_index() + .map(|i| i + 1) + .ok_or_else(|| format!("{AS_RESOURCES} extension not found in metadata"))?; + let tail = &all[tail_start..]; + + let mut payload = Vec::with_capacity(1 + call_data.len()); + payload.push(0x00); + payload.extend_from_slice(call_data); + for ext in tail { + payload.extend_from_slice(&ext.extra); + } + for ext in tail { + payload.extend_from_slice(&ext.additional_signed); + } + Ok(blake2b256(&payload)) +} + +/// BLAKE2b-256 of `message`. +pub fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b(32, &[], message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fixture metadata captured from paseo-next-v2 (raw `RuntimeMetadataPrefixed`). + const FIXTURE: &[u8] = include_bytes!("../../tests/fixtures/paseo-next-v2-metadata.scale"); + + /// The known-answer chain state frozen alongside the fixture. + fn fixture_state() -> ChainState { + ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + } + } + + /// `Resources.set_statement_store_account(period=7, seq=0, target=0)`. + fn fixture_call() -> Vec { + let mut call = vec![0x3f, 0x0a]; + call.extend_from_slice(&7u32.to_le_bytes()); + call.extend_from_slice(&0u32.to_le_bytes()); + call.extend_from_slice(&[0u8; 32]); + call + } + + #[test] + fn proof_message_matches_frozen_known_answer() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let msg = build_proof_message(&metadata, &fixture_call(), &fixture_state()).unwrap(); + assert_eq!( + hex::encode(msg), + "1d2e6d8d8f421b0857097c6076115507432d66fea47ebe0c3be282a369f6743c", + ); + } + + #[test] + fn as_resources_tail_is_indices_10_through_20() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let idx = metadata.as_resources_index().unwrap(); + // AsResources sits at index 9; the proof tail is everything after it. + assert_eq!(idx, 9); + let ids = metadata.extension_ids(); + assert_eq!( + ids[idx + 1..].to_vec(), + vec![ + "AuthorizeCall", + "RestrictOrigins", + "CheckNonZeroSender", + "CheckSpecVersion", + "CheckTxVersion", + "CheckGenesis", + "CheckMortality", + "CheckNonce", + "CheckWeight", + "ChargeAssetTxPayment", + "StorageWeightReclaim", + ], + ); + } + + #[test] + fn dropping_the_version_byte_changes_the_hash() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let state = fixture_state(); + let call = fixture_call(); + let all = metadata.encode_signed_extensions(&state); + let tail = &all[metadata.as_resources_index().unwrap() + 1..]; + let mut without = call.clone(); + for e in tail { + without.extend_from_slice(&e.extra); + } + for e in tail { + without.extend_from_slice(&e.additional_signed); + } + assert_ne!( + build_proof_message(&metadata, &call, &state).unwrap(), + blake2b256(&without), + ); + } +} diff --git a/rust/crates/truapi-host-cli/src/alloc/extrinsic.rs b/rust/crates/truapi-host-cli/src/alloc/extrinsic.rs new file mode 100644 index 00000000..8d0dd928 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/extrinsic.rs @@ -0,0 +1,142 @@ +//! `Resources.set_statement_store_account` call + unsigned General (v5) +//! extrinsic assembly. Mirrors signing-bot `allocation.ts` / `extrinsic-submit.ts`. + +use parity_scale_codec::{Compact, Encode}; + +use super::extension::{ChainState, Metadata}; + +/// Pallet + call index for `Resources.set_statement_store_account` (63 / 10). +pub const SET_STATEMENT_STORE_ACCOUNT_CALL: [u8; 2] = [0x3f, 0x0a]; +/// `AsResourcesInfo::RegisterStatementStoreAllowance` variant index. +const REGISTER_STATEMENT_STORE_ALLOWANCE: u8 = 0x02; +/// `MembershipCollection::LitePeople` variant index. +const MEMBERSHIP_COLLECTION_LITE_PEOPLE: u8 = 0x01; +/// General-transaction preamble byte: `0b01` (General) | version 5. +const GENERAL_V5_PREAMBLE: u8 = 0x45; +/// Current signed-extension version byte. +const EXTENSION_VERSION: u8 = 0x00; +/// `Option::Some` discriminant for the `AsResources` extension `extra`. +const OPTION_SOME: u8 = 0x01; + +/// Encode `Resources.set_statement_store_account(period, seq, target)`: +/// `3f 0a ‖ period_u32LE ‖ seq_u32LE ‖ target[32]`. +pub fn build_set_statement_store_account_call(period: u32, seq: u32, target: &[u8; 32]) -> Vec { + let mut call = Vec::with_capacity(2 + 4 + 4 + 32); + call.extend_from_slice(&SET_STATEMENT_STORE_ACCOUNT_CALL); + call.extend_from_slice(&period.to_le_bytes()); + call.extend_from_slice(&seq.to_le_bytes()); + call.extend_from_slice(target); + call +} + +/// Encode the `AsResources` extension `extra` for a statement-store allowance: +/// `Some(RegisterStatementStoreAllowance { proof, ring_index, LitePeople })`. +pub fn build_as_resources_extra(proof: &[u8], ring_index: u32) -> Vec { + let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 1); + extra.push(OPTION_SOME); + extra.push(REGISTER_STATEMENT_STORE_ALLOWANCE); + extra.extend_from_slice(&Compact(proof.len() as u32).encode()); + extra.extend_from_slice(proof); + extra.extend_from_slice(&ring_index.to_le_bytes()); + extra.push(MEMBERSHIP_COLLECTION_LITE_PEOPLE); + extra +} + +/// Assemble the unsigned General (v5) extrinsic: +/// `compact(len) ‖ 0x45 ‖ 0x00 ‖ Σ(all extra, AsResources = Some(info)) ‖ call`. +pub fn build_unsigned_extrinsic( + metadata: &Metadata, + state: &ChainState, + call_data: &[u8], + as_resources_extra: &[u8], +) -> Result, String> { + let all = metadata.encode_signed_extensions(state); + let as_resources_index = metadata + .as_resources_index() + .ok_or_else(|| "AsResources extension not found in metadata".to_string())?; + + let mut body = vec![GENERAL_V5_PREAMBLE, EXTENSION_VERSION]; + for (i, ext) in all.iter().enumerate() { + if i == as_resources_index { + body.extend_from_slice(as_resources_extra); + } else { + body.extend_from_slice(&ext.extra); + } + } + body.extend_from_slice(call_data); + + let mut extrinsic = Compact(body.len() as u32).encode(); + extrinsic.extend_from_slice(&body); + Ok(extrinsic) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE: &[u8] = include_bytes!("../../tests/fixtures/paseo-next-v2-metadata.scale"); + + fn fixture_state() -> ChainState { + ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + } + } + + #[test] + fn call_layout_is_pallet_call_period_seq_target() { + let call = build_set_statement_store_account_call(7, 0, &[0u8; 32]); + assert_eq!( + call, + [ + vec![0x3f, 0x0a], + 7u32.to_le_bytes().to_vec(), + 0u32.to_le_bytes().to_vec(), + vec![0u8; 32], + ] + .concat() + ); + } + + #[test] + fn as_resources_extra_wraps_proof_as_bytes() { + let proof = vec![0xEE; 785]; + let extra = build_as_resources_extra(&proof, 3); + // Some(0x01) ‖ variant(0x02) ‖ compact(785)=0x45,0x0c ‖ 785 bytes ‖ ringIndex LE ‖ LitePeople. + assert_eq!(&extra[..2], &[0x01, 0x02]); + assert_eq!(&extra[2..4], &Compact(785u32).encode()[..]); + assert_eq!(&extra[4..4 + 785], &proof[..]); + assert_eq!(&extra[4 + 785..4 + 785 + 4], &3u32.to_le_bytes()); + assert_eq!(extra[4 + 785 + 4], MEMBERSHIP_COLLECTION_LITE_PEOPLE); + } + + #[test] + fn extrinsic_has_general_v5_preamble_and_embeds_call() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let call = build_set_statement_store_account_call(7, 0, &[0u8; 32]); + let extra = build_as_resources_extra(&vec![0xEE; 785], 0); + let xt = build_unsigned_extrinsic(&metadata, &fixture_state(), &call, &extra).unwrap(); + + // Strip the compact length prefix and check the body head + tail. + let body = &xt[compact_prefix_len(&xt)..]; + assert_eq!(&body[..2], &[GENERAL_V5_PREAMBLE, EXTENSION_VERSION]); + assert_eq!(&body[body.len() - call.len()..], &call[..]); + // The Some(info) extra appears verbatim in the body. + assert!( + body.windows(extra.len()).any(|w| w == extra), + "AsResources Some(info) extra should appear in the body", + ); + } + + /// Length of the SCALE compact prefix at the head of `xt`. + fn compact_prefix_len(xt: &[u8]) -> usize { + match xt[0] & 0b11 { + 0b00 => 1, + 0b01 => 2, + 0b10 => 4, + _ => 5, + } + } +} diff --git a/rust/crates/truapi-host-cli/src/alloc/proof.rs b/rust/crates/truapi-host-cli/src/alloc/proof.rs new file mode 100644 index 00000000..b9dc567f --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/proof.rs @@ -0,0 +1,111 @@ +//! Bandersnatch ring-VRF one-shot membership proof (prover side). +//! +//! Wraps `verifiable`'s prover-gated `open` + `create` into the single-shot +//! proof a `RegisterStatementStoreAllowance` needs: prove that our member key is +//! in the LitePeople ring, bound to a slot `context` and the extrinsic proof +//! `message`. Mirrors signing-bot `ring-proof.ts` `oneShotProof`. + +use verifiable::GenerateVerifiable; +use verifiable::ring::RingDomainSize; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +/// A single-context ring-VRF signature is exactly 785 bytes. +pub const RING_VRF_PROOF_LEN: usize = 785; + +/// Map an on-chain `RingExponent` (9 / 10 / 14) to the FFT domain size +/// (power = exponent + 2). +pub fn domain_for_ring_exponent(exponent: u8) -> Result { + match exponent { + 9 => Ok(RingDomainSize::Domain11), + 10 => Ok(RingDomainSize::Domain12), + 14 => Ok(RingDomainSize::Domain16), + other => Err(format!("unsupported ring exponent {other}")), + } +} + +/// The ring member key for a bandersnatch entropy (`blake2b256(bip39_entropy)`). +pub fn member_key(entropy: [u8; 32]) -> [u8; 32] { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + BandersnatchVrfVerifiable::member_from_secret(&secret) +} + +/// Produce the 785-byte ring-VRF membership proof over `members` (already +/// sliced to the ring's included prefix), bound to `context` and `message`. +/// +/// `entropy` is the bandersnatch entropy; its member key must be present in +/// `members` or `open` fails with `NotInRing`. +pub fn ring_vrf_proof( + domain: RingDomainSize, + entropy: [u8; 32], + members: &[[u8; 32]], + context: &[u8], + message: &[u8], +) -> Result, String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let member = BandersnatchVrfVerifiable::member_from_secret(&secret); + let commitment = BandersnatchVrfVerifiable::open(domain, &member, members.iter().copied()) + .map_err(|err| format!("ring-VRF open failed: {err:?}"))?; + let (proof, _alias) = BandersnatchVrfVerifiable::create(commitment, &secret, context, message) + .map_err(|err| format!("ring-VRF create failed: {err:?}"))?; + let bytes = proof.into_inner(); + if bytes.len() != RING_VRF_PROOF_LEN { + return Err(format!( + "ring-VRF proof is {} bytes, expected {RING_VRF_PROOF_LEN}", + bytes.len() + )); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exponent_maps_to_domain() { + assert_eq!( + domain_for_ring_exponent(9).unwrap(), + RingDomainSize::Domain11 + ); + assert_eq!( + domain_for_ring_exponent(10).unwrap(), + RingDomainSize::Domain12 + ); + assert_eq!( + domain_for_ring_exponent(14).unwrap(), + RingDomainSize::Domain16 + ); + assert!(domain_for_ring_exponent(11).is_err()); + } + + #[test] + fn proof_is_785_bytes_for_a_single_member_ring() { + let entropy = [0x11u8; 32]; + let member = member_key(entropy); + let members = vec![member]; + let proof = ring_vrf_proof( + RingDomainSize::Domain11, + entropy, + &members, + b"SSS_SLOT:test-context-padding..", + &[0x42; 32], + ) + .unwrap(); + assert_eq!(proof.len(), RING_VRF_PROOF_LEN); + } + + #[test] + fn open_fails_when_member_absent_from_ring() { + let entropy = [0x11u8; 32]; + let other = member_key([0x22u8; 32]); + let err = ring_vrf_proof( + RingDomainSize::Domain11, + entropy, + &[other], + b"SSS_SLOT:test-context-padding..", + &[0x42; 32], + ) + .unwrap_err(); + assert!(err.contains("open failed"), "unexpected error: {err}"); + } +} diff --git a/rust/crates/truapi-host-cli/src/alloc/ring.rs b/rust/crates/truapi-host-cli/src/alloc/ring.rs new file mode 100644 index 00000000..aac6403d --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/ring.rs @@ -0,0 +1,164 @@ +//! LitePeople ring parameters from the People chain (`Members` pallet). +//! +//! Reads the on-chain ring so the membership proof is built against the same +//! members the runtime verifies against: the baked-in `included` prefix of the +//! current ring. Mirrors signing-bot `ring-proof.ts`. + +use parity_scale_codec::{Compact, Decode}; +use sp_crypto_hashing::{blake2_128, twox_64, twox_128}; + +use super::dynamic::read_field_variant_name; +use super::extension::Metadata; +use super::rpc::RpcClient; + +/// LitePeople collection identifier: ASCII, exactly 32 bytes. +const LITE_PEOPLE_IDENTIFIER: &[u8; 32] = b"pop:polkadot.network/people-lite"; +/// Ring member public key length. +const MEMBER_LEN: usize = 32; + +/// On-chain LitePeople ring parameters for building a verifying proof. +pub struct RingParams { + /// Ring members, sliced to the baked-in `included` prefix. + pub members: Vec<[u8; 32]>, + /// Ring size exponent (9 / 10 / 14). + pub exponent: u8, + /// Ring index these members belong to. + pub ring_index: u32, +} + +/// `Members.CurrentRingIndex[id]` storage key. +fn current_ring_index_key() -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"CurrentRingIndex").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + ] + .concat() +} + +/// `Members.Collections[id]` storage key. +fn collections_key() -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"Collections").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + ] + .concat() +} + +/// `Members.RingKeysStatus[(id, ring_index)]` storage key. +fn ring_keys_status_key(ring_index: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeysStatus").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + ] + .concat() +} + +/// `Members.RingKeys[(id, ring_index, page)]` storage key. +fn ring_keys_key(ring_index: u32, page: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeys").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + &twox_64_concat(&page.to_le_bytes()), + ] + .concat() +} + +/// `Blake2_128Concat(x)` = `blake2_128(x) ‖ x`. +pub(super) fn blake2_128_concat(x: &[u8]) -> Vec { + [blake2_128(x).as_slice(), x].concat() +} + +/// `Twox64Concat(x)` = `twox_64(x) ‖ x`. +fn twox_64_concat(x: &[u8]) -> Vec { + [twox_64(x).as_slice(), x].concat() +} + +/// Map a `RingExponent` variant name to its exponent. +fn ring_exponent_from_name(name: &str) -> Result { + match name { + "R2e9" => Ok(9), + "R2e10" => Ok(10), + "R2e14" => Ok(14), + other => Err(format!("unsupported RingExponent variant `{other}`")), + } +} + +/// Read the current LitePeople ring index (absent => 0). +pub async fn read_current_ring_index(rpc: &RpcClient) -> Result { + match rpc + .get_storage(¤t_ring_index_key()) + .await + .map_err(|e| e.to_string())? + { + Some(bytes) => u32::decode(&mut &bytes[..]).map_err(|e| format!("ring index: {e}")), + None => Ok(0), + } +} + +/// Read the LitePeople ring size exponent from `Collections[LitePeople].ring_size`. +/// This is a chain constant, so read it once and reuse across ring indices. +pub async fn read_ring_exponent(rpc: &RpcClient, metadata: &Metadata) -> Result { + let collection = rpc + .get_storage(&collections_key()) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Members.Collections[LitePeople] missing".to_string())?; + let value_type = metadata + .storage_value_type("Members", "Collections") + .ok_or_else(|| "Members.Collections type not in metadata".to_string())?; + let variant = + read_field_variant_name(metadata.registry(), value_type, "ring_size", &collection)?; + ring_exponent_from_name(&variant) +} + +/// Read the members of `ring_index`, sliced to the baked-in `included` prefix. +pub async fn read_ring_members_at( + rpc: &RpcClient, + ring_index: u32, +) -> Result, String> { + // 1. Page through RingKeys collecting raw 32-byte members. + let mut members = Vec::new(); + for page in 0.. { + let Some(bytes) = rpc + .get_storage(&ring_keys_key(ring_index, page)) + .await + .map_err(|e| e.to_string())? + else { + break; + }; + let mut cursor = &bytes[..]; + let Compact(len) = + Compact::::decode(&mut cursor).map_err(|e| format!("ring keys len: {e}"))?; + if len == 0 { + break; + } + for i in 0..len as usize { + let start = i * MEMBER_LEN; + let member: [u8; 32] = cursor + .get(start..start + MEMBER_LEN) + .ok_or_else(|| "ring keys page truncated".to_string())? + .try_into() + .expect("slice is 32 bytes"); + members.push(member); + } + } + + // 2. Slice to the baked-in `included` prefix (absent status => all included). + if let Some(status) = rpc + .get_storage(&ring_keys_status_key(ring_index)) + .await + .map_err(|e| e.to_string())? + { + // RingStatus = { total: u32 LE, included: u32 LE, .. }. + let included = u32::decode(&mut &status[4..]).map_err(|e| format!("ring status: {e}"))?; + members.truncate(included as usize); + } + + Ok(members) +} diff --git a/rust/crates/truapi-host-cli/src/alloc/rpc.rs b/rust/crates/truapi-host-cli/src/alloc/rpc.rs new file mode 100644 index 00000000..e6660c4c --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/rpc.rs @@ -0,0 +1,116 @@ +//! JSON-RPC helpers for statement-store allowance registration. +//! +//! Keep the CLI diagnostic path on the same `subxt-rpcs` transport surface as +//! the runtime code instead of hand-rolling request ids, subscriptions, and +//! websocket framing here. + +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde_json::Value; +use subxt_rpcs::client::{RpcClient as SubxtRpcClient, RpcParams, rpc_params}; +use tokio::time::timeout; + +/// Timeout for an allowance registration extrinsic to finalize. +const SUBMIT_TIMEOUT: Duration = Duration::from_secs(120); + +/// A People-chain JSON-RPC connection. +pub struct RpcClient { + inner: SubxtRpcClient, +} + +impl RpcClient { + /// Open a JSON-RPC connection to `url`. + pub async fn connect(url: &str) -> Result { + let inner = SubxtRpcClient::from_insecure_url(url) + .await + .with_context(|| format!("connect {url}"))?; + Ok(Self { inner }) + } + + /// Call `method` with `params`, returning the result value. + pub async fn call(&self, method: &str, params: Value) -> Result { + self.inner + .request(method, value_to_params(params)?) + .await + .with_context(|| format!("rpc {method}")) + } + + /// `state_getStorage(key)` -> raw value bytes, or `None` if absent. + pub async fn get_storage(&self, key: &[u8]) -> Result>> { + let key_hex = format!("0x{}", hex::encode(key)); + match self + .inner + .request::("state_getStorage", rpc_params![key_hex]) + .await + .context("rpc state_getStorage")? + { + Value::String(hex_value) => Ok(Some(decode_hex(&hex_value)?)), + _ => Ok(None), + } + } + + /// Submit an extrinsic and wait for `finalized`; returns the block hash. + pub async fn submit_and_watch(&self, extrinsic: &[u8]) -> Result { + let extrinsic_hex = format!("0x{}", hex::encode(extrinsic)); + let mut subscription = self + .inner + .subscribe::( + "author_submitAndWatchExtrinsic", + rpc_params![extrinsic_hex], + "author_unwatchExtrinsic", + ) + .await + .context("rpc author_submitAndWatchExtrinsic")?; + let started = Instant::now(); + + loop { + let remaining = SUBMIT_TIMEOUT + .checked_sub(started.elapsed()) + .ok_or_else(|| anyhow!("timed out waiting for author_submitAndWatchExtrinsic"))?; + let status = timeout(remaining, subscription.next()) + .await + .map_err(|_| anyhow!("timed out waiting for author_submitAndWatchExtrinsic"))? + .ok_or_else(|| anyhow!("author_submitAndWatchExtrinsic subscription ended"))? + .context("author_submitAndWatchExtrinsic update")?; + match extrinsic_status(&status) { + ExtrinsicStatus::Finalized(hash) => return Ok(hash), + ExtrinsicStatus::Rejected(reason) => bail!("extrinsic {reason}"), + ExtrinsicStatus::Pending => {} + } + } + } +} + +enum ExtrinsicStatus { + Finalized(String), + Rejected(String), + Pending, +} + +fn extrinsic_status(status: &Value) -> ExtrinsicStatus { + if let Some(hash) = status.get("finalized").and_then(Value::as_str) { + return ExtrinsicStatus::Finalized(hash.to_string()); + } + for key in ["invalid", "dropped", "usurped", "finalityTimeout"] { + if status.get(key).is_some() { + return ExtrinsicStatus::Rejected(key.to_string()); + } + } + ExtrinsicStatus::Pending +} + +fn value_to_params(value: Value) -> Result { + let Value::Array(values) = value else { + bail!("RPC params must be a JSON array"); + }; + let mut params = RpcParams::new(); + for value in values { + params.push(value).context("serialize RPC params")?; + } + Ok(params) +} + +fn decode_hex(value: &str) -> Result> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)).context("decode hex storage value") +} diff --git a/rust/crates/truapi-host-cli/src/alloc/slot.rs b/rust/crates/truapi-host-cli/src/alloc/slot.rs new file mode 100644 index 00000000..fd1fb59f --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/slot.rs @@ -0,0 +1,131 @@ +//! StatementStore allowance slot selection. +//! +//! An allowance is claimed at `(period, seq)`. The slot is bound to a 32-byte +//! `SSS_SLOT` context; occupancy is read from +//! `Resources.StatementStoreAllowances[period][alias]`, where the alias is +//! derived from OUR bandersnatch entropy in that slot context. Mirrors +//! signing-bot `allowance.ts` / `allowance-slots.ts`. + +use sp_crypto_hashing::twox_128; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use super::extension::Metadata; +use super::ring::blake2_128_concat; +use super::rpc::RpcClient; + +/// StatementStore allowance period: one UTC day, in seconds. +pub const STATEMENT_STORE_PERIOD_SECONDS: u64 = 86_400; + +/// The current allowance period for `now_seconds`. +pub fn current_period(now_seconds: u64) -> u32 { + (now_seconds / STATEMENT_STORE_PERIOD_SECONDS) as u32 +} + +/// Derive the 32-byte StatementStore slot context: +/// `"SSS_SLOT:" ‖ u32be(period) ‖ u32be(seq) ‖ 0x20 fill`. +pub fn derive_slot_context(period: u32, seq: u32) -> [u8; 32] { + let mut ctx = [0x20u8; 32]; + ctx[..9].copy_from_slice(b"SSS_SLOT:"); + ctx[9..13].copy_from_slice(&period.to_be_bytes()); + ctx[13..17].copy_from_slice(&seq.to_be_bytes()); + ctx +} + +/// The slot alias for our `entropy` at `(period, seq)`. +pub fn slot_alias(entropy: [u8; 32], period: u32, seq: u32) -> Result<[u8; 32], String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let context = derive_slot_context(period, seq); + BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("alias_in_context failed: {err:?}")) +} + +/// `Resources.StatementStoreAllowances[period][alias]` storage key. +/// key1 = Identity(u32be period); key2 = Blake2_128Concat(alias). +fn statement_store_allowance_key(period: u32, alias: &[u8; 32]) -> Vec { + [ + twox_128(b"Resources").as_slice(), + twox_128(b"StatementStoreAllowances").as_slice(), + &period.to_be_bytes(), + &blake2_128_concat(alias), + ] + .concat() +} + +/// Max StatementStore slots per period from `Resources.LiteStmtStoreSlotsPerPeriod`. +fn max_slots(metadata: &Metadata) -> Result { + let bytes = metadata + .constant("Resources", "LiteStmtStoreSlotsPerPeriod") + .ok_or_else(|| "Resources.LiteStmtStoreSlotsPerPeriod constant missing".to_string())?; + let mut buf = [0u8; 4]; + let n = bytes.len().min(4); + buf[..n].copy_from_slice(&bytes[..n]); + Ok(u32::from_le_bytes(buf)) +} + +/// The account id occupying a slot entry, if the storage value is present. +/// Entry = `account_id(32) ‖ seq(u32 LE) ‖ since(u64 LE)`. +fn entry_account_id(bytes: &[u8]) -> Option<[u8; 32]> { + bytes.get(..32).map(|s| s.try_into().expect("32 bytes")) +} + +/// Outcome of scanning for a slot to register `target` in. +pub enum SlotSelection { + /// A free `seq` we should claim. + Free(u32), + /// `target` already holds `seq` this period; no registration needed. + AlreadyAllocated(u32), +} + +/// Scan slots `0..max` for `period`, returning the first non-excluded free seq +/// (or detecting that `target` already holds one). `entropy` is our +/// bandersnatch entropy. +pub async fn scan_slot_excluding( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + period: u32, + target: &[u8; 32], + excluded: &[u32], +) -> Result { + let max = max_slots(metadata)?; + let mut first_free: Option = None; + for seq in 0..max { + let alias = slot_alias(entropy, period, seq)?; + let key = statement_store_allowance_key(period, &alias); + match rpc.get_storage(&key).await.map_err(|e| e.to_string())? { + None => { + if first_free.is_none() && !excluded.contains(&seq) { + first_free = Some(seq); + } + } + Some(bytes) => { + if entry_account_id(&bytes) == Some(*target) { + return Ok(SlotSelection::AlreadyAllocated(seq)); + } + } + } + } + first_free + .map(SlotSelection::Free) + .ok_or_else(|| format!("no free StatementStore slot in period {period} (max {max})")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slot_context_layout() { + let ctx = derive_slot_context(7, 3); + assert_eq!(&ctx[..9], b"SSS_SLOT:"); + assert_eq!(&ctx[9..13], &7u32.to_be_bytes()); + assert_eq!(&ctx[13..17], &3u32.to_be_bytes()); + assert!(ctx[17..].iter().all(|&b| b == 0x20)); + } + + #[test] + fn period_is_utc_day_index() { + assert_eq!(current_period(86_400 * 20_000 + 5), 20_000); + } +} diff --git a/rust/crates/truapi-host-cli/src/attestation.rs b/rust/crates/truapi-host-cli/src/attestation.rs new file mode 100644 index 00000000..c8dbdd8d --- /dev/null +++ b/rust/crates/truapi-host-cli/src/attestation.rs @@ -0,0 +1,227 @@ +//! Lite-username attestation against the People-chain identity backend. +//! +//! Ports signing-bot `attestation.ts`: fetch the backend verifier, build the +//! client proofs (`truapi_server::host_logic::attestation`), POST them to +//! `/usernames`, then poll People-chain `Resources.Consumers` until the record +//! lands. Registers the signing host's root account so the paired host can +//! resolve its username via `get_user_id`. + +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use serde_json::{Value, json}; +use subxt_rpcs::client::{RpcClient, rpc_params}; +use tracing::{info, warn}; +use truapi_server::host_logic::attestation::build_lite_registration; +use truapi_server::host_logic::identity::{ + decode_people_identity, resources_consumers_storage_key, +}; +use truapi_server::host_logic::product_account::{ + derive_root_keypair_from_entropy, derive_sr25519_hard_path, product_public_key_to_address, +}; + +/// Inputs for one attestation run. +pub struct AttestConfig { + /// Identity backend base URL including `/api/v1`. + pub backend_base: String, + /// People-chain WebSocket URL for the `Resources.Consumers` poll. + pub people_ws: String, + /// BIP-39 entropy of the signing host's root account. + pub entropy: Vec, + /// Requested lite username base (6+ lowercase letters, no digits). + pub username_base: String, +} + +/// Check whether a lite username base is available through the identity +/// backend. The username must be the base form without the digit suffix. +pub async fn lite_username_available(backend_base: &str, username_base: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + let url = format!("{backend_base}/usernames/available"); + let body = json!({ "usernames": [username_base] }); + let response = client + .post(&url) + .json(&body) + .send() + .await + .with_context(|| format!("POST {url}"))? + .error_for_status() + .with_context(|| format!("username availability check failed for {username_base}"))?; + let body: Value = response + .json() + .await + .context("decoding availability response")?; + Ok(body + .get(username_base) + .and_then(Value::as_str) + .is_some_and(|status| status == "AVAILABLE")) +} + +/// Register (or confirm) the signing host's lite username and wait until the +/// People-chain `Resources.Consumers` record exists. Returns the candidate +/// account's SS58 address. +pub async fn attest(config: &AttestConfig) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + + let verifier = fetch_verifier(&client, &config.backend_base).await?; + let registration = build_lite_registration(&config.entropy, verifier, &config.username_base) + .map_err(|reason| anyhow::anyhow!("failed to build registration params: {reason}"))?; + info!( + candidate = %registration.candidate_account_id, + "attesting lite username '{}'", + config.username_base + ); + + submit_registration( + &client, + &config.backend_base, + &config.username_base, + ®istration, + ) + .await?; + + let storage_key = format!( + "0x{}", + hex::encode(resources_consumers_storage_key( + ®istration.candidate_public_key + )) + ); + wait_for_consumer_record(&config.people_ws, &storage_key).await?; + info!("lite username registered and confirmed on-chain"); + Ok(registration.candidate_account_id) +} + +/// Probe the People chain for which derivation of `entropy` (bare root, +/// `//wallet`, `//wallet//sso`) has a `Resources.Consumers` record, printing +/// the account and decoded username. Used to confirm a pre-onboarded account. +pub async fn check_identity(people_ws: &str, entropy: &[u8]) -> Result<()> { + let root = derive_root_keypair_from_entropy(entropy) + .map_err(|err| anyhow::anyhow!("invalid entropy: {err}"))?; + let wallet = derive_sr25519_hard_path(entropy, &["wallet"]) + .map_err(|err| anyhow::anyhow!("//wallet derivation failed: {err}"))?; + let wallet_sso = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) + .map_err(|err| anyhow::anyhow!("//wallet//sso derivation failed: {err}"))?; + + for (label, public) in [ + ("", root.public.to_bytes()), + ("//wallet", wallet.public.to_bytes()), + ("//wallet//sso", wallet_sso.public.to_bytes()), + ] { + let key = format!( + "0x{}", + hex::encode(resources_consumers_storage_key(&public)) + ); + let address = product_public_key_to_address(public); + match query_storage(people_ws, &key).await { + Ok(Some(value)) => { + let decoded = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) + .ok() + .and_then(|bytes| decode_people_identity(&bytes).ok()); + let username = decoded + .and_then(|id| id.full_username.or(id.lite_username)) + .unwrap_or_else(|| "".to_string()); + println!("IDENTITY_FOUND path={label} account={address} username={username}"); + } + Ok(None) => println!("IDENTITY_NONE path={label} account={address}"), + Err(err) => println!("IDENTITY_ERROR path={label} account={address} error={err}"), + } + } + Ok(()) +} + +async fn fetch_verifier(client: &reqwest::Client, backend_base: &str) -> Result<[u8; 32]> { + let url = format!("{backend_base}/attester"); + let body: Value = client + .get(&url) + .send() + .await + .with_context(|| format!("GET {url}"))? + .error_for_status()? + .json() + .await + .context("decoding attester response")?; + let hex_value = body + .get("attester") + .and_then(Value::as_str) + .context("attester response missing 'attester' field")?; + let bytes = hex::decode(hex_value.strip_prefix("0x").unwrap_or(hex_value)) + .context("attester is not valid hex")?; + <[u8; 32]>::try_from(bytes) + .map_err(|bytes| anyhow::anyhow!("attester must be 32 bytes, got {}", bytes.len())) +} + +async fn submit_registration( + client: &reqwest::Client, + backend_base: &str, + username_base: &str, + reg: &truapi_server::host_logic::attestation::LiteRegistration, +) -> Result<()> { + let url = format!("{backend_base}/usernames"); + let body = json!({ + "username": username_base, + "candidateAccountId": reg.candidate_account_id, + "candidateSignature": hex0x(®.candidate_signature), + "ringVrfKey": hex0x(®.ring_vrf_key), + "proofOfOwnership": hex0x(®.proof_of_ownership), + "identifierKey": hex0x(®.identifier_key), + "consumerRegistrationSignature": hex0x(®.consumer_registration_signature), + }); + let response = client + .post(&url) + .json(&body) + .send() + .await + .with_context(|| format!("POST {url}"))?; + let status = response.status(); + if status.is_success() { + let text = response.text().await.unwrap_or_default(); + info!(%status, body = %text, "POST /usernames accepted"); + return Ok(()); + } + let text = response.text().await.unwrap_or_default(); + // Already-registered is a soft success; the on-chain poll confirms it. + if text.contains("already") || text.contains("AlreadyRegistered") || text.contains("duplicate") + { + warn!(%status, "username already registered; confirming on-chain"); + return Ok(()); + } + bail!("username registration failed ({status}): {text}"); +} + +fn hex0x(bytes: &[u8]) -> String { + format!("0x{}", hex::encode(bytes)) +} + +async fn wait_for_consumer_record(people_ws: &str, storage_key: &str) -> Result<()> { + // First-time lite registration is backend-async and can take minutes + // (ring onboarding). The record is permanent once written, so later runs + // resolve on the first poll. + const MAX_ATTEMPTS: usize = 90; + for attempt in 1..=MAX_ATTEMPTS { + match query_storage(people_ws, storage_key).await { + Ok(Some(_)) => return Ok(()), + Ok(None) => info!("Resources.Consumers poll {attempt}/{MAX_ATTEMPTS}: empty"), + Err(err) => warn!(%err, "Resources.Consumers poll attempt {attempt} failed"), + } + if attempt < MAX_ATTEMPTS { + tokio::time::sleep(Duration::from_secs(4)).await; + } + } + bail!("Resources.Consumers record did not appear after attestation") +} + +/// One `state_getStorage` request over a fresh RPC connection; returns the value +/// hex when present. +async fn query_storage(people_ws: &str, storage_key: &str) -> Result> { + let rpc = RpcClient::from_insecure_url(people_ws) + .await + .with_context(|| format!("connect {people_ws}"))?; + let value = rpc + .request::("state_getStorage", rpc_params![storage_key]) + .await + .context("rpc state_getStorage")?; + Ok(value.as_str().map(str::to_string)) +} diff --git a/rust/crates/truapi-host-cli/src/chain.rs b/rust/crates/truapi-host-cli/src/chain.rs new file mode 100644 index 00000000..7424da37 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/chain.rs @@ -0,0 +1,155 @@ +//! Native WebSocket `ChainProvider` / `JsonRpcConnection`. +//! +//! The headless hosts reach the real People-chain statement store over +//! WebSocket JSON-RPC (the same node an iOS/web client uses). Every `connect` +//! opens a fresh socket; the runtime's `HostRpcClient` sits on top and speaks +//! statement-store RPC. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use async_trait::async_trait; +use futures::stream::BoxStream; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::{broadcast, mpsc}; +use tokio_stream::wrappers::BroadcastStream; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; +use tracing::debug; +use truapi::v01; +use truapi_platform::{ChainProvider, JsonRpcConnection}; + +use crate::network::ChainEndpoint; + +/// Broadcast backlog for inbound JSON-RPC frames per connection. +const INBOUND_CHANNEL_CAPACITY: usize = 1024; + +/// Chain provider that maps a requested genesis hash to a WebSocket endpoint. +/// +/// The all-zero genesis (the headless SSO sentinel) and any unmapped genesis +/// fall back to the People-chain statement store; the Asset Hub and Bulletin +/// genesis hashes route to their own nodes (opt-in) for live playground +/// examples. +pub struct WsChainProvider { + fallback_url: String, + by_genesis: HashMap<[u8; 32], String>, +} + +impl WsChainProvider { + pub fn new(fallback_url: impl Into, live_chain_endpoints: &[ChainEndpoint]) -> Self { + // The fallback is the People-chain statement store, which serves the + // SSO/identity path directly. Asset Hub routing (for the `Chain/*` + // examples) is opt-in; when off, those genesis requests fall back to the + // People node, which does not serve Asset Hub chainHead, so they fail + // cleanly without disturbing the SSO/signer path. + let by_genesis = if std::env::var("E2E_LIVE_CHAIN").as_deref() == Ok("1") { + live_chain_endpoints + .iter() + .map(|endpoint| (endpoint.genesis, endpoint.ws.to_string())) + .collect() + } else { + HashMap::new() + }; + Self { + fallback_url: fallback_url.into(), + by_genesis, + } + } + + fn url_for(&self, genesis_hash: &[u8; 32]) -> &str { + self.by_genesis + .get(genesis_hash) + .map(String::as_str) + .unwrap_or(&self.fallback_url) + } +} + +#[async_trait] +impl ChainProvider for WsChainProvider { + async fn connect( + &self, + genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + let url = self.url_for(&genesis_hash); + debug!(genesis = %hex::encode(genesis_hash), %url, "chain connect"); + let connection = WsJsonRpcConnection::connect(url) + .await + .map_err(|reason| v01::GenericError { reason })?; + Ok(Box::new(connection)) + } +} + +/// One WebSocket JSON-RPC connection: outbound requests are queued to a writer +/// task, inbound frames are broadcast to every `responses()` stream. +pub struct WsJsonRpcConnection { + outbound: mpsc::UnboundedSender, + inbound: broadcast::Sender, + closed: Arc, +} + +impl WsJsonRpcConnection { + async fn connect(url: &str) -> Result { + let (stream, _response) = connect_async(url) + .await + .map_err(|err| format!("statement-store websocket connect failed: {err}"))?; + let (mut write, mut read) = stream.split(); + let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::(); + let (inbound_tx, _inbound_rx) = broadcast::channel(INBOUND_CHANNEL_CAPACITY); + let closed = Arc::new(AtomicBool::new(false)); + + tokio::spawn(async move { + while let Some(message) = outbound_rx.recv().await { + if write.send(message).await.is_err() { + break; + } + } + let _ = write.close().await; + }); + + let reader_inbound = inbound_tx.clone(); + let reader_closed = closed.clone(); + tokio::spawn(async move { + while let Some(message) = read.next().await { + match message { + Ok(Message::Text(text)) => { + let _ = reader_inbound.send(text.to_string()); + } + Ok(Message::Binary(bytes)) => { + if let Ok(text) = String::from_utf8(bytes.to_vec()) { + let _ = reader_inbound.send(text); + } + } + Ok(Message::Close(_)) | Err(_) => break, + Ok(_) => {} + } + } + reader_closed.store(true, Ordering::Release); + }); + + Ok(Self { + outbound: outbound_tx, + inbound: inbound_tx, + closed, + }) + } +} + +impl JsonRpcConnection for WsJsonRpcConnection { + fn send(&self, request: String) { + if self.closed.load(Ordering::Acquire) { + return; + } + let _ = self.outbound.send(Message::Text(request)); + } + + fn responses(&self) -> BoxStream<'static, String> { + BroadcastStream::new(self.inbound.subscribe()) + .filter_map(|item| async move { item.ok() }) + .boxed() + } + + fn close(&self) { + self.closed.store(true, Ordering::Release); + } +} diff --git a/rust/crates/truapi-host-cli/src/frame_server.rs b/rust/crates/truapi-host-cli/src/frame_server.rs new file mode 100644 index 00000000..131f7b9b --- /dev/null +++ b/rust/crates/truapi-host-cli/src/frame_server.rs @@ -0,0 +1,130 @@ +//! Product-frame WebSocket bridge for the pairing host. +//! +//! Each WebSocket connection is one product: inbound binary frames are pushed +//! into a [`ProductRuntime`] and its outgoing frames are written back as +//! binary messages. One binary WS message carries exactly one SCALE +//! `ProtocolMessage`, matching the browser transport's framing. + +use std::net::SocketAddr; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use futures_util::{SinkExt, StreamExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::mpsc; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::tungstenite::Message; +use tracing::{debug, info}; +use truapi_server::{ + FrameSink, PairingHostRuntime, ProductContext, ProductRuntime, SigningHostRuntime, +}; + +pub trait ProductRuntimeFactory: Send + Sync + 'static { + fn product_runtime(&self, product: ProductContext, sink: Arc) -> ProductRuntime; +} + +impl ProductRuntimeFactory for PairingHostRuntime { + fn product_runtime(&self, product: ProductContext, sink: Arc) -> ProductRuntime { + PairingHostRuntime::product_runtime(self, product, sink) + } +} + +impl ProductRuntimeFactory for SigningHostRuntime { + fn product_runtime(&self, product: ProductContext, sink: Arc) -> ProductRuntime { + SigningHostRuntime::product_runtime(self, product, sink) + } +} + +/// Frame sink that writes each outgoing protocol frame as one binary message. +struct WsFrameSink { + outbound: mpsc::UnboundedSender, +} + +impl FrameSink for WsFrameSink { + fn emit_frame(&self, frame: Vec) { + let _ = self.outbound.send(Message::Binary(frame)); + } +} + +/// Bind the product-frame listener on `addr`. +pub async fn bind(addr: SocketAddr) -> Result { + TcpListener::bind(addr) + .await + .with_context(|| format!("frame server failed to bind {addr}")) +} + +/// Accept product-frame connections on `listener` for `product_id` until +/// cancelled. +/// +/// The product dispatch future is `!Send` (matching the single-threaded wasm +/// runtime), so connections are driven with `spawn_local`; callers must run +/// this inside a `tokio::task::LocalSet`. The runtime's own subscription work +/// is `Send` and still runs on the multi-thread pool via the tokio spawner. +pub async fn accept_loop( + runtime: Arc, + product_id: String, + listener: TcpListener, +) -> Result<()> { + let bound = listener.local_addr()?; + info!(%bound, %product_id, "product frame server listening"); + loop { + let (stream, peer) = listener.accept().await?; + let runtime = runtime.clone(); + let product_id = product_id.clone(); + tokio::task::spawn_local(async move { + if let Err(err) = serve_connection(runtime, product_id, stream).await { + debug!(%peer, %err, "frame connection ended"); + } + }); + } +} + +async fn serve_connection( + runtime: Arc, + product_id: String, + stream: TcpStream, +) -> Result<()> { + let ws = accept_async(stream).await?; + let (mut write, mut read) = ws.split(); + let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::(); + + let writer = tokio::spawn(async move { + while let Some(message) = outbound_rx.recv().await { + if write.send(message).await.is_err() { + break; + } + } + }); + + let product = ProductContext::new(product_id) + .map_err(|err| anyhow::anyhow!("invalid product id: {err}"))?; + let sink = Arc::new(WsFrameSink { + outbound: outbound_tx.clone(), + }); + let product_runtime = runtime.product_runtime(product, sink); + + while let Some(message) = read.next().await { + match message { + Ok(Message::Binary(bytes)) => { + if let Err(err) = product_runtime.receive_frame(bytes.to_vec()).await { + debug!(%err, "product runtime rejected frame"); + } + } + Ok(Message::Text(text)) => { + if let Err(err) = product_runtime + .receive_frame(text.as_bytes().to_vec()) + .await + { + debug!(%err, "product runtime rejected text frame"); + } + } + Ok(Message::Close(_)) | Err(_) => break, + Ok(_) => {} + } + } + + product_runtime.dispose(); + drop(outbound_tx); + let _ = writer.await; + Ok(()) +} diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs new file mode 100644 index 00000000..bcc8b465 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -0,0 +1,791 @@ +//! Headless TrUAPI hosts for local end-to-end testing. +//! +//! Two roles, one binary, pairing over the real People-chain statement store: +//! - `pairing-host`: a seedless host that presents a pairing deeplink and +//! serves product frames over WebSocket (the surface a product/test driver +//! talks to). +//! - `signing-host`: a wallet-local host that answers a pairing deeplink and +//! auto-signs, replacing the external signing-bot in e2e. +//! +//! Plus `alloc-check`, a diagnostic for on-chain statement-store allowance. + +mod accounts; +mod alloc; +mod attestation; +mod chain; +mod frame_server; +mod network; +mod platform; +mod script_runner; + +use std::future::Future; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::process::ExitStatus; +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use clap::{Args, Parser, Subcommand}; +use futures::future::BoxFuture; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use truapi_platform::{HostInfo, PlatformInfo}; +use truapi_server::subscription::Spawner; +use truapi_server::{PairingHostConfig, PairingHostRuntime, SigningHostConfig, SigningHostRuntime}; + +use crate::accounts::{ResolveSignerConfig, ResolvedSigner}; +use crate::network::{Network, NetworkConfig}; +use crate::platform::{ApprovalPolicy, CliPlatform}; + +/// Default product served by the pairing host's frame endpoint. Product ids +/// must be a `.dot` name or a `localhost` identifier (host-spec product id). +const DEFAULT_PRODUCT_ID: &str = "headless-playground.dot"; +/// Default product-frame address for the pairing host. +const DEFAULT_PAIRING_FRAME_LISTEN: &str = "127.0.0.1:9955"; +/// Default product-frame address for the signing host. +const DEFAULT_SIGNING_FRAME_LISTEN: &str = "127.0.0.1:9956"; +/// Deeplink scheme advertised by the pairing host. +const DEEPLINK_SCHEME: &str = "polkadotapp"; + +#[derive(Parser)] +#[command(name = "truapi-host", about = "Headless TrUAPI hosts for e2e testing")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Run a seedless pairing host for product scripts or interactive pairing. + /// + /// With `--script`, exits with the script's status. Without it, stays in an + /// interactive shell where scripts can be run repeatedly. + PairingHost(PairingHostArgs), + /// Run a wallet-local signing host for scripts or pairing deeplinks. + /// + /// Owns signer identity, auto-manages accounts when no mnemonic/account is + /// specified, and can accept pairing deeplinks. With `--script`, exits with + /// the script's status; otherwise stays interactive. + SigningHost(SigningHostArgs), + /// Probe the People chain for a mnemonic's registered identity/username. + IdentityCheck { + /// BIP-39 mnemonic to probe. + #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC")] + mnemonic: String, + /// Network preset to probe. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, + }, + /// Check (and optionally submit) a statement-store allowance registration + /// against the real People chain: ring membership, the chosen slot, and + /// (with `--submit`) the `set_statement_store_account` extrinsic. + AllocCheck { + /// BIP-39 mnemonic proving LitePeople ring membership. + #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC")] + mnemonic: String, + /// Network preset to use for People-chain RPC. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, + /// Target account (hex, 32 bytes) to grant allowance to. Defaults to + /// all-zero (read-only slot scan only). + #[arg(long)] + target: Option, + /// How many rings back from the current index to scan for our member. + #[arg(long, default_value_t = 8)] + lookback: u32, + /// Submit the extrinsic instead of only checking membership + slot. + #[arg(long)] + submit: bool, + }, +} + +#[derive(Args)] +struct PairingHostArgs { + /// Product script to run (JS/TS). If omitted, start an interactive shell. + #[arg(long)] + script: Option, + /// Product id the host serves; scopes storage and product accounts. + #[arg(long = "product-id", default_value = DEFAULT_PRODUCT_ID)] + product_id: String, + /// Address to serve product frames on. + #[arg(long, default_value = DEFAULT_PAIRING_FRAME_LISTEN)] + frame_listen: SocketAddr, + /// Root directory for CLI-managed host state. + #[arg(long = "base-path", env = "TRUAPI_HOST_BASE_PATH")] + base_path: Option, + /// Network preset that supplies all RPC/backend/genesis config. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, + /// Approve every confirmation without prompting on the CLI. + #[arg(long)] + auto_accept: bool, +} + +#[derive(Args)] +struct SigningHostArgs { + /// Product script to run (JS/TS). If omitted, start an interactive shell. + #[arg(long)] + script: Option, + /// Product id used by scripts and product-scoped operations. + #[arg(long = "product-id", default_value = DEFAULT_PRODUCT_ID)] + product_id: String, + /// Pairing deeplink to answer. If omitted, no pairing is accepted + /// automatically; interactive mode lets you paste one later. + #[arg(long)] + deeplink: Option, + /// BIP-39 mnemonic for the wallet root. If omitted, the + /// `HOST_CLI_SIGNER_MNEMONIC` env var is used when set. Any mnemonic + /// bypasses account auto-management. + #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC")] + mnemonic: Option, + /// Named stored account to use. Omit this and `--mnemonic` to auto-select + /// or create a usable account. + #[arg(long)] + account: Option, + /// Prefix for newly-created lite usernames in auto-account mode. + #[arg(long = "lite-username-prefix")] + lite_username_prefix: Option, + /// Root directory for CLI-managed account and host state. + #[arg(long = "base-path", env = "TRUAPI_HOST_BASE_PATH")] + base_path: Option, + /// Network preset that supplies all RPC/backend/genesis config. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, + /// Address to serve product frames on when running scripts. + #[arg(long, default_value = DEFAULT_SIGNING_FRAME_LISTEN)] + frame_listen: SocketAddr, + /// Approve every confirmation without prompting on the CLI. + #[arg(long)] + auto_accept: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Install a rustls crypto provider so `wss://` chain connections work; + // rustls 0.23 panics without a process-level default provider. + let _ = rustls::crypto::ring::default_provider().install_default(); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_writer(std::io::stderr) + .init(); + + match Cli::parse().command { + Command::PairingHost(args) => run_pairing_host(args).await, + Command::SigningHost(args) => run_signing_host(args).await, + Command::IdentityCheck { mnemonic, network } => { + let entropy = bip39::Mnemonic::parse(mnemonic.trim()) + .context("invalid BIP-39 mnemonic")? + .to_entropy(); + attestation::check_identity(network.config().people_ws, &entropy).await + } + Command::AllocCheck { + mnemonic, + network, + target, + lookback, + submit, + } => run_alloc_check(mnemonic, network.config(), target, lookback, submit).await, + } +} + +/// Check statement-store allowance for a mnemonic: ring membership, the chosen +/// slot, and (with `submit`) the `set_statement_store_account` extrinsic. +async fn run_alloc_check( + mnemonic: String, + network: NetworkConfig, + target: Option, + lookback: u32, + submit: bool, +) -> Result<()> { + let entropy = bip39::Mnemonic::parse(mnemonic.trim()) + .context("invalid BIP-39 mnemonic")? + .to_entropy(); + let bandersnatch = alloc::bandersnatch_entropy(&entropy); + + if submit && target.is_none() { + bail!("--target is required with --submit; the all-zero default is read-only"); + } + + let target = match target { + Some(hex_str) => { + let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(&hex_str)) + .context("invalid --target hex")?; + <[u8; 32]>::try_from(bytes.as_slice()) + .map_err(|_| anyhow::anyhow!("--target must be 32 bytes"))? + } + None => [0u8; 32], + }; + + let rpc = alloc::rpc::RpcClient::connect(network.people_ws).await?; + let metadata = alloc::fetch_metadata(&rpc) + .await + .map_err(anyhow::Error::msg)?; + let chain_state = alloc::fetch_chain_state(&rpc) + .await + .map_err(anyhow::Error::msg)?; + println!( + "chain: specVersion={} txVersion={} genesis=0x{}", + chain_state.spec_version, + chain_state.transaction_version, + hex::encode(chain_state.genesis_hash), + ); + + let member = alloc::proof::member_key(bandersnatch); + println!("bandersnatch member=0x{}", hex::encode(member)); + let current_ring = alloc::ring::read_current_ring_index(&rpc) + .await + .map_err(anyhow::Error::msg)?; + println!("current ring index={current_ring}"); + let ring = alloc::find_including_ring(&rpc, &metadata, bandersnatch, lookback) + .await + .map_err(anyhow::Error::msg)?; + match &ring { + Some(r) => println!( + "member INCLUDED in ring_index={} exponent={} included_members={}", + r.ring_index, + r.exponent, + r.members.len(), + ), + None => println!("member NOT in the last {lookback} rings (onboarding pending)"), + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system clock before UNIX epoch")? + .as_secs(); + let period = alloc::slot::current_period(now); + println!("period={period} target=0x{}", hex::encode(target)); + + match alloc::slot::scan_slot_excluding(&rpc, &metadata, bandersnatch, period, &target, &[]) + .await + { + Ok(alloc::slot::SlotSelection::Free(seq)) => println!("slot scan: free seq={seq}"), + Ok(alloc::slot::SlotSelection::AlreadyAllocated(seq)) => { + println!("slot scan: target already allocated at seq={seq}") + } + Err(err) => println!("slot scan: {err}"), + } + + if submit { + let ring = ring.ok_or_else(|| anyhow::anyhow!("cannot submit: member not in any ring"))?; + match alloc::register_statement_account( + &rpc, + &metadata, + &chain_state, + bandersnatch, + &target, + period, + &ring, + ) + .await + { + Ok(alloc::RegistrationOutcome::Registered { + block_hash, + seq, + ring_index, + }) => println!("REGISTERED seq={seq} ring_index={ring_index} block={block_hash}"), + Ok(alloc::RegistrationOutcome::AlreadyAllocated { seq }) => { + println!("already allocated at seq={seq}") + } + Err(err) => bail!("registration failed: {err}"), + } + } + + Ok(()) +} + +/// Map the `--auto-accept` flag to an approval policy: auto-accept, or prompt +/// each confirmation on the CLI. +fn approval_policy(auto_accept: bool) -> ApprovalPolicy { + if auto_accept { + ApprovalPolicy::AutoAccept + } else { + ApprovalPolicy::Prompt + } +} + +/// Spawner that runs runtime futures on the tokio runtime, so their WebSocket +/// connects and timers have a reactor. +fn tokio_spawner() -> Spawner { + Arc::new(|fut: BoxFuture<'static, ()>| { + tokio::spawn(fut); + }) +} + +fn host_info(name: &str) -> HostInfo { + HostInfo { + name: name.to_string(), + icon: None, + version: Some(env!("CARGO_PKG_VERSION").to_string()), + } +} + +fn platform_info() -> PlatformInfo { + PlatformInfo { + kind: Some("cli".to_string()), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + } +} + +async fn run_pairing_host(args: PairingHostArgs) -> Result<()> { + let network = args.network.config(); + let base_path = args.base_path.unwrap_or_else(default_base_path); + let product_id = args.product_id; + let platform = CliPlatform::new( + network.people_ws, + network.live_chain_endpoints, + Some(role_state_path(&base_path, network, "pairing-host")), + approval_policy(args.auto_accept), + ); + // SSO and identity both run over the real People chain, so usernames always + // resolve from `Resources.Consumers` (host-spec G). + let config = PairingHostConfig::new( + host_info("Headless Pairing Host"), + platform_info(), + network.people_genesis, + network.bulletin_genesis, + DEEPLINK_SCHEME.to_string(), + ) + .context("invalid pairing host config")? + .with_identity_chain_genesis_hash(network.people_genesis); + let runtime = Arc::new(PairingHostRuntime::new(platform, config, tokio_spawner())); + + let listener = frame_server::bind(args.frame_listen).await?; + let frame_url = format!("ws://{}", listener.local_addr()?); + println!("FRAMES_LISTENING {frame_url}"); + let runtime: Arc = runtime; + + if let Some(script) = args.script { + let script_product_id = product_id.clone(); + let script_frame_url = frame_url.clone(); + let status = with_frame_server(runtime, product_id, listener, async move { + script_runner::run(&script_frame_url, &script_product_id, &script).await + }) + .await?; + std::process::exit(status.code().unwrap_or(1)); + } + + with_frame_server(runtime, product_id.clone(), listener, async move { + pairing_interactive_loop(frame_url, product_id).await + }) + .await +} + +async fn run_signing_host(args: SigningHostArgs) -> Result<()> { + validate_signing_args(&args)?; + let network = args.network.config(); + let base_path = args.base_path.clone().unwrap_or_else(default_base_path); + let mut session = start_signing_host(&args, base_path, network).await?; + let listener = frame_server::bind(args.frame_listen).await?; + let frame_url = format!("ws://{}", listener.local_addr()?); + println!("FRAMES_LISTENING {frame_url}"); + let runtime_for_frames: Arc = session.runtime.clone(); + + if let Some(script) = args.script { + let product_id = args.product_id.clone(); + let script_product_id = product_id.clone(); + let script_frame_url = frame_url.clone(); + let initial_deeplink = args.deeplink.clone(); + let status = with_frame_server(runtime_for_frames, product_id, listener, async move { + let mut responder = None; + if let Some(deeplink) = initial_deeplink { + prepare_pairing_response(&mut session, &deeplink).await?; + let runtime = session.runtime.clone(); + responder = Some(tokio::spawn(async move { + match runtime.respond_to_pairing(&deeplink).await { + Ok(exit) => println!("SIGNING_HOST_EXIT {exit:?}"), + Err(err) => eprintln!("SIGNING_HOST_ERROR {}", err.reason), + } + })); + } + ensure_signer(&mut session).await?; + let status = script_runner::run(&script_frame_url, &script_product_id, &script).await?; + if let Some(responder) = responder { + responder.abort(); + } + Ok::(status) + }) + .await?; + std::process::exit(status.code().unwrap_or(1)); + } + + let product_id = args.product_id.clone(); + let initial_deeplink = args.deeplink.clone(); + with_frame_server( + runtime_for_frames, + product_id.clone(), + listener, + async move { + if let Some(deeplink) = initial_deeplink { + respond_to_deeplink(&mut session, deeplink).await?; + } + signing_interactive_loop(&mut session, frame_url, product_id).await + }, + ) + .await +} + +struct SigningHostSession { + runtime: Arc, + signer: Option, + base_path: PathBuf, + network: NetworkConfig, + mnemonic: Option, + account: Option, + lite_username_prefix: Option, +} + +async fn start_signing_host( + args: &SigningHostArgs, + base_path: PathBuf, + network: NetworkConfig, +) -> Result { + let platform = CliPlatform::new( + network.people_ws, + network.live_chain_endpoints, + Some(role_state_path(&base_path, network, "signing-host")), + approval_policy(args.auto_accept), + ); + let config = SigningHostConfig::new( + host_info("Headless Signing Host"), + platform_info(), + network.people_genesis, + network.bulletin_genesis, + ) + .context("invalid signing host config")?; + let runtime = Arc::new(SigningHostRuntime::new(platform, config, tokio_spawner())); + + Ok(SigningHostSession { + runtime, + signer: None, + base_path, + network, + mnemonic: normalized(args.mnemonic.clone()), + account: normalized(args.account.clone()), + lite_username_prefix: normalized(args.lite_username_prefix.clone()), + }) +} + +fn validate_signing_args(args: &SigningHostArgs) -> Result<()> { + let mnemonic = normalized(args.mnemonic.clone()); + let account = normalized(args.account.clone()); + let prefix = normalized(args.lite_username_prefix.clone()); + if mnemonic.is_some() && account.is_some() { + bail!("--account cannot be used when --mnemonic or HOST_CLI_SIGNER_MNEMONIC is set"); + } + if mnemonic.is_some() && prefix.is_some() { + bail!( + "--lite-username-prefix cannot be used when --mnemonic or HOST_CLI_SIGNER_MNEMONIC is set" + ); + } + if account.is_some() && prefix.is_some() { + bail!("--lite-username-prefix only applies when --account is omitted"); + } + Ok(()) +} + +fn normalized(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }) +} + +async fn with_frame_server( + runtime: Arc, + product_id: String, + listener: tokio::net::TcpListener, + body: Fut, +) -> Result +where + Fut: Future>, +{ + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + let server = + tokio::task::spawn_local(frame_server::accept_loop(runtime, product_id, listener)); + let result = body.await; + server.abort(); + result + }) + .await +} + +async fn ensure_signer(session: &mut SigningHostSession) -> Result<()> { + if session.signer.is_some() { + return Ok(()); + } + session.signer = Some( + accounts::resolve_signer(ResolveSignerConfig { + base_path: &session.base_path, + network: session.network, + mnemonic: session.mnemonic.clone(), + account: session.account.clone(), + lite_username_prefix: session.lite_username_prefix.clone(), + }) + .await?, + ); + activate_current_signer(session).await +} + +async fn activate_current_signer(session: &mut SigningHostSession) -> Result<()> { + let signer = session + .signer + .as_ref() + .context("signer has not been resolved")?; + session + .runtime + .activate_local_session_with_identity(signer.entropy.clone(), signer.lite_username.clone()) + .await + .map_err(|err| anyhow::anyhow!("failed to activate local session: {}", err.reason))?; + println!("SIGNING_HOST_READY"); + Ok(()) +} + +async fn prepare_pairing_response(session: &mut SigningHostSession, deeplink: &str) -> Result<()> { + let mut attempts = 0usize; + loop { + ensure_signer(session).await?; + let (entropy, auto_managed, account_name) = { + let signer = session + .signer + .as_ref() + .context("signer has not been resolved")?; + ( + signer.entropy.clone(), + signer.auto_managed, + signer.account_name.clone(), + ) + }; + match register_pairing_allowances(session.network.people_ws, &entropy, deeplink).await { + Ok(()) => return Ok(()), + Err(err) if auto_managed && is_statement_slot_exhaustion(&err) => { + attempts += 1; + if attempts > 8 { + return Err(err); + } + if let Some(name) = &account_name { + let period = accounts::current_statement_period()?; + accounts::mark_account_exhausted( + &session.base_path, + session.network.id, + name, + period, + )?; + println!("SIGNING_HOST_ACCOUNT_EXHAUSTED {name} period={period}"); + } + session.signer = Some( + accounts::resolve_signer(ResolveSignerConfig { + base_path: &session.base_path, + network: session.network, + mnemonic: None, + account: None, + lite_username_prefix: session.lite_username_prefix.clone(), + }) + .await?, + ); + activate_current_signer(session).await?; + } + Err(err) => return Err(err), + } + } +} + +fn is_statement_slot_exhaustion(err: &anyhow::Error) -> bool { + err.to_string().contains("no free StatementStore slot") +} + +async fn respond_to_deeplink(session: &mut SigningHostSession, deeplink: String) -> Result<()> { + prepare_pairing_response(session, &deeplink).await?; + let exit = session + .runtime + .respond_to_pairing(&deeplink) + .await + .map_err(|err| anyhow::anyhow!("pairing failed: {}", err.reason))?; + println!("SIGNING_HOST_EXIT {exit:?}"); + Ok(()) +} + +/// Grant on-chain statement-store allowance to the two accounts that submit +/// statements during pairing: the signing host's own `//wallet//sso` account +/// and the pairing host's per-pairing device key (from the deeplink). Proves +/// the signing account's LitePeople ring membership once and reuses it. +async fn register_pairing_allowances( + statement_store_url: &str, + entropy: &[u8], + deeplink: &str, +) -> Result<()> { + use truapi_server::host_logic::product_account::derive_sr25519_hard_path; + use truapi_server::host_logic::sso::pairing::{ + VersionedHandshakeProposal, decode_pairing_deeplink, + }; + + let wallet_sso = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) + .map_err(|e| anyhow::anyhow!("//wallet//sso derivation failed: {e}"))? + .public + .to_bytes(); + let VersionedHandshakeProposal::V2(proposal) = + decode_pairing_deeplink(deeplink).map_err(anyhow::Error::msg)?; + let device = proposal.device.statement_account_id; + + let bandersnatch = alloc::bandersnatch_entropy(entropy); + let rpc = alloc::rpc::RpcClient::connect(statement_store_url).await?; + let metadata = alloc::fetch_metadata(&rpc) + .await + .map_err(anyhow::Error::msg)?; + let chain_state = alloc::fetch_chain_state(&rpc) + .await + .map_err(anyhow::Error::msg)?; + + // The signing account may be in an old ring, so scan back to genesis. + let current = alloc::ring::read_current_ring_index(&rpc) + .await + .map_err(anyhow::Error::msg)?; + let ring = alloc::find_including_ring(&rpc, &metadata, bandersnatch, current) + .await + .map_err(anyhow::Error::msg)? + .ok_or_else(|| { + anyhow::anyhow!( + "signing account is not a LitePeople ring member; cannot grant allowance" + ) + })?; + println!( + "SIGNING_HOST_RING ring_index={} members={}", + ring.ring_index, + ring.members.len() + ); + + let period = alloc::slot::current_period( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system clock before UNIX epoch")? + .as_secs(), + ); + + for (label, target) in [("wallet-sso", wallet_sso), ("device", device)] { + println!("SIGNING_HOST_ALLOWANCE {label} checking"); + let outcome = alloc::register_statement_account( + &rpc, + &metadata, + &chain_state, + bandersnatch, + &target, + period, + &ring, + ) + .await + .map_err(|e| anyhow::anyhow!("allowance registration for {label} failed: {e}"))?; + match outcome { + alloc::RegistrationOutcome::Registered { + block_hash, seq, .. + } => println!("SIGNING_HOST_ALLOWANCE {label} seq={seq} block={block_hash}"), + alloc::RegistrationOutcome::AlreadyAllocated { seq } => { + println!("SIGNING_HOST_ALLOWANCE {label} already-allocated seq={seq}") + } + } + } + Ok(()) +} + +async fn pairing_interactive_loop(frame_url: String, product_id: String) -> Result<()> { + println!("PAIRING_HOST_INTERACTIVE commands: script , quit"); + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + loop { + print_prompt("pairing-host> ").await?; + let Some(line) = lines.next_line().await? else { + return Ok(()); + }; + let line = line.trim(); + if line.is_empty() { + continue; + } + if is_quit(line) { + return Ok(()); + } + let Some(script) = script_command(line) else { + println!("unknown command; use: script , quit"); + continue; + }; + let status = script_runner::run(&frame_url, &product_id, &script).await?; + println!("SCRIPT_EXIT {}", status.code().unwrap_or(1)); + } +} + +async fn signing_interactive_loop( + session: &mut SigningHostSession, + frame_url: String, + product_id: String, +) -> Result<()> { + println!("SIGNING_HOST_INTERACTIVE commands: deeplink , script , quit"); + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + loop { + print_prompt("signing-host> ").await?; + let Some(line) = lines.next_line().await? else { + return Ok(()); + }; + let line = line.trim(); + if line.is_empty() { + continue; + } + if is_quit(line) { + return Ok(()); + } + if let Some(deeplink) = deeplink_command(line) { + respond_to_deeplink(session, deeplink).await?; + continue; + } + if let Some(script) = script_command(line) { + ensure_signer(session).await?; + let status = script_runner::run(&frame_url, &product_id, &script).await?; + println!("SCRIPT_EXIT {}", status.code().unwrap_or(1)); + continue; + } + println!("unknown command; use: deeplink , script , quit"); + } +} + +async fn print_prompt(prompt: &str) -> Result<()> { + let mut stdout = tokio::io::stdout(); + stdout.write_all(prompt.as_bytes()).await?; + stdout.flush().await?; + Ok(()) +} + +fn is_quit(line: &str) -> bool { + matches!(line, "quit" | "exit" | "q") +} + +fn script_command(line: &str) -> Option { + line.strip_prefix("script ") + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(PathBuf::from) +} + +fn deeplink_command(line: &str) -> Option { + if line.starts_with("polkadotapp://pair?") { + return Some(line.to_string()); + } + line.strip_prefix("deeplink ") + .map(str::trim) + .filter(|deeplink| !deeplink.is_empty()) + .map(str::to_string) +} + +fn default_base_path() -> PathBuf { + if let Some(path) = std::env::var_os("XDG_STATE_HOME") { + return PathBuf::from(path).join("truapi-host"); + } + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home).join(".local/state/truapi-host"); + } + PathBuf::from(".truapi-host") +} + +fn role_state_path(base_path: &std::path::Path, network: NetworkConfig, role: &str) -> PathBuf { + base_path.join(network.id).join(role) +} diff --git a/rust/crates/truapi-host-cli/src/network.rs b/rust/crates/truapi-host-cli/src/network.rs new file mode 100644 index 00000000..c8519b6b --- /dev/null +++ b/rust/crates/truapi-host-cli/src/network.rs @@ -0,0 +1,89 @@ +use clap::ValueEnum; + +/// Supported live network presets for the headless hosts. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] +pub enum Network { + #[value(name = "paseo-next-v2")] + #[default] + PaseoNextV2, +} + +impl Network { + pub fn config(self) -> NetworkConfig { + match self { + Self::PaseoNextV2 => NetworkConfig { + id: "paseo-next-v2", + identity_backend_base: "https://identity-backend-next.parity-testnet.parity.io/api/v1", + people_ws: "wss://paseo-people-next-system-rpc.polkadot.io", + bulletin_ws: "wss://paseo-bulletin-next-rpc.polkadot.io", + people_genesis: hex_literal_genesis( + "c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5", + ), + bulletin_genesis: hex_literal_genesis( + "8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22", + ), + live_chain_endpoints: PASEO_NEXT_V2_CHAIN_ENDPOINTS, + }, + } + } +} + +const PASEO_NEXT_V2_CHAIN_ENDPOINTS: &[ChainEndpoint] = &[ + ChainEndpoint { + genesis: hex_literal_genesis( + "bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f", + ), + ws: "wss://paseo-asset-hub-next-rpc.polkadot.io", + }, + ChainEndpoint { + genesis: hex_literal_genesis( + "c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5", + ), + ws: "wss://paseo-people-next-system-rpc.polkadot.io", + }, + ChainEndpoint { + genesis: hex_literal_genesis( + "8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22", + ), + ws: "wss://paseo-bulletin-next-rpc.polkadot.io", + }, +]; + +/// Resolved RPC/backend/genesis values for one network preset. +#[derive(Debug, Clone, Copy)] +pub struct NetworkConfig { + pub id: &'static str, + pub identity_backend_base: &'static str, + pub people_ws: &'static str, + #[allow(dead_code)] + pub bulletin_ws: &'static str, + pub people_genesis: [u8; 32], + pub bulletin_genesis: [u8; 32], + pub live_chain_endpoints: &'static [ChainEndpoint], +} + +#[derive(Debug, Clone, Copy)] +pub struct ChainEndpoint { + pub genesis: [u8; 32], + pub ws: &'static str, +} + +/// Decode a 64-char hex genesis at compile time. +const fn hex_literal_genesis(hex: &str) -> [u8; 32] { + let bytes = hex.as_bytes(); + let mut out = [0u8; 32]; + let mut i = 0; + while i < 32 { + out[i] = hex_nibble(bytes[i * 2]) << 4 | hex_nibble(bytes[i * 2 + 1]); + i += 1; + } + out +} + +const fn hex_nibble(c: u8) -> u8 { + match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + _ => panic!("invalid hex digit in genesis literal"), + } +} diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs new file mode 100644 index 00000000..d1a87f0a --- /dev/null +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -0,0 +1,383 @@ +//! `Platform` implementation for the headless hosts. +//! +//! In-memory product and core storage, a WebSocket chain provider pointed at +//! the real People-chain statement store, and a [`UserConfirmation`] that +//! either auto-accepts or prompts on the CLI (the web/iOS "sign?" modal). +//! Auth-state transitions are published on a channel so the CLI can print the +//! pairing deeplink and observe connection status. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use futures::stream::{self, BoxStream}; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::Mutex as AsyncMutex; +use truapi::v01; +use truapi_platform::{ + AuthState, ChainProvider, CoreStorage, CoreStorageKey, Features, JsonRpcConnection, Navigation, + Notifications, Permissions, PreimageHost, ProductStorage, ThemeHost, UserConfirmation, + UserConfirmationReview, +}; + +use crate::chain::WsChainProvider; + +/// How the host answers confirmation prompts (the web/iOS "sign?" modals). +#[derive(Clone, Copy)] +pub enum ApprovalPolicy { + /// Approve every sensitive action without prompting (`--auto-accept`). + AutoAccept, + /// Prompt on the CLI (y/n) for every sensitive action. + Prompt, +} + +/// Headless-host platform shared by both roles. +pub struct CliPlatform { + chain: WsChainProvider, + product_storage: Mutex>>, + core_storage: Mutex, Vec>>, + product_storage_path: Option, + core_storage_path: Option, + preimages: Mutex, Vec>>, + approval: ApprovalPolicy, + /// Serializes interactive CLI prompts so concurrent confirmations don't + /// interleave on stdin. + prompt_lock: AsyncMutex<()>, +} + +impl CliPlatform { + /// Build a platform whose chain provider connects to the network's People + /// chain and whose optional state directory backs product/core storage. + pub fn new( + statement_store_url: impl Into, + live_chain_endpoints: &[crate::network::ChainEndpoint], + storage_dir: Option, + approval: ApprovalPolicy, + ) -> Arc { + let (product_storage_path, core_storage_path) = storage_dir + .as_ref() + .map(|dir| { + let _ = fs::create_dir_all(dir); + ( + Some(dir.join("product-storage.json")), + Some(dir.join("core-storage.json")), + ) + }) + .unwrap_or((None, None)); + let product_storage = product_storage_path + .as_deref() + .map(load_string_map) + .unwrap_or_default(); + let core_storage = core_storage_path + .as_deref() + .map(load_hex_key_map) + .unwrap_or_default(); + + Arc::new(Self { + chain: WsChainProvider::new(statement_store_url, live_chain_endpoints), + product_storage: Mutex::new(product_storage), + core_storage: Mutex::new(core_storage), + product_storage_path, + core_storage_path, + preimages: Mutex::new(HashMap::new()), + approval, + prompt_lock: AsyncMutex::new(()), + }) + } + + fn core_key(key: &CoreStorageKey) -> Vec { + use parity_scale_codec::Encode; + key.encode() + } + + fn persist_product_storage(&self) -> Result<(), String> { + let Some(path) = &self.product_storage_path else { + return Ok(()); + }; + let storage = self + .product_storage + .lock() + .expect("product storage mutex poisoned"); + save_string_map(path, &storage) + } + + fn persist_core_storage(&self) -> Result<(), String> { + let Some(path) = &self.core_storage_path else { + return Ok(()); + }; + let storage = self + .core_storage + .lock() + .expect("core storage mutex poisoned"); + save_hex_key_map(path, &storage) + } + + /// Resolve a confirmation: auto-accept, or prompt y/n on the CLI. + async fn decide(&self, action: &str, detail: String) -> bool { + match self.approval { + ApprovalPolicy::AutoAccept => { + eprintln!("[auto-accept] {action}: {detail}"); + true + } + ApprovalPolicy::Prompt => { + let _guard = self.prompt_lock.lock().await; + prompt_yes_no(action, &detail).await + } + } + } +} + +/// Print a confirmation and read a y/n answer from the CLI (default: no). +async fn prompt_yes_no(action: &str, detail: &str) -> bool { + let mut stdout = tokio::io::stdout(); + let _ = stdout + .write_all( + format!( + "\n\u{2500}\u{2500} confirm: {action} \u{2500}\u{2500}\n{detail}\nApprove? [y/N] " + ) + .as_bytes(), + ) + .await; + let _ = stdout.flush().await; + let mut line = String::new(); + let mut reader = BufReader::new(tokio::io::stdin()); + if reader.read_line(&mut line).await.unwrap_or(0) == 0 { + return false; + } + matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes") +} + +#[async_trait] +impl ProductStorage for CliPlatform { + async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { + Ok(self + .product_storage + .lock() + .expect("product storage mutex poisoned") + .get(&key) + .cloned()) + } + + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + { + self.product_storage + .lock() + .expect("product storage mutex poisoned") + .insert(key, value); + } + self.persist_product_storage() + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { + { + self.product_storage + .lock() + .expect("product storage mutex poisoned") + .remove(&key); + } + self.persist_product_storage() + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } +} + +#[async_trait] +impl CoreStorage for CliPlatform { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + Ok(self + .core_storage + .lock() + .expect("core storage mutex poisoned") + .get(&Self::core_key(&key)) + .cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + { + self.core_storage + .lock() + .expect("core storage mutex poisoned") + .insert(Self::core_key(&key), value); + } + self.persist_core_storage() + .map_err(|reason| v01::GenericError { reason }) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), v01::GenericError> { + { + self.core_storage + .lock() + .expect("core storage mutex poisoned") + .remove(&Self::core_key(&key)); + } + self.persist_core_storage() + .map_err(|reason| v01::GenericError { reason }) + } +} + +#[async_trait] +impl ChainProvider for CliPlatform { + async fn connect( + &self, + genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + self.chain.connect(genesis_hash).await + } +} + +#[async_trait] +impl Navigation for CliPlatform { + async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { + tracing::info!(%url, "navigate_to"); + Ok(()) + } +} + +#[async_trait] +impl Notifications for CliPlatform { + async fn push_notification( + &self, + _notification: v01::HostPushNotificationRequest, + ) -> Result { + Ok(v01::HostPushNotificationResponse { id: 1 }) + } +} + +#[async_trait] +impl Permissions for CliPlatform { + async fn device_permission( + &self, + request: v01::HostDevicePermissionRequest, + ) -> Result { + let granted = self + .decide("device permission", format!("{request:?}")) + .await; + Ok(v01::HostDevicePermissionResponse { granted }) + } + + async fn remote_permission( + &self, + request: v01::RemotePermissionRequest, + ) -> Result { + let granted = self + .decide("remote permission", format!("{request:?}")) + .await; + Ok(v01::RemotePermissionResponse { granted }) + } +} + +#[async_trait] +impl Features for CliPlatform { + async fn feature_supported( + &self, + _request: v01::HostFeatureSupportedRequest, + ) -> Result { + Ok(v01::HostFeatureSupportedResponse { supported: true }) + } +} + +impl truapi_platform::AuthPresenter for CliPlatform { + fn auth_state_changed(&self, state: AuthState) { + // Machine-readable lines for orchestrators to observe pairing. + match &state { + AuthState::Pairing { deeplink } => println!("PAIRING_DEEPLINK {deeplink}"), + AuthState::Connected(_) => println!("PAIRING_CONNECTED"), + AuthState::Disconnected => println!("PAIRING_DISCONNECTED"), + AuthState::LoginFailed { reason } => println!("PAIRING_FAILED {reason}"), + } + } +} + +#[async_trait] +impl UserConfirmation for CliPlatform { + async fn confirm_user_action( + &self, + review: UserConfirmationReview, + ) -> Result { + Ok(self.decide("sign request", format!("{review:?}")).await) + } +} + +impl ThemeHost for CliPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + Box::pin(stream::once(async { Ok(v01::ThemeVariant::Dark) })) + } +} + +impl PreimageHost for CliPlatform { + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + let value = self + .preimages + .lock() + .expect("preimage mutex poisoned") + .get(&key) + .cloned(); + Box::pin(stream::once(async move { Ok(value) })) + } +} + +#[derive(Serialize, Deserialize)] +struct JsonMap { + values: HashMap, +} + +fn load_string_map(path: &Path) -> HashMap> { + let Ok(text) = fs::read_to_string(path) else { + return HashMap::new(); + }; + serde_json::from_str::(&text) + .ok() + .map(|json| { + json.values + .into_iter() + .filter_map(|(key, value)| hex::decode(value).ok().map(|bytes| (key, bytes))) + .collect() + }) + .unwrap_or_default() +} + +fn save_string_map(path: &Path, values: &HashMap>) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| format!("create storage dir: {err}"))?; + } + let json = JsonMap { + values: values + .iter() + .map(|(key, value)| (key.clone(), hex::encode(value))) + .collect(), + }; + let text = serde_json::to_string_pretty(&json).map_err(|err| err.to_string())?; + fs::write(path, text).map_err(|err| format!("write {}: {err}", path.display())) +} + +fn load_hex_key_map(path: &Path) -> HashMap, Vec> { + load_string_map(path) + .into_iter() + .filter_map(|(key, value)| hex::decode(key).ok().map(|decoded| (decoded, value))) + .collect() +} + +fn save_hex_key_map(path: &Path, values: &HashMap, Vec>) -> Result<(), String> { + let keyed: HashMap> = values + .iter() + .map(|(key, value)| (hex::encode(key), value.clone())) + .collect(); + save_string_map(path, &keyed) +} diff --git a/rust/crates/truapi-host-cli/src/script_runner.rs b/rust/crates/truapi-host-cli/src/script_runner.rs new file mode 100644 index 00000000..58d96061 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/script_runner.rs @@ -0,0 +1,49 @@ +//! Runs a user host-script under `bun`, driving a host through the injected +//! `truapi` global. +//! +//! The Rust CLI owns the flow: it starts the host, then spawns `js/runner.ts` +//! (which connects the `@parity/truapi` client to the host and evaluates the +//! user script). The child's exit status becomes the host command's status, so +//! `truapi-host pairing-host --script foo.ts` *is* the test — there is no +//! separate bun orchestrator. + +use std::path::{Path, PathBuf}; +use std::process::ExitStatus; + +use anyhow::{Context, Result}; +use tokio::process::Command; + +/// Locate `js/runner.ts`, shipped alongside the crate. +/// +/// Overridable with `TRUAPI_HOST_RUNNER` for packaged/relocated builds. +fn runner_path() -> PathBuf { + if let Ok(path) = std::env::var("TRUAPI_HOST_RUNNER") { + return PathBuf::from(path); + } + Path::new(env!("CARGO_MANIFEST_DIR")).join("js/runner.ts") +} + +/// Run `script` against the host serving frames at `frame_url`, as product +/// `product_id`. Inherits stdio so the script's output and any CLI confirmation +/// prompts share the terminal. Returns the child's exit status. +pub async fn run(frame_url: &str, product_id: &str, script: &Path) -> Result { + let runner = runner_path(); + if !runner.exists() { + anyhow::bail!( + "host-script runner not found at {}; set TRUAPI_HOST_RUNNER", + runner.display() + ); + } + let script = script + .canonicalize() + .with_context(|| format!("script not found: {}", script.display()))?; + + Command::new("bun") + .arg(&runner) + .env("TRUAPI_FRAME_URL", frame_url) + .env("TRUAPI_PRODUCT_ID", product_id) + .env("TRUAPI_SCRIPT", &script) + .status() + .await + .context("failed to spawn `bun` for the host script (is bun installed?)") +} diff --git a/rust/crates/truapi-host-cli/tests/fixtures/paseo-next-v2-metadata.scale b/rust/crates/truapi-host-cli/tests/fixtures/paseo-next-v2-metadata.scale new file mode 100644 index 00000000..28a7ac3f Binary files /dev/null and b/rust/crates/truapi-host-cli/tests/fixtures/paseo-next-v2-metadata.scale differ diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 520d3fca..365dd649 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -47,6 +47,13 @@ pub struct PairingHostConfig { pub host: HostRuntimeConfig, /// People-chain genesis hash used for statement-store SSO. pub people_chain_genesis_hash: [u8; 32], + /// Optional distinct genesis for People-chain identity (username) lookups. + /// + /// In production this equals `people_chain_genesis_hash` (SSO and identity + /// share the People chain). It can be set separately so a host can run SSO + /// over one transport (e.g. a local relay) while resolving usernames from + /// the real People chain. `None` falls back to `people_chain_genesis_hash`. + pub identity_chain_genesis_hash: Option<[u8; 32]>, /// Bulletin-chain genesis hash used for in-core preimage submission. pub bulletin_chain_genesis_hash: [u8; 32], /// Deeplink URI scheme used in pairing QR payloads, without `://`. @@ -151,11 +158,25 @@ impl PairingHostConfig { let config = Self { host: HostRuntimeConfig::new(host_info, platform_info)?, people_chain_genesis_hash, + identity_chain_genesis_hash: None, bulletin_chain_genesis_hash, pairing_deeplink_scheme, }; Ok(config) } + + /// Resolve usernames from a People chain distinct from the SSO transport. + pub fn with_identity_chain_genesis_hash(mut self, genesis_hash: [u8; 32]) -> Self { + self.identity_chain_genesis_hash = Some(genesis_hash); + self + } + + /// Genesis used for People-chain identity lookups (falls back to the SSO + /// People-chain genesis when no distinct identity chain is configured). + pub fn identity_lookup_genesis_hash(&self) -> [u8; 32] { + self.identity_chain_genesis_hash + .unwrap_or(self.people_chain_genesis_hash) + } } impl SigningHostConfig { diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 67f36e1c..8a30934b 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -46,6 +46,7 @@ unicode-normalization = "0.1" url = "2" hex = "0.4" nanoid = "0.4" +blake2-rfc = { version = "0.2", default-features = false } blake2b_simd = { version = "1", default-features = false } sp-crypto-hashing = { version = "0.1", default-features = false } schnorrkel = { version = "0.11.5", default-features = false, features = ["alloc", "getrandom"] } @@ -64,6 +65,12 @@ tracing-subscriber = { version = "0.3", default-features = false, features = ["r [target.'cfg(not(target_arch = "wasm32"))'.dependencies] subxt = { version = "0.50.2", default-features = false, features = ["native"] } subxt-rpcs = { version = "0.50.1", default-features = false, features = ["native"] } +frame-metadata = { version = "23", default-features = false, features = ["std", "current", "decode"] } +scale-info = { version = "2.11", default-features = false, features = ["decode"] } +# Bandersnatch ring-VRF for signing-host account aliases. The alias path uses +# only the thin VRF (no ring/SRS), and only the native signing host needs it, +# so it is a non-wasm dependency. Pinned to the rev the mobile app references. +verifiable = { git = "https://github.com/paritytech/verifiable", rev = "f65b39df04f2f9a453d78550438b189c96785285", default-features = false, features = ["std", "prover"] } [target.'cfg(target_arch = "wasm32")'.dependencies] futures-timer = { version = "3", features = ["wasm-bindgen"] } diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 8069be14..c4e2f494 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -526,6 +526,20 @@ impl ChainRuntime { Ok(self.subxt_connection(genesis_hash).await?.client) } + /// Raw JSON-RPC client for the chain identified by `genesis_hash`. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn rpc_client( + &self, + method: &'static str, + genesis_hash: &[u8], + ) -> Result { + Ok(self + .connection_for(method, genesis_hash) + .await? + .rpc_client + .clone()) + } + async fn subxt_connection( &self, genesis_hash: &[u8], diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 1dbae792..576aee5b 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -26,8 +26,8 @@ use truapi_platform::{ use crate::core::TrUApiCore; use crate::frame::ProtocolMessage; use crate::runtime::{ - LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, RuntimeServices, - SigningHostRole, + LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, ResponderExit, + RuntimeServices, SigningHostRole, respond_to_pairing, }; use crate::subscription::Spawner; use crate::transport::Transport; @@ -185,9 +185,9 @@ impl PairingHostAdmin for PairingHostRuntime { /// Owns the shared services plus signing-host state. There is no pairing flow, /// so pairing cancellation is not present here. /// -/// Raw-bytes signing and product entropy are implemented; extrinsic-payload -/// signing, transaction construction, ring-VRF aliases, and resource allocation -/// return an `Unavailable` error pending chain-metadata and on-chain support. +/// Raw-bytes and extrinsic-payload signing, v4 transaction construction, and +/// product entropy are implemented; ring-VRF aliases and resource allocation +/// return an `Unavailable` error pending on-chain support. pub struct SigningHostRuntime { services: Arc, signing_host: Arc, @@ -207,7 +207,7 @@ impl SigningHostRuntime { config.bulletin_chain_genesis_hash, spawner, ); - let signing_host = SigningHostRole::new(platform); + let signing_host = SigningHostRole::new(platform, services.clone()); Self { services, signing_host, @@ -252,6 +252,36 @@ impl SigningHostRuntime { reason: err.reason(), }) } + + /// Activate a wallet-local session from host-held secret material and + /// attach known identity metadata. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.activate_local_session"))] + pub async fn activate_local_session_with_identity( + &self, + secret: Vec, + lite_username: Option, + ) -> Result<(), v01::GenericError> { + self.signing_host + .activate_local_session_with_identity(secret, lite_username) + .await + .map_err(|err| v01::GenericError { + reason: err.reason(), + }) + } + + /// Answer a pairing host's handshake deeplink and serve the resulting SSO + /// session until it ends (host-spec §B responder half). Requires an + /// active local session; sensitive requests consult the platform's + /// [`truapi_platform::UserConfirmation`] before signing. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.respond_to_pairing"))] + pub async fn respond_to_pairing( + &self, + deeplink: &str, + ) -> Result { + respond_to_pairing(self.services.clone(), self.signing_host.clone(), deeplink) + .await + .map_err(|reason| v01::GenericError { reason }) + } } /// Product-scoped administration handle for host UI. diff --git a/rust/crates/truapi-server/src/host_logic.rs b/rust/crates/truapi-server/src/host_logic.rs index 12a1c516..e4040bd9 100644 --- a/rust/crates/truapi-server/src/host_logic.rs +++ b/rust/crates/truapi-server/src/host_logic.rs @@ -4,6 +4,12 @@ //! storage, URL handler, notification center). Everything else lives here so //! iOS, Android, and web hosts share one canonical implementation. +/// Bandersnatch ring-VRF product-account aliases (native signing host only). +#[cfg(not(target_arch = "wasm32"))] +pub mod alias; +/// Lite-person username registration parameters (native signing host only). +#[cfg(not(target_arch = "wasm32"))] +pub mod attestation; pub mod bulletin; pub mod dotns; pub mod entropy; @@ -16,3 +22,4 @@ pub mod session; pub mod session_store; pub mod sso; pub mod statement_store; +pub mod transaction; diff --git a/rust/crates/truapi-server/src/host_logic/alias.rs b/rust/crates/truapi-server/src/host_logic/alias.rs new file mode 100644 index 00000000..6d572350 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/alias.rs @@ -0,0 +1,80 @@ +//! Bandersnatch ring-VRF product-account aliases (signing host). +//! +//! Mirrors the mobile app's `ProductAccountHolder.deriveAlias`: the alias is a +//! thin bandersnatch VRF output over a per-product context, using a +//! bandersnatch secret derived from the wallet's BIP-39 entropy. No ring +//! commitment or SRS is involved (that machinery is only for membership +//! proofs, which this path does not use). +//! +//! Reference: polkadot-app-ios-v2 `Packages/Products/.../ProductAccountHolder.swift` +//! and `verifiable-swift` over `paritytech/verifiable`. + +use blake2_rfc::blake2b::blake2b; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +/// A product-account contextual alias. +pub struct ProductAlias { + /// 32-byte context identifier (blake2b-256 of the derivation path). + pub context: [u8; 32], + /// 32-byte ring-VRF alias output. + pub alias: [u8; 32], +} + +/// Derive the contextual alias for a product account from the wallet entropy. +/// +/// - `context = blake2b_256("/product/{product_id}/{derivation_index}")` +/// - `bandersnatch_entropy = blake2b_256(root_entropy)` +/// - `alias = BandersnatchVrf::alias_in_context(new_secret(bandersnatch_entropy), context)` +pub fn derive_product_alias( + root_entropy: &[u8], + product_id: &str, + derivation_index: u32, +) -> Result { + let derivation_path = format!("/product/{product_id}/{derivation_index}"); + let context = blake2b256(derivation_path.as_bytes()); + let bandersnatch_entropy = blake2b256(root_entropy); + let secret = BandersnatchVrfVerifiable::new_secret(bandersnatch_entropy); + let alias = BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("ring-VRF alias derivation failed: {err:?}"))?; + Ok(ProductAlias { context, alias }) +} + +fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b(32, &[], message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The alias is deterministic in the entropy, product id, and index, and + /// the context is the blake2b-256 of the derivation path. + #[test] + fn alias_is_deterministic_with_expected_context() { + let entropy = [0xABu8; 16]; + let first = derive_product_alias(&entropy, "truapi-playground.dot", 0).unwrap(); + let again = derive_product_alias(&entropy, "truapi-playground.dot", 0).unwrap(); + + assert_eq!(first.context, again.context); + assert_eq!(first.alias, again.alias); + assert_eq!( + first.context, + blake2b256(b"/product/truapi-playground.dot/0") + ); + } + + #[test] + fn alias_varies_by_product_and_index() { + let entropy = [0xABu8; 16]; + let base = derive_product_alias(&entropy, "a.dot", 0).unwrap(); + let other_product = derive_product_alias(&entropy, "b.dot", 0).unwrap(); + let other_index = derive_product_alias(&entropy, "a.dot", 1).unwrap(); + + assert_ne!(base.alias, other_product.alias); + assert_ne!(base.alias, other_index.alias); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/attestation.rs b/rust/crates/truapi-server/src/host_logic/attestation.rs new file mode 100644 index 00000000..53832596 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/attestation.rs @@ -0,0 +1,166 @@ +//! Lite-person username registration parameters (signing host, native only). +//! +//! Builds the client-side proofs the People-chain identity backend needs to +//! attest a lite username for an account: an sr25519 proof-of-ownership, a +//! bandersnatch ring-VRF member key + plain-VRF proof, and an sr25519 +//! consumer-registration signature. The backend submits the on-chain +//! `register_lite_person` extrinsic; the host never signs a chain extrinsic. +//! +//! Byte layout mirrors signing-bot `src/core/attestation.ts` for backend +//! parity. The registered account is the account whose secret signs here; the +//! paired host resolves the username from `Resources.Consumers[that account]`. + +use blake2_rfc::blake2b::blake2b; +use parity_scale_codec::Encode; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use crate::host_logic::product_account::{ + SR25519_SIGNING_CONTEXT, derive_sr25519_hard_path, product_public_key_to_address, +}; +use crate::host_logic::sso::pairing::derive_p256_keypair_from_entropy; + +/// sr25519 proof-of-ownership message prefix (exact bytes; one space). +const REGISTER_PREFIX: &[u8] = b"pop:people-lite:register using"; +/// Domain label for the P-256 identifier key advertised to the backend. +const IDENTIFIER_KEY_LABEL: &[u8] = b"chat-encryption"; + +/// Client-computed parameters for `POST /usernames`. +pub struct LiteRegistration { + /// SS58 (prefix 42) of the candidate account. + pub candidate_account_id: String, + /// Raw 32-byte candidate public key (the future `Resources.Consumers` key). + pub candidate_public_key: [u8; 32], + /// sr25519 signature over `prefix ‖ candidate_pub ‖ ring_vrf_key`. + pub candidate_signature: [u8; 64], + /// Bandersnatch ring-VRF member key. + pub ring_vrf_key: [u8; 32], + /// Plain bandersnatch VRF proof over the same proof message. + pub proof_of_ownership: [u8; 64], + /// 65-byte uncompressed P-256 identifier key. + pub identifier_key: [u8; 65], + /// sr25519 signature over the SCALE consumer-registration tuple. + pub consumer_registration_signature: [u8; 64], +} + +/// Build the lite-person registration parameters for `username_base` +/// (6+ lowercase letters, no digit suffix) against the backend `verifier`. +pub fn build_lite_registration( + entropy: &[u8], + verifier_account_id: [u8; 32], + username_base: &str, +) -> Result { + // The candidate is the `//wallet//sso` statement account, matching the + // account the SSO responder presents as `identity_account_id`. + let candidate = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) + .map_err(|err| format!("//wallet//sso derivation failed: {err}"))?; + let candidate_public_key = candidate.public.to_bytes(); + + let vrf_entropy = blake2b256(entropy); + let vrf_secret = BandersnatchVrfVerifiable::new_secret(vrf_entropy); + let ring_vrf_key = BandersnatchVrfVerifiable::member_from_secret(&vrf_secret); + + let mut proof_message = Vec::with_capacity(REGISTER_PREFIX.len() + 64); + proof_message.extend_from_slice(REGISTER_PREFIX); + proof_message.extend_from_slice(&candidate_public_key); + proof_message.extend_from_slice(&ring_vrf_key); + + let candidate_signature = candidate + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &proof_message, &candidate.public) + .to_bytes(); + let proof_of_ownership = BandersnatchVrfVerifiable::sign(&vrf_secret, &proof_message) + .map_err(|err| format!("ring-VRF proof-of-ownership failed: {err:?}"))?; + + let (_identifier_secret, identifier_key) = + derive_p256_keypair_from_entropy(entropy, IDENTIFIER_KEY_LABEL) + .map_err(|err| format!("identifier key derivation failed: {err}"))?; + + // SCALE Tuple(Bytes(32), Bytes(32), Bytes(65), str, Option(Bytes())=None). + let mut consumer_message = Vec::new(); + consumer_message.extend_from_slice(&candidate_public_key); + consumer_message.extend_from_slice(&verifier_account_id); + consumer_message.extend_from_slice(&identifier_key); + consumer_message.extend_from_slice(&username_base.encode()); + consumer_message.push(0u8); + let consumer_registration_signature = candidate + .secret + .sign_simple( + SR25519_SIGNING_CONTEXT, + &consumer_message, + &candidate.public, + ) + .to_bytes(); + + Ok(LiteRegistration { + candidate_account_id: product_public_key_to_address(candidate_public_key), + candidate_public_key, + candidate_signature, + ring_vrf_key, + proof_of_ownership, + identifier_key, + consumer_registration_signature, + }) +} + +fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b(32, &[], message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + use schnorrkel::{PublicKey, Signature}; + + const ENTROPY: [u8; 16] = [0xAB; 16]; + + #[test] + fn registration_params_have_expected_shapes_and_verify() { + let verifier = [0x11u8; 32]; + let reg = build_lite_registration(&ENTROPY, verifier, "headlesstester").unwrap(); + + assert_eq!(reg.identifier_key[0], 0x04, "P-256 uncompressed prefix"); + assert!( + reg.candidate_account_id + .chars() + .all(|c| c.is_alphanumeric()) + ); + + // candidateSignature verifies over prefix ‖ candidate_pub ‖ ring_vrf_key. + let mut proof_message = Vec::new(); + proof_message.extend_from_slice(REGISTER_PREFIX); + proof_message.extend_from_slice(®.candidate_public_key); + proof_message.extend_from_slice(®.ring_vrf_key); + let public = PublicKey::from_bytes(®.candidate_public_key).unwrap(); + let sig = Signature::from_bytes(®.candidate_signature).unwrap(); + assert!( + public + .verify_simple(SR25519_SIGNING_CONTEXT, &proof_message, &sig) + .is_ok(), + "candidate signature verifies" + ); + + // proofOfOwnership verifies as a plain VRF signature for the member key. + assert!( + BandersnatchVrfVerifiable::verify_signature( + ®.proof_of_ownership, + &proof_message, + ®.ring_vrf_key + ), + "ring-VRF proof-of-ownership validates against the member key" + ); + } + + #[test] + fn registration_is_deterministic_per_entropy_and_username() { + let verifier = [0x22u8; 32]; + let first = build_lite_registration(&ENTROPY, verifier, "aliceheadless").unwrap(); + let again = build_lite_registration(&ENTROPY, verifier, "aliceheadless").unwrap(); + assert_eq!(first.candidate_public_key, again.candidate_public_key); + assert_eq!(first.ring_vrf_key, again.ring_vrf_key); + assert_eq!(first.candidate_account_id, again.candidate_account_id); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/entropy.rs b/rust/crates/truapi-server/src/host_logic/entropy.rs index 652a880c..47176f2f 100644 --- a/rust/crates/truapi-server/src/host_logic/entropy.rs +++ b/rust/crates/truapi-server/src/host_logic/entropy.rs @@ -23,8 +23,14 @@ pub fn derive_product_entropy( product_id: &str, key: &[u8], ) -> Result<[u8; 32], ProductEntropyError> { - let root_entropy_source = blake2b256_keyed(entropy_secret, DOMAIN_SEPARATOR); - derive_product_entropy_from_source(&root_entropy_source, product_id, key) + derive_product_entropy_from_source(&root_entropy_source(entropy_secret), product_id, key) +} + +/// Pre-hashed root entropy source (RFC-0007 layer 1). Signing hosts share this +/// value with paired hosts during the SSO handshake so both sides derive the +/// same product entropy. +pub fn root_entropy_source(entropy_secret: &[u8]) -> [u8; 32] { + blake2b256_keyed(entropy_secret, DOMAIN_SEPARATOR) } /// Derive product-scoped entropy when the session already stores the diff --git a/rust/crates/truapi-server/src/host_logic/product_account.rs b/rust/crates/truapi-server/src/host_logic/product_account.rs index a70cceff..a6f75c8a 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -91,6 +91,27 @@ pub fn product_public_key_to_address(public_key: [u8; 32]) -> String { subxt::utils::AccountId32(public_key).to_string() } +/// Derive an sr25519 keypair down a path of hard string junctions from the +/// BIP-39 entropy, e.g. `["wallet", "sso"]` for `//wallet//sso`. +/// +/// Matches Substrate hard derivation (schnorrkel `SchnorrRistrettoHDKD`), the +/// same path the mobile/bot signing hosts use for their statement identity +/// account. +pub fn derive_sr25519_hard_path( + entropy: &[u8], + junctions: &[&str], +) -> Result { + let mut keypair = derive_root_keypair_from_entropy(entropy)?; + for junction in junctions { + let chain_code = schnorrkel::derive::ChainCode(create_chain_code(junction)?); + let (mini_secret, _) = keypair + .secret + .hard_derive_mini_secret_key(Some(chain_code), b""); + keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + } + Ok(keypair) +} + /// Create a Substrate soft-derivation chain code for one junction. fn create_chain_code(code: &str) -> Result<[u8; 32], ProductAccountError> { let encoded = if !code.is_empty() && code.bytes().all(|byte| byte.is_ascii_digit()) { diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index 5af8e3b8..fbcc447d 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -30,10 +30,11 @@ use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::pairing::{ AES_GCM_NONCE_LEN, SsoStatementData, decrypt_session_statement_data, encrypt_session_statement_data, encrypt_session_statement_data_with_nonce, + peer_response_channel, }; use crate::host_logic::statement_store::{ - build_signed_session_request_statement, current_unix_secs, decode_verified_statement_data, - statement_expiry_elapsed, + build_signed_session_request_statement, build_signed_statement, current_unix_secs, + decode_verified_statement_data, statement_expiry_elapsed, }; pub mod v1; @@ -136,6 +137,31 @@ impl SigningPayloadRequest { } } +impl From for truapi::v01::HostSignPayloadRequest { + fn from(value: SigningPayloadRequest) -> Self { + Self { + account: value.product_account_id, + payload: truapi::v01::HostSignPayloadData { + block_hash: value.block_hash, + block_number: value.block_number, + era: value.era, + genesis_hash: value.genesis_hash, + method: value.method, + nonce: value.nonce, + spec_version: value.spec_version, + tip: value.tip, + transaction_version: value.transaction_version, + signed_extensions: value.signed_extensions, + version: value.version, + asset_id: value.asset_id, + metadata_hash: value.metadata_hash, + mode: value.mode, + with_signed_transaction: value.with_signed_transaction.0, + }, + } + } +} + /// Request sent when a product asks the paired signing host to sign raw bytes or a /// string message with a product-derived account. /// @@ -188,6 +214,24 @@ impl From for SigningRawPayload { } } +impl From for RawPayload { + fn from(value: SigningRawPayload) -> Self { + match value { + SigningRawPayload::Bytes(bytes) => Self::Bytes { bytes }, + SigningRawPayload::Payload(payload) => Self::Payload { payload }, + } + } +} + +impl From for truapi::v01::HostSignRawRequest { + fn from(value: SigningRawRequest) -> Self { + Self { + account: value.product_account_id, + payload: value.data.into(), + } + } +} + /// Response returned by the signing host for a product-account signing request. /// /// Decoded from [`v1::RemoteMessage::SignResponse`] while the runtime is waiting @@ -215,6 +259,26 @@ pub struct SignRawLegacyResponse { pub signature: Result, String>, } +/// Request sent when a product asks the paired signing host to sign exact +/// statement-store proof bytes with a product-derived account. +/// +/// This cannot reuse raw signing: raw-signing requests apply the public +/// `...` payload convention, while statement proofs sign the +/// unsigned statement payload bytes directly. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct StatementStoreProductSignRequest { + pub product_account_id: ProductAccountId, + pub payload: Vec, +} + +/// Response returned by the signing host for exact statement-store proof +/// signing. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct StatementStoreProductSignResponse { + pub responding_to: String, + pub signature: Result, String>, +} + /// Request sent when a product asks the signing host for a ring-VRF alias. /// /// Used by `Account::get_account_alias`; the product account identifies the @@ -353,6 +417,7 @@ pub enum SsoSessionStatement { pub enum SsoRemoteResponse { Sign(SigningResponse), SignRawLegacy(SignRawLegacyResponse), + StatementStoreProductSign(StatementStoreProductSignResponse), RingVrfAlias(RingVrfAliasResponse), ResourceAllocation(ResourceAllocationResponse), CreateTransaction(CreateTransactionResponse), @@ -452,6 +517,11 @@ fn remote_response_for_message( { Some(SsoRemoteResponse::SignRawLegacy(response)) } + v1::RemoteMessage::StatementStoreProductSignResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::StatementStoreProductSign(response)) + } v1::RemoteMessage::ResourceAllocationResponse(response) if response.responding_to == expected_remote_message_id => { @@ -466,6 +536,23 @@ fn remote_response_for_message( } } +/// Build a signing-host exact statement-store proof signing request message. +pub fn statement_store_product_sign_message( + message_id: String, + product_account_id: ProductAccountId, + payload: Vec, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(v1::RemoteMessage::StatementStoreProductSignRequest( + StatementStoreProductSignRequest { + product_account_id, + payload, + }, + )), + } +} + /// Build a signing-host payload-signing request message. pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) -> RemoteMessage { RemoteMessage { @@ -569,6 +656,81 @@ pub fn create_transaction_legacy_message( } } +/// Inbound request decoded from a peer-signed session statement. +/// +/// `request_id` identifies the statement for the transport-level ack; +/// `messages` are the application messages batched into it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IncomingSsoRequest { + pub request_id: String, + pub messages: Vec, +} + +/// Decode an inbound session statement into the peer's request batch. +/// +/// Returns `Ok(None)` for statements that carry no peer request: own echoes, +/// transport-level acks, and expired statements. Used by the signing-host +/// responder, which serves peer requests instead of matching pending ones. +pub fn decode_incoming_sso_request( + session: &SsoSessionInfo, + statement: &[u8], +) -> Result, String> { + let verified = + decode_verified_statement_data(statement, None).map_err(|err| err.to_string())?; + if verified.signer == session.ss_public_key { + return Ok(None); + } + if verified.signer != session.identity_account_id { + return Err("statement proof signer does not match expected peer".to_string()); + } + if verified + .expiry + .is_some_and(|expiry| statement_expiry_elapsed(expiry, current_unix_secs())) + { + return Ok(None); + } + match decrypt_session_statement_data(session, &verified.data)? { + SsoStatementData::Response { .. } => Ok(None), + SsoStatementData::Request { request_id, data } => { + let messages = data + .iter() + .map(|message| { + RemoteMessage::decode(&mut message.as_slice()) + .map_err(|err| format!("invalid SSO remote message: {err}")) + }) + .collect::, _>>()?; + Ok(Some(IncomingSsoRequest { + request_id, + messages, + })) + } + } +} + +/// Build the signed transport-level ack for a peer-initiated request +/// statement: topic `session_id_peer`, channel [`peer_response_channel`]. +pub fn build_signed_session_response_statement( + session: &SsoSessionInfo, + request_id: String, + response_code: u8, + expiry: u64, +) -> Result, String> { + let encrypted = encrypt_session_statement_data( + session, + &SsoStatementData::Response { + request_id, + response_code, + }, + )?; + build_signed_statement( + session, + peer_response_channel(session), + session.session_id_peer, + encrypted, + expiry, + ) +} + /// Build a signed outbound SSO request statement with a random nonce. pub fn build_outgoing_request_statement( session: &SsoSessionInfo, @@ -992,6 +1154,187 @@ mod tests { assert_eq!(decoded, None); } + fn host_and_responder_sessions() -> (SsoSessionInfo, SsoSessionInfo) { + use crate::host_logic::sso::pairing::{ + ResponderIdentity, create_pairing_bootstrap, derive_p256_keypair_from_entropy, + establish_responder_session_info, establish_sso_session_info, + }; + use truapi_platform::{HostInfo, PairingHostConfig, PlatformInfo}; + + let config = PairingHostConfig::new( + HostInfo { + name: "Test Host".to_string(), + icon: None, + version: None, + }, + PlatformInfo::default(), + [0; 32], + [0xbb; 32], + "polkadotapp".to_string(), + ) + .expect("test pairing config is valid"); + let bootstrap = create_pairing_bootstrap(&config).unwrap(); + let statement_keypair = MiniSecretKey::from_bytes(&[7; 32]) + .unwrap() + .expand_to_keypair(ExpansionMode::Ed25519); + let (encryption_secret_key, encryption_public_key) = + derive_p256_keypair_from_entropy(&[0xAB; 16], b"sso-encryption").unwrap(); + let responder = ResponderIdentity { + statement_secret: statement_keypair.secret.to_bytes(), + statement_public_key: statement_keypair.public.to_bytes(), + encryption_secret_key, + encryption_public_key, + }; + let responder_session = establish_responder_session_info( + &responder, + bootstrap.statement_store_public_key, + bootstrap.encryption_public_key, + ) + .unwrap(); + let host_session = establish_sso_session_info( + &bootstrap, + responder.statement_public_key, + responder.encryption_public_key, + ) + .unwrap(); + (host_session, responder_session) + } + + /// A host-built request statement decodes on the responder side into the + /// batched remote messages, and the responder's ack plus response + /// statements resolve the host's pending wait. + #[test] + fn host_request_round_trips_through_responder_statements() { + let (host_session, responder_session) = host_and_responder_sessions(); + let request = sign_raw_message( + "remote-1".to_string(), + HostSignRawRequest { + account: account(), + payload: RawPayload::Payload { + payload: "hello".to_string(), + }, + }, + ); + let host_statement = build_outgoing_request_statement( + &host_session, + "statement-1".to_string(), + vec![request.clone()], + fresh_expiry(), + ) + .unwrap(); + + let incoming = decode_incoming_sso_request(&responder_session, &host_statement) + .unwrap() + .expect("responder should surface the host request"); + assert_eq!( + incoming, + IncomingSsoRequest { + request_id: "statement-1".to_string(), + messages: vec![request], + } + ); + + let ack = build_signed_session_response_statement( + &responder_session, + incoming.request_id.clone(), + 0, + fresh_expiry(), + ) + .unwrap(); + assert_eq!( + decode_sso_session_statement(&host_session, &ack, "statement-1", "remote-1").unwrap(), + Some(SsoSessionStatement::RequestAccepted) + ); + + let response = RemoteMessage { + message_id: "resp-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::SignResponse(SigningResponse { + responding_to: "remote-1".to_string(), + payload: Ok(SigningPayloadResponseData { + signature: vec![9; 64], + signed_transaction: None, + }), + })), + }; + let response_statement = build_outgoing_request_statement( + &responder_session, + "resp-statement-1".to_string(), + vec![response], + fresh_expiry(), + ) + .unwrap(); + let decoded = decode_sso_session_statement( + &host_session, + &response_statement, + "statement-1", + "remote-1", + ) + .unwrap(); + assert_eq!( + decoded, + Some(SsoSessionStatement::RemoteResponse( + SsoRemoteResponse::Sign(SigningResponse { + responding_to: "remote-1".to_string(), + payload: Ok(SigningPayloadResponseData { + signature: vec![9; 64], + signed_transaction: None, + }), + }) + )) + ); + } + + #[test] + fn responder_ignores_own_echo_and_transport_acks() { + let (host_session, responder_session) = host_and_responder_sessions(); + let own_statement = build_outgoing_request_statement( + &responder_session, + "resp-statement-1".to_string(), + vec![RemoteMessage { + message_id: "resp-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }], + fresh_expiry(), + ) + .unwrap(); + assert_eq!( + decode_incoming_sso_request(&responder_session, &own_statement).unwrap(), + None + ); + + let host_ack = build_signed_session_response_statement( + &host_session, + "resp-statement-1".to_string(), + 0, + fresh_expiry(), + ) + .unwrap(); + assert_eq!( + decode_incoming_sso_request(&responder_session, &host_ack).unwrap(), + None + ); + } + + #[test] + fn responder_ignores_expired_host_request() { + let (host_session, responder_session) = host_and_responder_sessions(); + let stale_statement = build_outgoing_request_statement( + &host_session, + "statement-1".to_string(), + vec![RemoteMessage { + message_id: "remote-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }], + elapsed_expiry(), + ) + .unwrap(); + + assert_eq!( + decode_incoming_sso_request(&responder_session, &stale_statement).unwrap(), + None + ); + } + fn response_ack_statement(session: &SsoSessionInfo, expiry: u64) -> Vec { let encrypted = encrypt_session_statement_data_with_nonce( session, diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs index cbd0204a..da820ab4 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs @@ -11,7 +11,7 @@ use super::{ CreateTransactionLegacyRequest, CreateTransactionRequest, CreateTransactionResponse, ResourceAllocationRequest, ResourceAllocationResponse, RingVrfAliasRequest, RingVrfAliasResponse, SignRawLegacyRequest, SignRawLegacyResponse, SigningRequest, - SigningResponse, + SigningResponse, StatementStoreProductSignRequest, StatementStoreProductSignResponse, }; /// v1 messages exchanged with the paired signing host over the encrypted SSO channel. @@ -32,4 +32,6 @@ pub enum RemoteMessage { CreateTransactionLegacyRequest(CreateTransactionLegacyRequest), SignRawLegacyRequest(SignRawLegacyRequest), SignRawLegacyResponse(SignRawLegacyResponse), + StatementStoreProductSignRequest(StatementStoreProductSignRequest), + StatementStoreProductSignResponse(StatementStoreProductSignResponse), } diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs index 4c09adb5..17c18f5f 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -14,6 +14,7 @@ use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Nonce}; +use blake2_rfc::blake2b::blake2b; use hkdf::Hkdf; use p256::ecdh::diffie_hellman; use p256::elliptic_curve::sec1::ToEncodedPoint; @@ -111,6 +112,53 @@ pub enum SsoStatementData { }, } +/// Decode a pairing deeplink (or its bare handshake hex) into the advertised +/// handshake proposal. Inverse of [`build_pairing_deeplink`]. +pub fn decode_pairing_deeplink(deeplink: &str) -> Result { + let hex_payload = match deeplink.split_once("?handshake=") { + Some((_, hex_payload)) => hex_payload, + None => deeplink, + }; + let encoded = hex::decode(hex_payload.trim()) + .map_err(|err| format!("invalid pairing deeplink hex: {err}"))?; + let mut input = encoded.as_slice(); + let proposal = VersionedHandshakeProposal::decode(&mut input) + .map_err(|err| format!("invalid pairing handshake proposal: {err}"))?; + if !input.is_empty() { + return Err("invalid pairing handshake proposal: trailing bytes".to_string()); + } + Ok(proposal) +} + +/// Encrypt a v2 handshake response for the host that advertised +/// `host_encryption_public_key`. Inverse of [`decrypt_v2_handshake_response`]: +/// a fresh ephemeral P-256 key is used per response so each Pending/Success +/// statement carries an independent ciphertext. +pub fn encrypt_v2_handshake_response( + host_encryption_public_key: [u8; 65], + response: &v2::EncryptedResponse, +) -> Result { + let (ephemeral_secret, ephemeral_public) = + generate_p256_keypair().map_err(|err| err.to_string())?; + let shared_secret = shared_secret(ephemeral_secret, host_encryption_public_key)?; + let aes_key = aes_key_from_shared_secret(&shared_secret)?; + let mut nonce = [0u8; AES_GCM_NONCE_LEN]; + getrandom::getrandom(&mut nonce) + .map_err(|err| format!("failed to generate AES-GCM nonce: {err}"))?; + let cipher = Aes256Gcm::new_from_slice(&aes_key) + .map_err(|err| format!("failed to initialize AES-GCM: {err}"))?; + let mut encrypted_message = nonce.to_vec(); + encrypted_message.extend( + cipher + .encrypt(Nonce::from_slice(&nonce), response.encode().as_slice()) + .map_err(|err| format!("failed to encrypt SSO handshake response: {err}"))?, + ); + Ok(VersionedHandshakeResponse::V2 { + encrypted_message, + public_key: ephemeral_public, + }) +} + /// Decode wallet-posted pairing handshake data from SCALE bytes. pub fn decode_app_handshake_data(blob: &[u8]) -> Result { let mut input = blob; @@ -175,6 +223,96 @@ pub fn establish_sso_session_info( }) } +/// Signing-host key material answering one pairing proposal. +/// +/// The statement keypair signs every session statement (its public key is the +/// `identityAccountId` the pairing host binds the session to), and the P-256 +/// secret is the persistent `sso_enc` key both sides feed into the session +/// ECDH. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponderIdentity { + pub statement_secret: [u8; 64], + pub statement_public_key: [u8; 32], + pub encryption_secret_key: [u8; 32], + pub encryption_public_key: [u8; 65], +} + +/// Derive the SSO session channels from the responder (signing host) +/// perspective after answering the handshake advertised by +/// `host_statement_account_id` / `host_encryption_public_key`. +/// +/// Mirror of [`establish_sso_session_info`]: the responder's `session_id_own` +/// equals the pairing host's `session_id_peer` and vice versa, so statements +/// built from either side land on the topics the other side subscribes to. +pub fn establish_responder_session_info( + identity: &ResponderIdentity, + host_statement_account_id: [u8; 32], + host_encryption_public_key: [u8; 65], +) -> Result { + let shared_secret = shared_secret(identity.encryption_secret_key, host_encryption_public_key)?; + let shared_secret_bytes: [u8; 32] = (*shared_secret.raw_secret_bytes()).into(); + let session_id_own = create_session_id( + shared_secret_bytes, + identity.statement_public_key, + host_statement_account_id, + ); + let session_id_peer = create_session_id( + shared_secret_bytes, + host_statement_account_id, + identity.statement_public_key, + ); + + Ok(SsoSessionInfo { + ss_secret: identity.statement_secret, + ss_public_key: identity.statement_public_key, + enc_secret: identity.encryption_secret_key, + peer_enc_pubkey: host_encryption_public_key, + identity_account_id: host_statement_account_id, + session_id_own, + session_id_peer, + request_channel: keyed_hash(session_id_own, REQUEST_CHANNEL_SUFFIX), + response_channel: keyed_hash(session_id_own, RESPONSE_CHANNEL_SUFFIX), + peer_request_channel: keyed_hash(session_id_peer, REQUEST_CHANNEL_SUFFIX), + }) +} + +/// Statement channel acknowledging requests the peer initiated: the peer's +/// own `response` channel seen from this side of the session. +pub fn peer_response_channel(session: &SsoSessionInfo) -> [u8; 32] { + keyed_hash(session.session_id_peer, RESPONSE_CHANNEL_SUFFIX) +} + +/// Derive a deterministic P-256 keypair from BIP-39 entropy and a domain +/// label. Host-spec C.4 leaves signing-host P-256 derivation +/// implementation-defined; this scheme only needs to be stable per entropy. +pub fn derive_p256_keypair_from_entropy( + entropy: &[u8], + label: &[u8], +) -> Result<([u8; 32], [u8; 65]), PairingBootstrapError> { + for attempt in 0..MAX_P256_SECRET_ATTEMPTS { + let mut message = Vec::with_capacity(label.len() + 1); + message.extend_from_slice(label); + message.push(attempt as u8); + let candidate: [u8; 32] = blake2b(32, entropy, &message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes"); + let Ok(secret) = SecretKey::from_slice(&candidate) else { + continue; + }; + let public = secret.public_key().to_encoded_point(false); + let public = public.as_bytes(); + if public.len() != 65 { + return Err(PairingBootstrapError::InvalidP256Secret); + } + let mut encryption_public_key = [0u8; 65]; + encryption_public_key.copy_from_slice(public); + return Ok((candidate, encryption_public_key)); + } + + Err(PairingBootstrapError::InvalidP256Secret) +} + /// Encrypt session-channel statement data with a random nonce. pub fn encrypt_session_statement_data( session: &SsoSessionInfo, @@ -666,6 +804,136 @@ mod tests { ); } + #[test] + fn decodes_pairing_deeplink_round_trip() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + + let decoded = decode_pairing_deeplink(&deeplink).unwrap(); + + let VersionedHandshakeProposal::V2(proposal) = decoded; + assert_eq!(proposal.device.statement_account_id, SS_PUBLIC); + assert_eq!(proposal.device.encryption_public_key, ENC_PUBLIC); + } + + #[test] + fn decodes_bare_handshake_hex() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + let hex_payload = deeplink.split("handshake=").nth(1).unwrap(); + + assert_eq!( + decode_pairing_deeplink(hex_payload).unwrap(), + decode_pairing_deeplink(&deeplink).unwrap() + ); + } + + #[test] + fn rejects_pairing_deeplink_with_trailing_bytes() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + + let err = decode_pairing_deeplink(&format!("{deeplink}00")).unwrap_err(); + + assert_eq!(err, "invalid pairing handshake proposal: trailing bytes"); + } + + #[test] + fn encrypted_handshake_response_round_trips_through_host_decrypt() { + let host_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let host_public: [u8; 65] = host_secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(); + let response = v2::EncryptedResponse::Success(Box::new(v2::Success { + identity_account_id: [8; 32], + root_account_id: [7; 32], + identity_chat_private_key: [6; 32], + sso_enc_pub_key: ENC_PUBLIC, + device_enc_pub_key: ENC_PUBLIC, + root_entropy_source: [5; 32], + })); + + let VersionedHandshakeResponse::V2 { + encrypted_message, + public_key, + } = encrypt_v2_handshake_response(host_public, &response).unwrap(); + + assert_eq!( + decrypt_v2_handshake_response( + host_secret.to_bytes().into(), + public_key, + &encrypted_message + ) + .unwrap(), + response + ); + } + + fn responder_identity() -> ResponderIdentity { + let (statement_secret, statement_public_key) = generate_statement_store_keypair().unwrap(); + let (encryption_secret_key, encryption_public_key) = + derive_p256_keypair_from_entropy(&[0xAB; 16], b"sso-encryption").unwrap(); + ResponderIdentity { + statement_secret, + statement_public_key, + encryption_secret_key, + encryption_public_key, + } + } + + /// The responder-perspective session must mirror the host-perspective + /// session: swapped session ids, aligned channels, and one shared AES key + /// so either side can decrypt the other's statement data. + #[test] + fn responder_session_mirrors_host_session() { + let config = runtime_config(); + let host_bootstrap = create_pairing_bootstrap(&config).unwrap(); + let responder = responder_identity(); + + let responder_session = establish_responder_session_info( + &responder, + host_bootstrap.statement_store_public_key, + host_bootstrap.encryption_public_key, + ) + .unwrap(); + let host_session = establish_sso_session_info( + &host_bootstrap, + responder.statement_public_key, + responder.encryption_public_key, + ) + .unwrap(); + + assert_eq!( + responder_session.session_id_own, + host_session.session_id_peer + ); + assert_eq!( + responder_session.session_id_peer, + host_session.session_id_own + ); + assert_eq!( + responder_session.request_channel, + host_session.peer_request_channel + ); + assert_eq!( + peer_response_channel(&responder_session), + host_session.response_channel + ); + + let data = SsoStatementData::Request { + request_id: "req-1".to_string(), + data: vec![vec![0xde, 0xad]], + }; + let encrypted = encrypt_session_statement_data(&responder_session, &data).unwrap(); + assert_eq!( + decrypt_session_statement_data(&host_session, &encrypted).unwrap(), + data + ); + } + #[test] fn statement_data_codec_round_trips_request_and_response() { let request = SsoStatementData::Request { diff --git a/rust/crates/truapi-server/src/host_logic/transaction.rs b/rust/crates/truapi-server/src/host_logic/transaction.rs new file mode 100644 index 00000000..55522a8a --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/transaction.rs @@ -0,0 +1,207 @@ +//! Extrinsic signing preimages and v4 signed-extrinsic assembly. +//! +//! Signing hosts receive pre-encoded payload fields (`HostSignPayloadData`) +//! or pre-encoded transaction extensions (`TxPayloadExtension`), so no chain +//! metadata is needed: the preimage is a byte concatenation and the signed +//! extrinsic is assembled mechanically. Matches the polkadot-app signing +//! convention: preimages longer than 256 bytes are BLAKE2b-256 hashed before +//! signing. + +use blake2_rfc::blake2b::blake2b; +use parity_scale_codec::{Compact, Encode}; +use truapi::v01::{HostSignPayloadData, TxPayloadExtension}; + +/// Preimages longer than this are hashed before signing (standard Substrate +/// signed-payload rule). +const MAX_SIGNED_PREIMAGE_LEN: usize = 256; + +/// Extrinsic version 4 with the signed bit set. +const EXTRINSIC_V4_SIGNED: u8 = 0x84; +/// `MultiAddress::Id` variant index. +const MULTI_ADDRESS_ID: u8 = 0x00; +/// `MultiSignature::Sr25519` variant index. +const MULTI_SIGNATURE_SR25519: u8 = 0x01; + +/// Signing preimage for an extrinsic payload assembled from pre-encoded +/// fields, in the polkadot-app field order. Empty optional fields are +/// skipped, mirroring the JS falsy-field rule. +pub fn extrinsic_payload_preimage(payload: &HostSignPayloadData) -> Vec { + let parts: [&[u8]; 8] = [ + &payload.method, + &payload.era, + &payload.nonce, + &payload.tip, + &payload.spec_version, + &payload.transaction_version, + &payload.genesis_hash, + &payload.block_hash, + ]; + let mut preimage = Vec::new(); + for part in parts { + preimage.extend_from_slice(part); + } + if let Some(asset_id) = &payload.asset_id { + preimage.extend_from_slice(asset_id); + } + if let Some(metadata_hash) = &payload.metadata_hash { + preimage.extend_from_slice(metadata_hash); + } + hash_large_preimage(preimage) +} + +/// Signing preimage for a transaction built from pre-encoded extensions: +/// call data, then every extension's `extra`, then every extension's +/// `additional_signed`. +pub fn transaction_signing_preimage( + call_data: &[u8], + extensions: &[TxPayloadExtension], +) -> Vec { + let mut preimage = call_data.to_vec(); + for extension in extensions { + preimage.extend_from_slice(&extension.extra); + } + for extension in extensions { + preimage.extend_from_slice(&extension.additional_signed); + } + hash_large_preimage(preimage) +} + +/// Assemble a v4 signed extrinsic from a signer public key, an sr25519 +/// signature over [`transaction_signing_preimage`], the pre-encoded +/// extension `extra` data, and the call data. +pub fn build_v4_signed_extrinsic( + signer_public_key: [u8; 32], + signature: [u8; 64], + extensions: &[TxPayloadExtension], + call_data: &[u8], +) -> Vec { + let mut body = Vec::with_capacity(2 + 32 + 1 + 64 + call_data.len()); + body.push(EXTRINSIC_V4_SIGNED); + body.push(MULTI_ADDRESS_ID); + body.extend_from_slice(&signer_public_key); + body.push(MULTI_SIGNATURE_SR25519); + body.extend_from_slice(&signature); + for extension in extensions { + body.extend_from_slice(&extension.extra); + } + body.extend_from_slice(call_data); + + let mut extrinsic = Compact(body.len() as u32).encode(); + extrinsic.extend_from_slice(&body); + extrinsic +} + +fn hash_large_preimage(preimage: Vec) -> Vec { + if preimage.len() > MAX_SIGNED_PREIMAGE_LEN { + blake2b(32, &[], &preimage).as_bytes().to_vec() + } else { + preimage + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn payload() -> HostSignPayloadData { + HostSignPayloadData { + block_hash: vec![0xB1, 0xB2], + block_number: vec![0xFF], + era: vec![0xE1], + genesis_hash: vec![0x61, 0x62], + method: vec![0x4D], + nonce: vec![0x4E], + spec_version: vec![0x51], + tip: vec![0x54], + transaction_version: vec![0x56], + signed_extensions: vec![], + version: 4, + asset_id: None, + metadata_hash: None, + mode: None, + with_signed_transaction: None, + } + } + + #[test] + fn payload_preimage_uses_polkadot_app_field_order() { + // method, era, nonce, tip, spec_version, transaction_version, + // genesis_hash, block_hash. block_number is not part of the preimage. + assert_eq!( + extrinsic_payload_preimage(&payload()), + vec![0x4D, 0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2] + ); + } + + #[test] + fn payload_preimage_appends_asset_id_and_metadata_hash() { + let mut payload = payload(); + payload.asset_id = Some(vec![0xAA]); + payload.metadata_hash = Some(vec![0xBB]); + + assert_eq!( + extrinsic_payload_preimage(&payload), + vec![ + 0x4D, 0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2, 0xAA, 0xBB + ] + ); + } + + #[test] + fn long_preimages_are_blake2b_hashed() { + let mut payload = payload(); + payload.method = vec![0x4D; 300]; + + let preimage = extrinsic_payload_preimage(&payload); + + assert_eq!(preimage.len(), 32); + let mut raw = vec![0x4D; 300]; + raw.extend_from_slice(&[0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2]); + assert_eq!(preimage, blake2b(32, &[], &raw).as_bytes().to_vec()); + } + + #[test] + fn transaction_preimage_orders_call_extra_then_implicit() { + let extensions = vec![ + TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![0x01], + additional_signed: vec![0x02], + }, + TxPayloadExtension { + id: "CheckSpecVersion".to_string(), + extra: vec![0x03], + additional_signed: vec![0x04], + }, + ]; + + assert_eq!( + transaction_signing_preimage(&[0xCA, 0x11], &extensions), + vec![0xCA, 0x11, 0x01, 0x03, 0x02, 0x04] + ); + } + + #[test] + fn builds_v4_signed_extrinsic_layout() { + let extensions = vec![TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![0xEE], + additional_signed: vec![0xDD], + }]; + + let extrinsic = + build_v4_signed_extrinsic([0xAB; 32], [0xCD; 64], &extensions, &[0xCA, 0x11]); + + let body_len = 1 + 1 + 32 + 1 + 64 + 1 + 2; + assert_eq!(extrinsic[..2], Compact(body_len as u32).encode()[..]); + let body = &extrinsic[2..]; + assert_eq!(body.len(), body_len); + assert_eq!(body[0], 0x84); + assert_eq!(body[1], 0x00); + assert_eq!(&body[2..34], &[0xAB; 32]); + assert_eq!(body[34], 0x01); + assert_eq!(&body[35..99], &[0xCD; 64]); + assert_eq!(body[99], 0xEE); + assert_eq!(&body[100..], &[0xCA, 0x11]); + } +} diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index 9d2d8489..3d890b94 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -32,6 +32,7 @@ pub use host_core::{ FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeError, SigningHostRuntime, }; +pub use runtime::ResponderExit; pub use truapi_platform::{ HostRuntimeConfig, PairingHostConfig, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, ProductContext, SigningHostConfig, diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 4debdd2a..02614d4f 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -17,12 +17,15 @@ pub(crate) mod services; mod signing_host; pub(crate) mod sso_pairing; pub(crate) mod sso_remote; +#[cfg(not(target_arch = "wasm32"))] +mod statement_allowance; pub(crate) mod statement_store; mod statement_store_rpc; use core::future::Future; use core::time::Duration; -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use crate::chain_runtime::RuntimeFailure; use crate::host_logic::bulletin::preimage_key; @@ -43,7 +46,10 @@ pub(crate) use authority::ProductAuthority; use pairing_host::PairingHost; pub(crate) use pairing_host::PairingHost as PairingHostRole; pub(crate) use services::RuntimeServices; -pub(crate) use signing_host::{LocalActivation, SigningHost as SigningHostRole}; +pub use signing_host::ResponderExit; +pub(crate) use signing_host::{ + LocalActivation, SigningHost as SigningHostRole, respond_to_pairing, +}; use authority::{ AuthorityCancelError, AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, @@ -213,6 +219,8 @@ pub struct ProductRuntimeHost { /// Stable per-product-runtime id used to scope long-lived chain follow /// operation ids within one shared host runtime. core_instance: u64, + /// Host-owned transaction operation ids mapped to provider operation ids. + transaction_operations: Mutex>, } impl ProductRuntimeHost { @@ -228,6 +236,7 @@ impl ProductRuntimeHost { authority, product, core_instance, + transaction_operations: Mutex::new(HashMap::new()), } } @@ -316,6 +325,7 @@ impl ProductRuntimeHost { authority: pairing_host.clone(), product, core_instance, + transaction_operations: Mutex::new(HashMap::new()), }; (host, pairing_host) } @@ -372,6 +382,16 @@ impl ProductRuntimeHost { fn follow_id(&self, id: &str) -> String { format!("c{}:{id}", self.core_instance) } + + fn transaction_id(&self) -> String { + format!("c{}:tx:{}", self.core_instance, nanoid::nanoid!(10)) + } +} + +#[derive(Debug, Clone)] +struct TransactionOperation { + genesis_hash: Vec, + provider_operation_id: String, } impl ProductRuntimeHost { @@ -1561,12 +1581,34 @@ impl Chain for ProductRuntimeHost { }, )) .await?; - self.services + let genesis_hash = inner.genesis_hash.clone(); + let response = self + .services .chain .remote_chain_transaction_broadcast(inner) .await - .map(RemoteChainTransactionBroadcastResponse::V1) - .map_err(runtime_failure_to_call_error) + .map_err(runtime_failure_to_call_error)?; + let Some(provider_operation_id) = response.operation_id else { + return Ok(RemoteChainTransactionBroadcastResponse::V1( + v01::RemoteChainTransactionBroadcastResponse { operation_id: None }, + )); + }; + let operation_id = self.transaction_id(); + self.transaction_operations + .lock() + .expect("transaction operations mutex poisoned") + .insert( + operation_id.clone(), + TransactionOperation { + genesis_hash, + provider_operation_id, + }, + ); + Ok(RemoteChainTransactionBroadcastResponse::V1( + v01::RemoteChainTransactionBroadcastResponse { + operation_id: Some(operation_id), + }, + )) } #[instrument(skip_all, fields(runtime.method = "chain.stop_transaction"))] @@ -1577,18 +1619,58 @@ impl Chain for ProductRuntimeHost { ) -> Result> { let RemoteChainTransactionStopRequest::V1(inner) = request; - // We intentionally forward the provider operation id here. Transaction - // operation ids are node-assigned and short-lived, so cross-product - // collision or guessing is not worth local id indirection yet. - self.services + let operation = { + let operations = self + .transaction_operations + .lock() + .expect("transaction operations mutex poisoned"); + let Some(operation) = operations.get(&inner.operation_id).cloned() else { + return Err(CallError::HostFailure { + reason: format!("unknown transaction operation id: {}", inner.operation_id), + }); + }; + if operation.genesis_hash != inner.genesis_hash { + return Err(CallError::HostFailure { + reason: "transaction operation id belongs to a different chain".to_string(), + }); + } + operation + }; + let stop_request = v01::RemoteChainTransactionStopRequest { + genesis_hash: operation.genesis_hash, + operation_id: operation.provider_operation_id, + }; + let result = self + .services .chain - .remote_chain_transaction_stop(inner) + .remote_chain_transaction_stop(stop_request) .await + .or_else(|failure| { + if transaction_operation_already_finished(&failure) { + Ok(()) + } else { + Err(failure) + } + }) .map(|()| RemoteChainTransactionStopResponse::V1) - .map_err(runtime_failure_to_call_error) + .map_err(runtime_failure_to_call_error); + if result.is_ok() { + self.transaction_operations + .lock() + .expect("transaction operations mutex poisoned") + .remove(&inner.operation_id); + } + result } } +fn transaction_operation_already_finished(failure: &RuntimeFailure) -> bool { + failure + .reason() + .to_ascii_lowercase() + .contains("invalid operation id") +} + // --------------------------------------------------------------------------- // Deferred product surfaces. // @@ -2904,6 +2986,150 @@ mod tests { assert!(platform.sent_rpc.lock().unwrap().is_empty()); } + #[test] + fn chain_stop_transaction_uses_host_operation_id_and_accepts_finished_remote_op() { + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":"remote-op"}"#.to_string(), + r#"{"jsonrpc":"2.0","id":"truapi:2","error":{"code":-32602,"message":"Invalid operation id"}}"#.to_string(), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::new(); + let genesis_hash = vec![7u8; 32]; + let broadcast = futures::executor::block_on(Chain::broadcast_transaction( + &host, + &cx, + RemoteChainTransactionBroadcastRequest::V1( + v01::RemoteChainTransactionBroadcastRequest { + genesis_hash: genesis_hash.clone(), + transaction: vec![1, 2, 3], + }, + ), + )) + .unwrap(); + let RemoteChainTransactionBroadcastResponse::V1(broadcast) = broadcast; + let operation_id = broadcast.operation_id.expect("host operation id"); + assert!(operation_id.starts_with("c")); + assert!(operation_id.contains(":tx:")); + assert_ne!(operation_id, "remote-op"); + + futures::executor::block_on(Chain::stop_transaction( + &host, + &cx, + RemoteChainTransactionStopRequest::V1(v01::RemoteChainTransactionStopRequest { + genesis_hash, + operation_id: operation_id.clone(), + }), + )) + .expect("finished provider operation should count as stopped"); + + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + let stop_request: serde_json::Value = serde_json::from_str( + sent.iter() + .find(|request| request.contains("transaction_v1_stop")) + .expect("remote stop request sent"), + ) + .unwrap(); + assert_eq!(stop_request["params"][0], "remote-op"); + assert_ne!(stop_request["params"][0], operation_id); + } + + #[test] + fn chain_stop_transaction_rejects_unknown_host_operation_id() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::new(); + let err = futures::executor::block_on(Chain::stop_transaction( + &host, + &cx, + RemoteChainTransactionStopRequest::V1(v01::RemoteChainTransactionStopRequest { + genesis_hash: vec![7u8; 32], + operation_id: "missing-op".to_string(), + }), + )) + .unwrap_err(); + assert!( + matches!(err, CallError::HostFailure { ref reason } if reason.contains("unknown transaction operation id")), + "unexpected error: {err:?}", + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } + + #[test] + fn chain_stop_transaction_keeps_operation_retryable_after_remote_failure() { + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":"remote-op"}"#.to_string(), + r#"{"jsonrpc":"2.0","id":"truapi:2","error":{"code":-32000,"message":"temporary stop failure"}}"#.to_string(), + r#"{"jsonrpc":"2.0","id":"truapi:3","result":null}"#.to_string(), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::new(); + let genesis_hash = vec![7u8; 32]; + let broadcast = futures::executor::block_on(Chain::broadcast_transaction( + &host, + &cx, + RemoteChainTransactionBroadcastRequest::V1( + v01::RemoteChainTransactionBroadcastRequest { + genesis_hash: genesis_hash.clone(), + transaction: vec![1, 2, 3], + }, + ), + )) + .unwrap(); + let RemoteChainTransactionBroadcastResponse::V1(broadcast) = broadcast; + let operation_id = broadcast.operation_id.expect("host operation id"); + + let first_stop = futures::executor::block_on(Chain::stop_transaction( + &host, + &cx, + RemoteChainTransactionStopRequest::V1(v01::RemoteChainTransactionStopRequest { + genesis_hash: genesis_hash.clone(), + operation_id: operation_id.clone(), + }), + )) + .unwrap_err(); + assert!( + matches!(first_stop, CallError::HostFailure { ref reason } if reason.contains("temporary stop failure")), + "unexpected error: {first_stop:?}", + ); + + futures::executor::block_on(Chain::stop_transaction( + &host, + &cx, + RemoteChainTransactionStopRequest::V1(v01::RemoteChainTransactionStopRequest { + genesis_hash, + operation_id, + }), + )) + .expect("operation should remain retryable"); + + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + let stop_operation_ids = sent + .iter() + .filter(|request| request.contains("transaction_v1_stop")) + .map(|request| serde_json::from_str::(request).unwrap()) + .map(|request| request["params"][0].clone()) + .collect::>(); + assert_eq!(stop_operation_ids, vec!["remote-op", "remote-op"]); + } + #[test] fn preimage_lookup_cache_hit_emits_once_and_stays_open() { use crate::host_logic::bulletin::preimage_key; diff --git a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs index c19de06b..fed6d0e6 100644 --- a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs @@ -22,11 +22,15 @@ use subxt::tx::{ SubmittableTransaction, TransactionInBlock, TransactionInvalid, TransactionStatus, TransactionUnknown, ValidationResult, }; +#[cfg(not(target_arch = "wasm32"))] +use subxt_rpcs::RpcClient; use tracing::{instrument, warn}; use truapi::CallContext; use truapi_platform::BulletinAllowanceKey; use crate::chain_runtime::ChainRuntime; +#[cfg(not(target_arch = "wasm32"))] +use crate::chain_runtime::RuntimeFailure; use crate::host_logic::bulletin::{ STORE_PALLET_NAME, allowance_signer, build_signed_store_transaction, preimage_key, }; @@ -156,6 +160,15 @@ impl BulletinRpc { } } + /// Open a raw RPC client over the configured Bulletin chain. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn client(&self, label: &'static str) -> Result { + self.chain + .rpc_client(label, &self.genesis_hash) + .await + .map(RpcClient::new) + } + /// Submit `value` as a Bulletin preimage signed by `allowance`, returning /// the preimage key once the transaction is included and its dispatch /// succeeded. @@ -275,18 +288,32 @@ impl BulletinRpc { .map_err(|error| BulletinSubmitError::ChainUnavailable { reason: format!("block {} unavailable: {error}", block.number()), })?; - let signed = build_signed_store_transaction(&at_block, signer, value) - .await - .map_err(map_store_transaction_build_error)?; + let signed = match build_signed_store_transaction(&at_block, signer, value).await { + Ok(signed) => signed, + Err(error) => { + warn!( + block = block.number(), + error = %error, + "Bulletin store transaction assembly failed" + ); + return Err(map_store_transaction_build_error(error)); + } + }; self.enter_phase("dry-run"); - let validity = - signed - .validate() - .await - .map_err(|error| BulletinSubmitError::ChainUnavailable { + let validity = match signed.validate().await { + Ok(validity) => validity, + Err(error) => { + warn!( + block = block.number(), + error = %error, + "Bulletin transaction dry-run failed" + ); + return Err(BulletinSubmitError::ChainUnavailable { reason: format!("transaction dry-run unavailable: {error}"), - })?; + }); + } + }; match Self::classify_dry_run_validity(validity)? { DryRunStatus::Valid => return Ok(signed), DryRunStatus::AllowanceRejected diff --git a/rust/crates/truapi-server/src/runtime/identity.rs b/rust/crates/truapi-server/src/runtime/identity.rs index 36e7d9f4..8a9964b8 100644 --- a/rust/crates/truapi-server/src/runtime/identity.rs +++ b/rust/crates/truapi-server/src/runtime/identity.rs @@ -51,7 +51,7 @@ pub(super) async fn resolve_session_identity_with_chain( } let preferred_account = session.identity_account_id.unwrap_or(session.public_key); - if !lookup_and_apply( + if lookup_and_apply( chain, people_chain_genesis_hash, preferred_account, @@ -59,6 +59,7 @@ pub(super) async fn resolve_session_identity_with_chain( "identity", ) .await + == LookupOutcome::NoRecord && preferred_account != session.public_key { let public_key = session.public_key; @@ -75,42 +76,60 @@ pub(super) async fn resolve_session_identity_with_chain( session } +/// Maximum lookup attempts per account on transient failure. The first attempt +/// warms the People-chain connection (cached per genesis), so a retry after a +/// cold-start timeout usually resolves immediately. +const IDENTITY_LOOKUP_MAX_ATTEMPTS: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LookupOutcome { + /// A username record was found and applied. + Applied, + /// The account has no consumer record (definitive; do not retry). + NoRecord, + /// The lookup failed transiently after exhausting retries. + Failed, +} + /// Look up `account`'s people-chain identity and apply any usernames to -/// `session`; returns whether a username record was found and applied. +/// `session`, retrying transient failures against the warmed connection. async fn lookup_and_apply( chain: &ChainRuntime, people_chain_genesis_hash: [u8; 32], account: [u8; 32], session: &mut SessionInfo, label: &str, -) -> bool { - match lookup_people_identity(chain, people_chain_genesis_hash, account).await { - Ok(Some(identity)) => { - debug!( - account = %hex::encode(account), - lite_username = identity.lite_username.as_deref().unwrap_or(""), - full_username = identity.full_username.as_deref().unwrap_or(""), - "People-chain {label} lookup found username" - ); - session.apply_usernames(identity.lite_username, identity.full_username); - true - } - Ok(None) => { - debug!( - account = %hex::encode(account), - "People-chain {label} lookup found no consumer record" - ); - false - } - Err(reason) => { - warn!( - account = %hex::encode(account), - %reason, - "People-chain {label} lookup failed" - ); - false +) -> LookupOutcome { + for attempt in 1..=IDENTITY_LOOKUP_MAX_ATTEMPTS { + match lookup_people_identity(chain, people_chain_genesis_hash, account).await { + Ok(Some(identity)) => { + debug!( + account = %hex::encode(account), + lite_username = identity.lite_username.as_deref().unwrap_or(""), + full_username = identity.full_username.as_deref().unwrap_or(""), + "People-chain {label} lookup found username" + ); + session.apply_usernames(identity.lite_username, identity.full_username); + return LookupOutcome::Applied; + } + Ok(None) => { + debug!( + account = %hex::encode(account), + "People-chain {label} lookup found no consumer record" + ); + return LookupOutcome::NoRecord; + } + Err(reason) => { + warn!( + account = %hex::encode(account), + attempt, + %reason, + "People-chain {label} lookup failed" + ); + } } } + LookupOutcome::Failed } #[instrument(skip_all, fields(runtime.method = "session.identity.lookup"))] diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index 050c394f..487e375d 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -215,7 +215,7 @@ impl PairingHost { Ok(session) => { let resolved = resolve_session_identity_with_chain( &pairing_host.chain, - pairing_host.host_config.people_chain_genesis_hash, + pairing_host.host_config.identity_lookup_genesis_hash(), session, ) .await; @@ -463,13 +463,14 @@ impl PairingHost { async fn refresh_current_session_identity(&self) -> Option { let current = self.session_state.current()?; - if current.has_username() || self.host_config.people_chain_genesis_hash == [0; 32] { + let identity_genesis_hash = self.host_config.identity_lookup_genesis_hash(); + if current.has_username() || identity_genesis_hash == [0; 32] { return Some(authority_session(¤t)); } let resolved = resolve_session_identity_with_chain( &self.chain, - self.host_config.people_chain_genesis_hash, + identity_genesis_hash, current.clone(), ) .await; @@ -794,17 +795,14 @@ impl PairingHost { async fn sign_statement_store_product_payload( &self, - _cx: &CallContext, + cx: &CallContext, session: &AuthoritySession, - _account: v01::ProductAccountId, - _payload: Vec, + account: v01::ProductAccountId, + payload: Vec, ) -> Result<[u8; 64], AuthorityError> { - self.current_private_session(session)?; - Err(AuthorityError::Unavailable { - reason: "pairing host: exact statement proof signing is not supported over the \ - current SSO raw-signing protocol" - .to_string(), - }) + let session = self.current_private_session(session)?; + self.remote_sign_statement_store_product_payload(cx, &session, account, payload) + .await } fn derive_entropy( diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index cf5a04e4..d0dc64c8 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -17,7 +17,8 @@ use crate::host_logic::sso::messages::{ OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, SsoAllocatedResource, SsoAllocationOutcome, SsoRemoteResponse, SsoSessionStatement, alias_request_message, build_outgoing_request_statement, create_transaction_message, decode_sso_session_statement, - resource_allocation_message, sign_payload_message, sign_raw_message, v1, + resource_allocation_message, sign_payload_message, sign_raw_message, + statement_store_product_sign_message, v1, }; use crate::host_logic::statement_store::parse_new_statements_result; @@ -37,6 +38,8 @@ enum RemoteAction { AccountAlias, #[display("resource-allocation")] ResourceAllocation, + #[display("statement-store-product-sign")] + StatementStoreProductSign, } /// Active peer-disconnect watcher for one SSO session; aborts on drop. @@ -377,6 +380,41 @@ impl PairingHost { response.payload.map_err(remote_authority_error) } + pub(super) async fn remote_sign_statement_store_product_payload( + &self, + cx: &CallContext, + session: &SessionInfo, + account: latest::ProductAccountId, + payload: Vec, + ) -> Result<[u8; 64], AuthorityError> { + let message_id = sso_message_id(); + let message = statement_store_product_sign_message(message_id, account, payload); + let response = self + .submit_remote_message( + cx, + session, + RemoteAction::StatementStoreProductSign, + message, + ) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::StatementStoreProductSign(response) = response else { + return Err(AuthorityError::Unknown { + reason: "Unexpected SSO response for statement-store proof signing request" + .to_string(), + }); + }; + let signature = response.signature.map_err(remote_authority_error)?; + signature + .try_into() + .map_err(|signature: Vec| AuthorityError::Unknown { + reason: format!( + "Invalid statement-store proof signature length: {}", + signature.len() + ), + }) + } + pub(super) async fn remote_allocate_resources( &self, cx: &CallContext, diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 14f3cb93..e0924467 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -5,12 +5,22 @@ //! embedding host at unlock through [`LocalActivation::activate_local_session`] //! (the host owns its persistence, e.g. the OS keychain) and kept in memory //! for the session, zeroized on disconnect. +//! +//! Implemented: local session lifecycle, raw-bytes signing, extrinsic-payload +//! signing, v4 transaction construction (payload fields and extensions arrive +//! pre-encoded, so no chain metadata is needed), RFC-0007 product entropy, and +//! bandersnatch ring-VRF product-account aliases (native only), and +//! product-scoped Bulletin allowance keys. Deferred (returns +//! [`AuthorityError::Unavailable`]): on-chain resource allocation. mod local_activation; +mod sso_responder; use std::sync::{Arc, Mutex}; pub(crate) use local_activation::LocalActivation; +pub use sso_responder::ResponderExit; +pub(crate) use sso_responder::respond_to_pairing; use super::authority::{ AuthorityError, AuthoritySession, BulletinAllowanceKey, CreateTransactionAuthorityRequest, @@ -21,11 +31,13 @@ use super::connected_session_ui_info; use crate::host_logic::entropy::derive_product_entropy; use crate::host_logic::extrinsic::{Sr25519Signer, build_signed_extrinsic_v4}; use crate::host_logic::product_account::{ - ProductAccountError, SR25519_SIGNING_CONTEXT, derive_product_keypair, - derive_root_keypair_from_entropy, + ProductAccountError, SR25519_SIGNING_CONTEXT, derive_product_keypair, derive_sr25519_hard_path, }; use crate::host_logic::session::SessionState; +use crate::host_logic::sso::messages::OnExistingAllowancePolicy; +use crate::host_logic::transaction::extrinsic_payload_preimage; use crate::runtime::auth_state::AuthStateMachine; +use crate::runtime::services::RuntimeServices; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, v01}; @@ -37,6 +49,8 @@ const BYTES_WRAP_SUFFIX: &[u8] = b""; /// Wallet-local account authority for a signing host. pub(crate) struct SigningHost { + #[cfg(not(target_arch = "wasm32"))] + services: Arc, session_state: Arc, auth_state: AuthStateMachine, /// Root BIP-39 entropy held only while a session is active. @@ -44,8 +58,13 @@ pub(crate) struct SigningHost { } impl SigningHost { - pub(crate) fn new(platform: Arc) -> Arc { + pub(crate) fn new(platform: Arc, services: Arc) -> Arc { + #[cfg(target_arch = "wasm32")] + let _ = services; + Arc::new(Self { + #[cfg(not(target_arch = "wasm32"))] + services, session_state: SessionState::new(), auth_state: AuthStateMachine::new(platform), root_entropy: Mutex::new(None), @@ -66,26 +85,73 @@ impl SigningHost { .ok_or(AuthorityError::Disconnected) } - /// Derive the product-account keypair for `account` from the root entropy. + /// Derive the product-account keypair for `account` from the wallet root. /// - /// The root keypair is recomputed per call (PBKDF2, 2048 rounds, via - /// `substrate-bip39`) rather than cached: the signing host holds only the - /// raw, zeroizable entropy, never an expanded secret key. + /// Per host-spec C.5, product keys derive from the user's main wallet + /// account at `//wallet` (whose public key is `rootUserAccountId`), not the + /// bare BIP-39 root. The wallet keypair is recomputed per call; the signing + /// host holds only the raw, zeroizable entropy. fn product_keypair( &self, account: &v01::ProductAccountId, ) -> Result { let entropy = self.root_entropy()?; - let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; + let wallet = wallet_root_keypair(&entropy)?; let product_id = normalize_product_identifier(&account.dot_ns_identifier).map_err(|err| { AuthorityError::Unavailable { reason: err.to_string(), } })?; - derive_product_keypair(&root, &product_id, account.derivation_index) + derive_product_keypair(&wallet, &product_id, account.derivation_index) .map_err(product_authority_error) } + + #[cfg(not(target_arch = "wasm32"))] + async fn local_statement_store_allowance_key( + &self, + product_id: &str, + ) -> Result { + let secret = + sso_responder::allocate_statement_store_allowance(&self.services, self, product_id) + .await + .map_err(allocation_error)?; + StatementStoreAllowanceKey::from_secret_bytes(secret) + } + + #[cfg(target_arch = "wasm32")] + async fn local_statement_store_allowance_key( + &self, + _product_id: &str, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: statement-store allowance allocation is native-only".to_string(), + }) + } + + #[cfg(not(target_arch = "wasm32"))] + async fn local_bulletin_allowance_key( + &self, + product_id: &str, + policy: OnExistingAllowancePolicy, + ) -> Result { + let secret = + sso_responder::allocate_bulletin_allowance(&self.services, self, product_id, policy) + .await + .map_err(allocation_error)?; + BulletinAllowanceKey::from_secret_bytes(secret).map_err(AuthorityError::from) + } + + #[cfg(target_arch = "wasm32")] + async fn local_bulletin_allowance_key( + &self, + _product_id: &str, + _policy: OnExistingAllowancePolicy, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: Bulletin allowance allocation is native-only".to_string(), + }) + } } #[async_trait::async_trait] @@ -129,13 +195,26 @@ impl ProductAuthority for SigningHost { async fn sign_payload( &self, _cx: &CallContext, - _session: &AuthoritySession, - _request: SignPayloadAuthorityRequest, + session: &AuthoritySession, + request: SignPayloadAuthorityRequest, ) -> Result { - Err(AuthorityError::Unavailable { - reason: "signing host: extrinsic-payload signing needs chain-metadata payload \ - assembly (not yet implemented)" - .to_string(), + let (account, payload) = match request { + SignPayloadAuthorityRequest::Product(request) => (request.account, request.payload), + SignPayloadAuthorityRequest::LegacyAccount { + product_account, + request, + } => (product_account, request.payload), + }; + require_current_session(&self.session_state, session)?; + let keypair = self.product_keypair(&account)?; + let message = extrinsic_payload_preimage(&payload); + let signature = keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) + .to_bytes(); + Ok(v01::HostSignPayloadResponse { + signature: signature.to_vec(), + signed_transaction: None, }) } @@ -145,15 +224,16 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: SignRawAuthorityRequest, ) -> Result { - let SignRawAuthorityRequest::Product(request) = request else { - return Err(AuthorityError::Unavailable { - reason: "signing host: legacy-account raw signing is not yet implemented" - .to_string(), - }); + let (account, payload) = match request { + SignRawAuthorityRequest::Product(request) => (request.account, request.payload), + SignRawAuthorityRequest::LegacyAccount { + product_account, + request, + } => (product_account, request.payload), }; require_current_session(&self.session_state, session)?; - let keypair = self.product_keypair(&request.account)?; - let message = raw_payload_bytes(request.payload)?; + let keypair = self.product_keypair(&account)?; + let message = raw_payload_bytes(payload)?; let signature = keypair .secret .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) @@ -211,62 +291,100 @@ impl ProductAuthority for SigningHost { async fn account_alias( &self, _cx: &CallContext, - _session: &AuthoritySession, - _product_account_id: v01::ProductAccountId, + session: &AuthoritySession, + product_account_id: v01::ProductAccountId, _requesting_product_id: String, ) -> Result { - Err(AuthorityError::Unavailable { - reason: "signing host: ring-VRF alias derivation not yet implemented".to_string(), - }) + #[cfg(target_arch = "wasm32")] + { + let _ = (session, product_account_id); + Err(AuthorityError::Unavailable { + reason: "signing host: ring-VRF alias derivation is native-only".to_string(), + }) + } + #[cfg(not(target_arch = "wasm32"))] + { + require_current_session(&self.session_state, session)?; + let entropy = self.root_entropy()?; + let product_id = normalize_product_identifier(&product_account_id.dot_ns_identifier) + .map_err(|err| AuthorityError::Unavailable { + reason: err.to_string(), + })?; + let alias = crate::host_logic::alias::derive_product_alias( + &entropy, + &product_id, + product_account_id.derivation_index, + ) + .map_err(|reason| AuthorityError::Unknown { reason })?; + Ok(v01::HostAccountGetAliasResponse { + context: alias.context, + alias: alias.alias.to_vec(), + }) + } } async fn allocate_resources( &self, _cx: &CallContext, - _session: &AuthoritySession, - _product_id: String, - _request: v01::HostRequestResourceAllocationRequest, + session: &AuthoritySession, + product_id: String, + request: v01::HostRequestResourceAllocationRequest, ) -> Result { - Err(AuthorityError::Unavailable { - reason: "signing host: on-chain resource allocation not yet implemented".to_string(), - }) + require_current_session(&self.session_state, session)?; + let mut outcomes = Vec::with_capacity(request.resources.len()); + for resource in request.resources { + let outcome = match resource { + v01::AllocatableResource::StatementStoreAllowance => { + self.local_statement_store_allowance_key(&product_id) + .await?; + v01::AllocationOutcome::Allocated + } + v01::AllocatableResource::BulletinAllowance => { + self.local_bulletin_allowance_key( + &product_id, + OnExistingAllowancePolicy::Ignore, + ) + .await?; + v01::AllocationOutcome::Allocated + } + v01::AllocatableResource::SmartContractAllowance(_) + | v01::AllocatableResource::AutoSigning => v01::AllocationOutcome::NotAvailable, + }; + outcomes.push(outcome); + } + Ok(v01::HostRequestResourceAllocationResponse { outcomes }) } async fn statement_store_allowance_key( &self, _cx: &CallContext, session: &AuthoritySession, - _product_id: String, + product_id: String, ) -> Result { require_current_session(&self.session_state, session)?; - Err(AuthorityError::Unavailable { - reason: "signing host: statement-store allowance allocation not yet implemented" - .to_string(), - }) + self.local_statement_store_allowance_key(&product_id).await } async fn bulletin_allowance_key( &self, _cx: &CallContext, session: &AuthoritySession, - _product_id: String, + product_id: String, ) -> Result { require_current_session(&self.session_state, session)?; - Err(AuthorityError::Unavailable { - reason: "signing host: bulletin allowance allocation not yet implemented".to_string(), - }) + self.local_bulletin_allowance_key(&product_id, OnExistingAllowancePolicy::Ignore) + .await } async fn refresh_bulletin_allowance_key( &self, _cx: &CallContext, session: &AuthoritySession, - _product_id: String, + product_id: String, ) -> Result { require_current_session(&self.session_state, session)?; - Err(AuthorityError::Unavailable { - reason: "signing host: bulletin allowance allocation not yet implemented".to_string(), - }) + self.local_bulletin_allowance_key(&product_id, OnExistingAllowancePolicy::Increase) + .await } async fn sign_statement_store_product_payload( @@ -306,6 +424,29 @@ fn product_authority_error(err: ProductAccountError) -> AuthorityError { } } +#[cfg(not(target_arch = "wasm32"))] +fn allocation_error(reason: String) -> AuthorityError { + AuthorityError::Unavailable { reason } +} + +/// The user's main wallet keypair at `//wallet` (host-spec C.0), the root of +/// product-account derivation and the `rootUserAccountId` shared with paired +/// hosts. +pub(crate) fn wallet_root_keypair(entropy: &[u8]) -> Result { + derive_sr25519_hard_path(entropy, &["wallet"]).map_err(product_authority_error) +} + +#[cfg(test)] +fn derive_bulletin_allowance_key( + entropy: &[u8], + product_id: &str, +) -> Result { + let allowance = derive_sr25519_hard_path(entropy, &["allowance", "bulletin", product_id]) + .map_err(product_authority_error)?; + BulletinAllowanceKey::from_secret_bytes(allowance.secret.to_bytes().to_vec()) + .map_err(AuthorityError::from) +} + /// Assemble and sign a transaction locally from caller-supplied, pre-encoded /// parts. Only Extrinsic V4 (`tx_ext_version == 0`) is supported; the caller's /// extension bytes carry the whole chain binding, so no metadata is consulted. @@ -373,11 +514,12 @@ mod tests { AuthorityError, CreateTransactionAuthorityRequest, SignRawAuthorityRequest, }; use super::super::{ProductAuthority, ProductRuntimeHost, RuntimeServices, SigningHostRole}; - use super::{BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, raw_payload_bytes}; - use crate::host_logic::extrinsic::tests::split_v4; - use crate::host_logic::product_account::{ - derive_product_keypair, derive_root_keypair_from_entropy, + use super::{ + BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, derive_bulletin_allowance_key, + raw_payload_bytes, }; + use crate::host_logic::extrinsic::tests::split_v4; + use crate::host_logic::product_account::{derive_product_keypair, derive_sr25519_hard_path}; use crate::test_support::{StubPlatform, test_spawner}; use truapi::api::{Account, Entropy, Signing}; use truapi::versioned::account::{HostAccountGetError, HostAccountGetRequest}; @@ -412,7 +554,7 @@ mod tests { config.bulletin_chain_genesis_hash, test_spawner(), ); - let signing_host = SigningHostRole::new(platform); + let signing_host = SigningHostRole::new(platform, services.clone()); (services, signing_host) } @@ -460,8 +602,8 @@ mod tests { futures::executor::block_on(runtime.sign_raw(&cx, request)).expect("sign_raw ok"); assert!(response.signed_transaction.is_none()); - let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); let signature = schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); assert!( @@ -531,8 +673,8 @@ mod tests { let (account, signature, tail) = split_v4(&response.transaction); assert_eq!(tail, vec![1, 0x00, 0x00], "body tail is extra ++ call_data"); - let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); assert_eq!(account, keypair.public.to_bytes()); // Payload = call_data ++ extra ++ additional_signed (call first). @@ -600,8 +742,8 @@ mod tests { let session = activation.current_session().expect("active session"); let cx = CallContext::new(); - let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); let request = CreateTransactionAuthorityRequest::LegacyAccount { product_account: product_account(0), @@ -764,8 +906,8 @@ mod tests { }); let HostSignRawResponse::V1(response) = futures::executor::block_on(runtime.sign_raw(&cx, request)).expect("sign_raw ok"); - let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); let signature = schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); assert!( @@ -851,7 +993,159 @@ mod tests { } #[test] - fn deferred_operations_return_unavailable() { + fn sign_payload_verifies_against_derived_product_key() { + use super::super::authority::SignPayloadAuthorityRequest; + use crate::host_logic::transaction::extrinsic_payload_preimage; + + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + let cx = CallContext::new(); + let payload = v01::HostSignPayloadData { + block_hash: vec![0xB1; 32], + block_number: vec![0x01], + era: vec![0x00], + genesis_hash: vec![0x61; 32], + method: vec![0x4D, 0x00], + nonce: vec![0x00], + spec_version: vec![0x51], + tip: vec![0x00], + transaction_version: vec![0x56], + signed_extensions: vec![], + version: 4, + asset_id: None, + metadata_hash: None, + mode: None, + with_signed_transaction: None, + }; + let request = v01::HostSignPayloadRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: payload.clone(), + }; + + let response = futures::executor::block_on(authority.sign_payload( + &cx, + &session, + SignPayloadAuthorityRequest::Product(request), + )) + .expect("sign_payload ok"); + + assert!(response.signed_transaction.is_none()); + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); + let signature = + schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); + assert!( + keypair + .public + .verify_simple( + b"substrate", + &extrinsic_payload_preimage(&payload), + &signature + ) + .is_ok(), + "signature verifies over the payload preimage", + ); + } + + #[test] + fn create_transaction_builds_verifiable_v4_extrinsic() { + use super::super::authority::CreateTransactionAuthorityRequest; + use crate::host_logic::transaction::transaction_signing_preimage; + + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + let cx = CallContext::new(); + let extensions = vec![v01::TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![0x04], + additional_signed: vec![], + }]; + let payload = v01::ProductAccountTxPayload { + signer: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + genesis_hash: [0x61; 32], + call_data: vec![0x00, 0x00], + extensions: extensions.clone(), + tx_ext_version: 0, + }; + + let response = futures::executor::block_on(authority.create_transaction( + &cx, + &session, + CreateTransactionAuthorityRequest::Product(payload), + )) + .expect("create_transaction ok"); + + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); + let transaction = response.transaction; + let mut body = transaction.as_slice(); + let body_len = + as parity_scale_codec::Decode>::decode(&mut body) + .expect("compact length prefix") + .0 as usize; + assert_eq!(body.len(), body_len); + assert_eq!(body[0], 0x84); + assert_eq!(body[1], 0x00); + assert_eq!(&body[2..34], &keypair.public.to_bytes()); + assert_eq!(body[34], 0x01); + let signature = schnorrkel::Signature::from_bytes(&body[35..99]).unwrap(); + assert_eq!(body[99], 0x04); + assert_eq!(&body[100..], &[0x00, 0x00]); + assert!( + keypair + .public + .verify_simple( + b"substrate", + &transaction_signing_preimage(&[0x00, 0x00], &extensions), + &signature + ) + .is_ok(), + "extrinsic signature verifies over call ++ extra ++ implicit", + ); + } + + #[test] + fn create_transaction_rejects_v5_extension_version() { + use super::super::authority::CreateTransactionAuthorityRequest; + + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + let cx = CallContext::new(); + let payload = v01::ProductAccountTxPayload { + signer: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + genesis_hash: [0x61; 32], + call_data: vec![], + extensions: vec![], + tx_ext_version: 1, + }; + + let err = futures::executor::block_on(authority.create_transaction( + &cx, + &session, + CreateTransactionAuthorityRequest::Product(payload), + )) + .expect_err("v5 rejected"); + + assert!(matches!(err, AuthorityError::NotSupported { .. })); + } + + #[test] + fn account_alias_returns_ring_vrf_alias() { let (_services, authority) = signing_runtime(); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation"); @@ -862,13 +1156,27 @@ mod tests { &cx, &session, v01::ProductAccountId { - dot_ns_identifier: "other.dot".to_string(), + dot_ns_identifier: "truapi-playground.dot".to_string(), derivation_index: 0, }, - "myapp.dot".to_string(), + "truapi-playground.dot".to_string(), )) - .expect_err("alias deferred"); - assert!(matches!(alias, AuthorityError::Unavailable { .. })); + .expect("alias derives"); + + let expected = + crate::host_logic::alias::derive_product_alias(&ENTROPY, "truapi-playground.dot", 0) + .unwrap(); + assert_eq!(alias.context, expected.context); + assert_eq!(alias.alias, expected.alias.to_vec()); + } + + #[test] + fn empty_resource_allocation_returns_empty_response() { + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + let cx = CallContext::new(); let alloc = futures::executor::block_on(authority.allocate_resources( &cx, @@ -876,7 +1184,20 @@ mod tests { "myapp.dot".to_string(), v01::HostRequestResourceAllocationRequest { resources: vec![] }, )) - .expect_err("allocation deferred"); - assert!(matches!(alloc, AuthorityError::Unavailable { .. })); + .expect("empty allocation"); + assert!(alloc.outcomes.is_empty()); + } + + #[test] + fn bulletin_allowance_key_uses_product_scoped_ios_path() { + let key = derive_bulletin_allowance_key(&ENTROPY, "truapi-playground.dot") + .expect("bulletin allowance key"); + let expected = derive_sr25519_hard_path( + &ENTROPY, + &["allowance", "bulletin", "truapi-playground.dot"], + ) + .unwrap(); + + assert_eq!(key.as_secret_bytes(), &expected.secret.to_bytes()); } } diff --git a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs index 9da344b0..cf7899a0 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs @@ -1,5 +1,4 @@ -use super::{SigningHost, product_authority_error}; -use crate::host_logic::product_account::derive_root_keypair_from_entropy; +use super::{SigningHost, wallet_root_keypair}; use crate::host_logic::session::SessionInfo; use crate::runtime::authority::AuthorityError; use crate::runtime::connected_session_ui_info; @@ -16,14 +15,31 @@ pub(crate) trait LocalActivation: Send + Sync { /// Activate a local session from raw BIP-39 entropy, deriving the root /// public key and marking the session connected. async fn activate_local_session(&self, secret: Vec) -> Result<(), AuthorityError>; + + /// Activate a local session and attach known identity metadata from the + /// host's signer/account store. + async fn activate_local_session_with_identity( + &self, + secret: Vec, + lite_username: Option, + ) -> Result<(), AuthorityError>; } #[async_trait::async_trait] impl LocalActivation for SigningHost { async fn activate_local_session(&self, secret: Vec) -> Result<(), AuthorityError> { + self.activate_local_session_with_identity(secret, None) + .await + } + + async fn activate_local_session_with_identity( + &self, + secret: Vec, + lite_username: Option, + ) -> Result<(), AuthorityError> { let secret = Zeroizing::new(secret); - let root = derive_root_keypair_from_entropy(&secret).map_err(product_authority_error)?; - let public_key = root.public.to_bytes(); + let wallet = wallet_root_keypair(&secret)?; + let public_key = wallet.public.to_bytes(); *self .root_entropy .lock() @@ -33,7 +49,7 @@ impl LocalActivation for SigningHost { sso: None, root_entropy_source: None, identity_account_id: None, - lite_username: None, + lite_username, full_username: None, }; self.session_state.set_session(session.clone()); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs new file mode 100644 index 00000000..8a70b6db --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -0,0 +1,658 @@ +//! Signing-host responder half of the host-spec §B pairing protocol. +//! +//! Answers a pairing host's handshake proposal (QR/deeplink) with an +//! encrypted `Success` statement, then serves the encrypted SSO session: +//! acks every inbound request statement, dispatches the batched +//! [`v1::RemoteMessage`] requests onto the local signing authority, and posts +//! the response statements the pairing host is waiting for. Runs until the +//! peer sends `Disconnected`, the local session ends, or the transport fails. +//! +//! Sensitive operations consult [`truapi_platform::UserConfirmation`], the +//! same seam browser hosts use for their confirmation modals; a headless host +//! implements it with its approval policy. + +use std::collections::HashSet; +use std::sync::Arc; + +use parity_scale_codec::Encode; +use tracing::{debug, info, instrument, warn}; +use truapi::latest::HostAccountGetAliasResponse; +use truapi::{CallContext, v01}; +use truapi_platform::{ + CreateTransactionReview, SignPayloadReview, SignRawReview, UserConfirmationReview, +}; + +use super::SigningHost; +use crate::host_logic::entropy::root_entropy_source; +#[cfg(not(target_arch = "wasm32"))] +use crate::host_logic::product_account::ProductAccountError; +use crate::host_logic::product_account::derive_sr25519_hard_path; +use crate::host_logic::session::SsoSessionInfo; +use crate::host_logic::sso::messages::{ + self, CreateTransactionPayload, IncomingSsoRequest, OnExistingAllowancePolicy, RemoteMessage, + RemoteMessageData, ResourceAllocationResponse, RingVrfAliasResponse, SignRawLegacyResponse, + SigningPayloadResponseData, SigningRequest, SigningResponse, SsoAllocatableResource, + SsoAllocatedResource, SsoAllocationOutcome, StatementStoreProductSignResponse, + build_outgoing_request_statement, build_signed_session_response_statement, + decode_incoming_sso_request, v1, +}; +use crate::host_logic::sso::pairing::{ + ResponderIdentity, VersionedHandshakeProposal, bootstrap_topic, decode_pairing_deeplink, + derive_p256_keypair_from_entropy, encrypt_v2_handshake_response, + establish_responder_session_info, v2, +}; +use crate::host_logic::statement_store::{build_signed_statement, parse_new_statements_result}; +use crate::runtime::authority::{ + CreateTransactionAuthorityRequest, ProductAuthority, SignPayloadAuthorityRequest, + SignRawAuthorityRequest, +}; +use crate::runtime::services::RuntimeServices; +use crate::runtime::sso_remote::fresh_statement_expiry; +use crate::runtime::statement_store_rpc; + +/// Domain label for the responder's persistent P-256 encryption key. +const SSO_ENCRYPTION_KEY_LABEL: &[u8] = b"sso-encryption"; +/// Domain label for the identity chat key shared in the handshake payload. +const CHAT_KEY_LABEL: &[u8] = b"chat-encryption"; + +/// Terminal outcome of one responder serve loop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResponderExit { + /// The pairing host announced `Disconnected`. + PeerDisconnected, + /// The statement subscription ended without a disconnect message. + SubscriptionEnded, +} + +/// Answer `deeplink` and serve the resulting SSO session until it ends. +#[instrument(skip_all, fields(runtime.method = "sso_responder.respond_to_pairing"))] +pub(crate) async fn respond_to_pairing( + services: Arc, + signing_host: Arc, + deeplink: &str, +) -> Result { + let VersionedHandshakeProposal::V2(proposal) = decode_pairing_deeplink(deeplink)?; + let entropy = signing_host + .root_entropy() + .map_err(|err| format!("signing host has no active local session: {err}"))?; + // `//wallet` is the user's main account (host-spec C.0): it backs + // product-account derivation and is `rootUserAccountId`. The + // statement/identity account is `//wallet//sso`. Usernames may be + // registered on either (mobile registers `//wallet`, the bot + // `//wallet//sso`); the paired host's lookup tries identity then root. + let wallet = derive_sr25519_hard_path(&entropy, &["wallet"]) + .map_err(|err| format!("//wallet derivation failed: {err}"))?; + let statement = derive_sr25519_hard_path(&entropy, &["wallet", "sso"]) + .map_err(|err| format!("//wallet//sso derivation failed: {err}"))?; + let (encryption_secret_key, encryption_public_key) = + derive_p256_keypair_from_entropy(&entropy, SSO_ENCRYPTION_KEY_LABEL) + .map_err(|err| format!("responder P-256 derivation failed: {err}"))?; + let (identity_chat_private_key, _) = derive_p256_keypair_from_entropy(&entropy, CHAT_KEY_LABEL) + .map_err(|err| format!("responder chat-key derivation failed: {err}"))?; + let identity = ResponderIdentity { + statement_secret: statement.secret.to_bytes(), + statement_public_key: statement.public.to_bytes(), + encryption_secret_key, + encryption_public_key, + }; + let session = establish_responder_session_info( + &identity, + proposal.device.statement_account_id, + proposal.device.encryption_public_key, + )?; + + let success = v2::EncryptedResponse::Success(Box::new(v2::Success { + identity_account_id: identity.statement_public_key, + root_account_id: wallet.public.to_bytes(), + identity_chat_private_key, + sso_enc_pub_key: identity.encryption_public_key, + device_enc_pub_key: identity.encryption_public_key, + root_entropy_source: root_entropy_source(&entropy), + })); + let handshake = encrypt_v2_handshake_response(proposal.device.encryption_public_key, &success)?; + let topic = bootstrap_topic( + proposal.device.statement_account_id, + proposal.device.encryption_public_key, + ); + let statement = build_signed_statement( + &session, + topic, + topic, + handshake.encode(), + fresh_statement_expiry(), + )?; + services + .statement_store + .submit(statement, "sso-responder handshake") + .await?; + info!("answered pairing handshake, serving SSO session"); + + serve_session(services, signing_host, session).await +} + +/// Serve inbound session statements until the session ends. +#[instrument(skip_all, fields(runtime.method = "sso_responder.serve_session"))] +async fn serve_session( + services: Arc, + signing_host: Arc, + session: SsoSessionInfo, +) -> Result { + let rpc_client = services + .statement_store + .client("sso-responder session") + .await?; + let mut subscription = + statement_store_rpc::subscribe_match_all(&rpc_client, &[session.session_id_peer]) + .await + .map_err(|err| format!("sso-responder subscribe failed: {err}"))?; + let mut served_request_ids = HashSet::new(); + + while let Some(item) = subscription.next().await { + let value = item.map_err(|err| format!("sso-responder subscription failed: {err}"))?; + let page = parse_new_statements_result("sso-responder".to_string(), &value) + .map_err(|err| err.to_string())?; + for statement in page.statements { + let incoming = match decode_incoming_sso_request(&session, &statement) { + Ok(Some(incoming)) => incoming, + Ok(None) => continue, + Err(reason) => { + debug!(%reason, "ignoring undecodable session statement"); + continue; + } + }; + if !served_request_ids.insert(incoming.request_id.clone()) { + continue; + } + if let Some(exit) = serve_request(&services, &signing_host, &session, incoming).await? { + return Ok(exit); + } + } + } + Ok(ResponderExit::SubscriptionEnded) +} + +/// Ack one inbound request statement and answer its batched messages. +async fn serve_request( + services: &Arc, + signing_host: &Arc, + session: &SsoSessionInfo, + incoming: IncomingSsoRequest, +) -> Result, String> { + let ack = build_signed_session_response_statement( + session, + incoming.request_id.clone(), + 0, + fresh_statement_expiry(), + )?; + services + .statement_store + .submit(ack, "sso-responder ack") + .await?; + + for message in incoming.messages { + let RemoteMessageData::V1(request) = message.data; + if matches!(request, v1::RemoteMessage::Disconnected) { + info!("pairing host disconnected the SSO session"); + return Ok(Some(ResponderExit::PeerDisconnected)); + } + let Some(response) = + answer_remote_message(services, signing_host, message.message_id, request).await + else { + continue; + }; + let statement_request_id = format!("resp:{}", response.message_id); + let statement = build_outgoing_request_statement( + session, + statement_request_id, + vec![response], + fresh_statement_expiry(), + )?; + services + .statement_store + .submit(statement, "sso-responder response") + .await?; + } + Ok(None) +} + +/// Answer one application-level request message; `None` for message kinds +/// that take no response (responses echoed by the peer, unknown variants). +async fn answer_remote_message( + services: &Arc, + signing_host: &Arc, + message_id: String, + request: v1::RemoteMessage, +) -> Option { + let response_id = format!("{message_id}:response"); + let data = match request { + v1::RemoteMessage::SignRequest(request) => v1::RemoteMessage::SignResponse( + sign_response(services, signing_host, &message_id, *request).await, + ), + v1::RemoteMessage::RingVrfAliasRequest(request) => { + let payload = account_alias_response(signing_host, request).await; + v1::RemoteMessage::RingVrfAliasResponse(RingVrfAliasResponse { + responding_to: message_id, + payload, + }) + } + v1::RemoteMessage::ResourceAllocationRequest(request) => { + let payload = resource_allocation_response(services, signing_host, request).await; + if let Err(reason) = &payload { + warn!(%reason, "resource allocation request failed"); + } + v1::RemoteMessage::ResourceAllocationResponse(ResourceAllocationResponse { + responding_to: message_id, + payload, + }) + } + v1::RemoteMessage::CreateTransactionRequest(request) => { + let CreateTransactionPayload::V1(payload) = request.payload; + let signed_transaction = create_transaction_response( + services, + signing_host, + CreateTransactionReview::Product(payload.clone()), + CreateTransactionAuthorityRequest::Product(payload), + ) + .await; + v1::RemoteMessage::CreateTransactionResponse(messages::CreateTransactionResponse { + responding_to: message_id, + signed_transaction, + }) + } + v1::RemoteMessage::CreateTransactionLegacyRequest(_) => { + v1::RemoteMessage::CreateTransactionResponse(messages::CreateTransactionResponse { + responding_to: message_id, + signed_transaction: Err( + "signing host: legacy-account transactions are not supported".to_string(), + ), + }) + } + v1::RemoteMessage::SignRawLegacyRequest(_) => { + v1::RemoteMessage::SignRawLegacyResponse(SignRawLegacyResponse { + responding_to: message_id, + signature: Err( + "signing host: legacy-account raw signing is not supported".to_string() + ), + }) + } + v1::RemoteMessage::StatementStoreProductSignRequest(request) => { + let signature = statement_store_product_sign_response(signing_host, request).await; + v1::RemoteMessage::StatementStoreProductSignResponse( + StatementStoreProductSignResponse { + responding_to: message_id, + signature, + }, + ) + } + v1::RemoteMessage::Disconnected + | v1::RemoteMessage::SignResponse(_) + | v1::RemoteMessage::RingVrfAliasResponse(_) + | v1::RemoteMessage::ResourceAllocationResponse(_) + | v1::RemoteMessage::CreateTransactionResponse(_) + | v1::RemoteMessage::SignRawLegacyResponse(_) + | v1::RemoteMessage::StatementStoreProductSignResponse(_) => return None, + }; + Some(RemoteMessage { + message_id: response_id, + data: RemoteMessageData::V1(data), + }) +} + +async fn resource_allocation_response( + services: &Arc, + signing_host: &Arc, + request: messages::ResourceAllocationRequest, +) -> Result, String> { + let mut outcomes = Vec::with_capacity(request.resources.len()); + for resource in request.resources { + let outcome = match resource { + SsoAllocatableResource::StatementStoreAllowance => { + let slot_account_key = allocate_statement_store_allowance( + services, + signing_host, + &request.calling_product_id, + ) + .await?; + SsoAllocationOutcome::Allocated(SsoAllocatedResource::StatementStoreAllowance { + slot_account_key, + }) + } + SsoAllocatableResource::BulletinAllowance => { + let slot_account_key = allocate_bulletin_allowance( + services, + signing_host, + &request.calling_product_id, + request.on_existing, + ) + .await?; + SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { + slot_account_key, + }) + } + SsoAllocatableResource::SmartContractAllowance(_) + | SsoAllocatableResource::AutoSigning => SsoAllocationOutcome::NotAvailable, + }; + outcomes.push(outcome); + } + Ok(outcomes) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) async fn allocate_statement_store_allowance( + services: &Arc, + signing_host: &SigningHost, + product_id: &str, +) -> Result, String> { + use crate::runtime::statement_allowance::{ + self, fetch_chain_state, fetch_metadata, find_including_ring, register_statement_account, + }; + + let entropy = signing_host.root_entropy().map_err(|err| err.reason())?; + let allowance = + derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id]) + .map_err(product_account_error)?; + let target = allowance.public.to_bytes(); + let bandersnatch = statement_allowance::bandersnatch_entropy(&entropy); + let rpc = statement_allowance::rpc::RpcClient::new( + services + .statement_store + .client("statement-store allowance") + .await?, + ); + let metadata = fetch_metadata(&rpc).await?; + let chain_state = fetch_chain_state(&rpc).await?; + let current = statement_allowance::ring::read_current_ring_index(&rpc).await?; + let ring = find_including_ring(&rpc, &metadata, bandersnatch, current) + .await? + .ok_or_else(|| { + "signing account is not a LitePeople ring member; cannot grant statement-store allowance" + .to_string() + })?; + let period = statement_allowance::slot::current_period(current_unix_secs()?); + let outcome = register_statement_account( + &rpc, + &metadata, + &chain_state, + bandersnatch, + &target, + period, + &ring, + ) + .await?; + match outcome { + statement_allowance::RegistrationOutcome::Registered { + block_hash, + seq, + ring_index, + } => { + info!( + %product_id, + %block_hash, + seq, + ring_index, + "registered statement-store allowance" + ); + } + statement_allowance::RegistrationOutcome::AlreadyAllocated { seq } => { + info!( + %product_id, + seq, + "statement-store allowance already allocated" + ); + } + } + Ok(allowance.secret.to_bytes().to_vec()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) async fn allocate_bulletin_allowance( + services: &Arc, + signing_host: &SigningHost, + product_id: &str, + policy: OnExistingAllowancePolicy, +) -> Result, String> { + use crate::runtime::statement_allowance::{ + self, claim_long_term_storage, fetch_bulletin_allowance, fetch_chain_state, fetch_metadata, + find_including_ring, wait_bulletin_authorization, + }; + + const AUTHORIZATION_WAIT: std::time::Duration = std::time::Duration::from_secs(60); + + let entropy = signing_host.root_entropy().map_err(|err| err.reason())?; + let allowance = derive_sr25519_hard_path(&entropy, &["allowance", "bulletin", product_id]) + .map_err(product_account_error)?; + let target = allowance.public.to_bytes(); + + let bulletin_rpc = statement_allowance::rpc::RpcClient::new( + services + .bulletin + .client("bulletin allowance") + .await + .map_err(|err| err.reason())?, + ); + let current_allowance = fetch_bulletin_allowance(&bulletin_rpc, &target).await?; + if matches!(policy, OnExistingAllowancePolicy::Ignore) + && current_allowance.is_some_and(|allowance| allowance.available()) + { + return Ok(allowance.secret.to_bytes().to_vec()); + } + + let people_rpc = statement_allowance::rpc::RpcClient::new( + services + .statement_store + .client("bulletin allowance claim") + .await?, + ); + let metadata = fetch_metadata(&people_rpc).await?; + let chain_state = fetch_chain_state(&people_rpc).await?; + let bandersnatch = statement_allowance::bandersnatch_entropy(&entropy); + let current = statement_allowance::ring::read_current_ring_index(&people_rpc).await?; + let ring = find_including_ring(&people_rpc, &metadata, bandersnatch, current) + .await? + .ok_or_else(|| { + "signing account is not a LitePeople ring member; cannot grant Bulletin allowance" + .to_string() + })?; + let period_duration = statement_allowance::slot::long_term_storage_period_duration(&metadata)?; + let period = statement_allowance::slot::current_long_term_storage_period( + current_unix_secs()?, + period_duration, + )?; + let outcome = claim_long_term_storage( + &people_rpc, + &metadata, + &chain_state, + bandersnatch, + &target, + period, + &ring, + ) + .await?; + let statement_allowance::LongTermStorageOutcome::Claimed { + block_hash, + counter, + ring_index, + } = outcome; + info!( + %product_id, + %block_hash, + counter, + ring_index, + "claimed Bulletin long-term storage allowance" + ); + + let authorization = wait_bulletin_authorization( + &bulletin_rpc, + &target, + current_allowance, + AUTHORIZATION_WAIT, + ) + .await?; + info!( + %product_id, + remained_size = authorization.remained_size, + remained_transactions = authorization.remained_transactions, + "Bulletin authorization visible" + ); + Ok(allowance.secret.to_bytes().to_vec()) +} + +#[cfg(target_arch = "wasm32")] +pub(super) async fn allocate_statement_store_allowance( + _services: &Arc, + _signing_host: &SigningHost, + _product_id: &str, +) -> Result, String> { + Err("signing host: statement-store allowance allocation is native-only".to_string()) +} + +#[cfg(target_arch = "wasm32")] +pub(super) async fn allocate_bulletin_allowance( + _services: &Arc, + _signing_host: &SigningHost, + _product_id: &str, + _policy: OnExistingAllowancePolicy, +) -> Result, String> { + Err("signing host: Bulletin allowance allocation is native-only".to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn current_unix_secs() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| "system clock before UNIX epoch".to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn product_account_error(err: ProductAccountError) -> String { + err.to_string() +} + +/// Confirm and serve a payload or raw signing request. +async fn sign_response( + services: &Arc, + signing_host: &Arc, + message_id: &str, + request: SigningRequest, +) -> SigningResponse { + let payload = serve_sign_request(services, signing_host, request).await; + if let Err(reason) = &payload { + warn!(%reason, "sign request failed"); + } + SigningResponse { + responding_to: message_id.to_string(), + payload, + } +} + +async fn serve_sign_request( + services: &Arc, + signing_host: &Arc, + request: SigningRequest, +) -> Result { + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + let cx = CallContext::new(); + let response = match request { + SigningRequest::Payload(request) => { + let request: v01::HostSignPayloadRequest = (*request).into(); + confirm( + services, + UserConfirmationReview::SignPayload(SignPayloadReview::Product(request.clone())), + ) + .await?; + signing_host + .sign_payload(&cx, &session, SignPayloadAuthorityRequest::Product(request)) + .await + } + SigningRequest::Raw(request) => { + let request: v01::HostSignRawRequest = request.into(); + confirm( + services, + UserConfirmationReview::SignRaw(SignRawReview::Product(request.clone())), + ) + .await?; + signing_host + .sign_raw(&cx, &session, SignRawAuthorityRequest::Product(request)) + .await + } + } + .map_err(|err| err.reason())?; + Ok(SigningPayloadResponseData { + signature: response.signature, + signed_transaction: response.signed_transaction, + }) +} + +async fn statement_store_product_sign_response( + signing_host: &Arc, + request: messages::StatementStoreProductSignRequest, +) -> Result, String> { + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + let cx = CallContext::new(); + signing_host + .sign_statement_store_product_payload( + &cx, + &session, + request.product_account_id, + request.payload, + ) + .await + .map(|signature| signature.to_vec()) + .map_err(|err| err.reason()) +} + +/// Confirm and serve a transaction-creation request. +async fn create_transaction_response( + services: &Arc, + signing_host: &Arc, + review: CreateTransactionReview, + request: CreateTransactionAuthorityRequest, +) -> Result, String> { + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + confirm(services, UserConfirmationReview::CreateTransaction(review)).await?; + let cx = CallContext::new(); + signing_host + .create_transaction(&cx, &session, request) + .await + .map(|response| response.transaction) + .map_err(|err| err.reason()) +} + +async fn account_alias_response( + signing_host: &Arc, + request: messages::RingVrfAliasRequest, +) -> Result { + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + let cx = CallContext::new(); + signing_host + .account_alias( + &cx, + &session, + request.product_account_id, + request.product_id, + ) + .await + .map_err(|err| err.reason()) +} + +/// Run the platform confirmation seam; rejection and failure both refuse the +/// operation with an opaque reason (host-spec B.7). +async fn confirm( + services: &Arc, + review: UserConfirmationReview, +) -> Result<(), String> { + match services.platform.confirm_user_action(review).await { + Ok(true) => Ok(()), + Ok(false) => Err("Rejected".to_string()), + Err(err) => Err(format!("confirmation failed: {}", err.reason)), + } +} diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index ab6da4b5..0b8239ce 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -228,7 +228,7 @@ impl<'a> SsoPairingFlow<'a> { }; let resolve_session = resolve_session_identity_with_chain( &self.host.chain, - self.host.host_config.people_chain_genesis_hash, + self.host.host_config.identity_lookup_genesis_hash(), session, ) .fuse(); diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs new file mode 100644 index 00000000..cb9a1e95 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -0,0 +1,465 @@ +//! On-chain statement-store allowance registration (`set_statement_store_account`). +//! +//! Mirrors how an iOS/web client obtains statement-store allowance from the real +//! People chain: build the `Resources.set_statement_store_account` call, prove +//! LitePeople ring membership with a bandersnatch ring-VRF, and submit the +//! resulting unsigned General (v5) extrinsic. Native only (needs the +//! `verifiable` prover and live chain reads). + +pub mod dynamic; +pub mod extension; +pub mod extrinsic; +pub mod proof; +pub mod ring; +pub mod rpc; +pub mod slot; + +use std::time::{Duration, Instant}; + +use blake2_rfc::blake2b::blake2b; +use futures::FutureExt; +use parity_scale_codec::Decode; +use serde_json::{Value, json}; +use sp_crypto_hashing::twox_128; +use tracing::{info, warn}; + +use extension::{ChainState, Metadata}; +use ring::RingParams; +use rpc::RpcClient; +use slot::SlotSelection; + +/// Bandersnatch entropy for a bip39 entropy: `blake2b256(bip39_entropy)`. +pub fn bandersnatch_entropy(bip39_entropy: &[u8]) -> [u8; 32] { + blake2b(32, &[], bip39_entropy) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +/// Fetch and decode the runtime metadata (`state_getMetadata`). +pub async fn fetch_metadata(rpc: &RpcClient) -> Result { + let value = rpc + .call("state_getMetadata", json!([])) + .await + .map_err(|e| e.to_string())?; + let hex_str = value + .as_str() + .ok_or_else(|| "state_getMetadata returned non-string".to_string())?; + let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(hex_str)) + .map_err(|e| format!("metadata hex: {e}"))?; + // `state_getMetadata` may return either the raw `RuntimeMetadataPrefixed` + // (starts with the `meta` magic) or an OpaqueMetadata wrapper + // (`Vec` = compact(len) ‖ bytes). Strip the wrapper only when present. + const META_MAGIC: [u8; 4] = *b"meta"; + if bytes.get(..4) == Some(&META_MAGIC) { + Metadata::decode(&bytes) + } else { + let inner = + Vec::::decode(&mut &bytes[..]).map_err(|e| format!("opaque metadata: {e}"))?; + Metadata::decode(&inner) + } +} + +/// Fetch the chain state needed to fill the signed extensions. +pub async fn fetch_chain_state(rpc: &RpcClient) -> Result { + let genesis_hex = rpc + .call("chain_getBlockHash", json!([0])) + .await + .map_err(|e| e.to_string())?; + let genesis_str = genesis_hex + .as_str() + .ok_or_else(|| "chain_getBlockHash returned non-string".to_string())?; + let genesis = hex::decode(genesis_str.strip_prefix("0x").unwrap_or(genesis_str)) + .map_err(|e| format!("genesis hex: {e}"))?; + let genesis_hash: [u8; 32] = genesis + .try_into() + .map_err(|_| "genesis hash is not 32 bytes".to_string())?; + + let runtime = rpc + .call("state_getRuntimeVersion", json!([])) + .await + .map_err(|e| e.to_string())?; + let spec_version = json_u32(&runtime, "specVersion")?; + let transaction_version = json_u32(&runtime, "transactionVersion")?; + + Ok(ChainState { + spec_version, + transaction_version, + genesis_hash, + nonce: 0, + }) +} + +/// Read a u32 field from a JSON object. +fn json_u32(value: &Value, field: &str) -> Result { + value + .get(field) + .and_then(Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .ok_or_else(|| format!("missing/invalid {field}")) +} + +/// Result of a statement-store allowance registration attempt. +pub enum RegistrationOutcome { + /// The extrinsic reached a block; the target now holds slot `seq`. + Registered { + /// Block hash the extrinsic landed in. + block_hash: String, + /// Claimed slot sequence. + seq: u32, + /// Ring index the proof was built against. + ring_index: u32, + }, + /// The target already held a slot this period; nothing submitted. + AlreadyAllocated { + /// Existing slot sequence. + seq: u32, + }, +} + +/// Result of a long-term storage claim attempt. +pub enum LongTermStorageOutcome { + /// The extrinsic reached a block; the target should receive Bulletin + /// authorization once XCM/chain propagation completes. + Claimed { + /// Block hash the extrinsic landed in. + block_hash: String, + /// Claimed counter within the long-term storage period. + counter: u8, + /// Ring index the proof was built against. + ring_index: u32, + }, +} + +/// Bulletin authorization state for one account. +#[derive(Debug, Clone, Copy)] +pub struct BulletinAllowanceInfo { + pub remained_size: u64, + pub remained_transactions: u32, + pub expires_in: u32, + pub fetched_at: u32, +} + +impl BulletinAllowanceInfo { + pub fn available(self) -> bool { + self.remained_size > 0 + && self.remained_transactions > 0 + && self.fetched_at < self.expires_in + } +} + +/// Find the newest ring (scanning up to `lookback` back from the current index) +/// that includes our member key. Reads the ring exponent once and stops at the +/// first match. +pub async fn find_including_ring( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + lookback: u32, +) -> Result, String> { + let member = proof::member_key(entropy); + let exponent = ring::read_ring_exponent(rpc, metadata).await?; + let current = ring::read_current_ring_index(rpc).await?; + let oldest = current.saturating_sub(lookback); + for ring_index in (oldest..=current).rev() { + let members = ring::read_ring_members_at(rpc, ring_index).await?; + if members.contains(&member) { + return Ok(Some(RingParams { + members, + exponent, + ring_index, + })); + } + } + Ok(None) +} + +/// Register statement-store allowance for `target`, proving membership in the +/// already-located `ring`, at UTC-day `period`. +pub async fn register_statement_account( + rpc: &RpcClient, + metadata: &Metadata, + chain_state: &ChainState, + entropy: [u8; 32], + target: &[u8; 32], + period: u32, + ring: &RingParams, +) -> Result { + let mut skipped_duplicate_slots = Vec::new(); + loop { + let seq = match slot::scan_slot_excluding( + rpc, + metadata, + entropy, + period, + target, + &skipped_duplicate_slots, + ) + .await? + { + SlotSelection::AlreadyAllocated(seq) => { + return Ok(RegistrationOutcome::AlreadyAllocated { seq }); + } + SlotSelection::Free(seq) => seq, + }; + + let context = slot::derive_slot_context(period, seq); + let call = extrinsic::build_set_statement_store_account_call(period, seq, target); + let message = extension::build_proof_message(metadata, &call, chain_state)?; + let domain = proof::domain_for_ring_exponent(ring.exponent)?; + let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; + let as_resources_extra = extrinsic::build_as_resources_extra(&ring_proof, ring.ring_index); + let extrinsic = + extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; + + match rpc.submit_and_watch(&extrinsic).await { + Ok(block_hash) => { + return Ok(RegistrationOutcome::Registered { + block_hash, + seq, + ring_index: ring.ring_index, + }); + } + Err(err) if duplicate_submit_error(&err) => { + skipped_duplicate_slots.push(seq); + } + Err(err) => return Err(err.to_string()), + } + } +} + +/// Claim long-term Bulletin storage authorization for `target`, proving +/// membership in the already-located `ring`, at People-chain `period`. +pub async fn claim_long_term_storage( + rpc: &RpcClient, + metadata: &Metadata, + chain_state: &ChainState, + entropy: [u8; 32], + target: &[u8; 32], + period: u32, + ring: &RingParams, +) -> Result { + let revision = ring::read_ring_revision(rpc, metadata, ring.ring_index).await?; + let mut skipped_duplicate_counters = Vec::new(); + loop { + let counter = slot::scan_long_term_storage_counter_excluding( + rpc, + metadata, + entropy, + period, + &skipped_duplicate_counters, + ) + .await?; + + let context = slot::derive_long_term_storage_context(period, counter); + let call = extrinsic::build_claim_long_term_storage_call(period, counter, target); + let message = extension::build_proof_message(metadata, &call, chain_state)?; + let domain = proof::domain_for_ring_exponent(ring.exponent)?; + let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; + let as_resources_extra = + extrinsic::build_long_term_storage_extra(&ring_proof, ring.ring_index, revision); + let extrinsic = + extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; + info!( + period, + counter, + ring_index = ring.ring_index, + revision, + "submitting Bulletin long-term-storage claim" + ); + + match rpc.submit_and_watch(&extrinsic).await { + Ok(block_hash) => { + return Ok(LongTermStorageOutcome::Claimed { + block_hash, + counter, + ring_index: ring.ring_index, + }); + } + Err(err) if duplicate_submit_error(&err) => { + skipped_duplicate_counters.push(counter); + } + Err(err) => { + warn!( + period, + counter, + ring_index = ring.ring_index, + revision, + %err, + "Bulletin long-term-storage claim failed" + ); + return Err(err.to_string()); + } + } + } +} + +/// Fetch Bulletin `TransactionStorage.Authorizations[Account(target)]`. +pub async fn fetch_bulletin_allowance( + rpc: &RpcClient, + target: &[u8; 32], +) -> Result, String> { + let Some(bytes) = rpc + .get_storage(&bulletin_authorization_key(target)) + .await + .map_err(|e| e.to_string())? + else { + return Ok(None); + }; + let fetched_at = fetch_block_number(rpc).await?; + decode_bulletin_allowance(&bytes, fetched_at).map(Some) +} + +/// Wait until Bulletin authorization is available and fresher than `current`. +pub async fn wait_bulletin_authorization( + rpc: &RpcClient, + target: &[u8; 32], + current: Option, + timeout: Duration, +) -> Result { + let started = Instant::now(); + let baseline = current.filter(|info| info.available()); + loop { + let Some(info) = fetch_bulletin_allowance(rpc, target).await? else { + wait_before_next_bulletin_authorization_poll(started, timeout).await?; + continue; + }; + if authorization_refreshed(info, baseline) { + return Ok(info); + } + wait_before_next_bulletin_authorization_poll(started, timeout).await?; + } +} + +async fn wait_before_next_bulletin_authorization_poll( + started: Instant, + timeout: Duration, +) -> Result<(), String> { + let Some(remaining) = timeout.checked_sub(started.elapsed()) else { + return Err("timed out waiting for Bulletin authorization".to_string()); + }; + let delay = futures_timer::Delay::new(remaining.min(Duration::from_secs(2))).fuse(); + futures::pin_mut!(delay); + delay.await; + Ok(()) +} + +fn authorization_refreshed( + info: BulletinAllowanceInfo, + baseline: Option, +) -> bool { + if !info.available() { + return false; + } + match baseline { + None => true, + Some(current) => { + info.remained_transactions > current.remained_transactions + || info.remained_size > current.remained_size + || info.expires_in > current.expires_in + } + } +} + +/// `TransactionStorage.Authorizations[AuthorizationScope::Account(target)]`. +fn bulletin_authorization_key(target: &[u8; 32]) -> Vec { + let mut scope = Vec::with_capacity(1 + 32); + scope.push(0x00); + scope.extend_from_slice(target); + [ + twox_128(b"TransactionStorage").as_slice(), + twox_128(b"Authorizations").as_slice(), + &ring::blake2_128_concat(&scope), + ] + .concat() +} + +fn decode_bulletin_allowance( + bytes: &[u8], + fetched_at: u32, +) -> Result { + let mut input = bytes; + let transactions = + u32::decode(&mut input).map_err(|err| format!("authorization transactions: {err}"))?; + let transactions_allowance = u32::decode(&mut input) + .map_err(|err| format!("authorization transactions_allowance: {err}"))?; + let bytes_used = + u64::decode(&mut input).map_err(|err| format!("authorization bytes: {err}"))?; + let _bytes_permanent = + u64::decode(&mut input).map_err(|err| format!("authorization bytes_permanent: {err}"))?; + let bytes_allowance = + u64::decode(&mut input).map_err(|err| format!("authorization bytes_allowance: {err}"))?; + let expires_in = + u32::decode(&mut input).map_err(|err| format!("authorization expiration: {err}"))?; + Ok(BulletinAllowanceInfo { + remained_size: bytes_allowance.saturating_sub(bytes_used), + remained_transactions: transactions_allowance.saturating_sub(transactions), + expires_in, + fetched_at, + }) +} + +async fn fetch_block_number(rpc: &RpcClient) -> Result { + let header = rpc + .call("chain_getHeader", json!([])) + .await + .map_err(|err| err.to_string())?; + let number = header + .get("number") + .and_then(Value::as_str) + .ok_or_else(|| "chain_getHeader returned no number".to_string())?; + u32::from_str_radix(number.trim_start_matches("0x"), 16) + .map_err(|err| format!("chain_getHeader number: {err}")) +} + +fn duplicate_submit_error(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("priority is too low") + || message.contains("already imported") + || message.contains("temporarily banned") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn allowance( + remained_size: u64, + remained_transactions: u32, + expires_in: u32, + ) -> BulletinAllowanceInfo { + BulletinAllowanceInfo { + remained_size, + remained_transactions, + expires_in, + fetched_at: 10, + } + } + + #[test] + fn bulletin_refresh_accepts_available_state_when_baseline_was_unusable() { + let exhausted_by_size = allowance(0, 4, 100); + let refreshed_same_transactions = allowance(4096, 4, 100); + + assert!(!exhausted_by_size.available()); + assert!(authorization_refreshed( + refreshed_same_transactions, + Some(exhausted_by_size).filter(|info| info.available()), + )); + } + + #[test] + fn bulletin_refresh_accepts_size_only_increase() { + let baseline = allowance(128, 4, 100); + let refreshed = allowance(4096, 4, 100); + + assert!(authorization_refreshed(refreshed, Some(baseline))); + } + + #[test] + fn bulletin_refresh_rejects_unchanged_available_state() { + let baseline = allowance(128, 4, 100); + + assert!(!authorization_refreshed(baseline, Some(baseline))); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs new file mode 100644 index 00000000..42e1df77 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs @@ -0,0 +1,164 @@ +//! Minimal metadata-driven SCALE walker. +//! +//! Just enough to read one field out of a storage struct without a full dynamic +//! codec: `skip` advances a cursor past one value of a given type, +//! `read_field_variant_name` walks a composite to a named field and returns its +//! enum variant name (used for `CollectionInfo.ring_size` -> `R2e9`/`R2e10`/`R2e14`), +//! and `read_field_u32` reads simple numeric fields such as `RingRoot.revision`. + +use parity_scale_codec::{Compact, Decode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + +/// Advance `input` past exactly one SCALE-encoded value of `type_id`. +pub fn skip(registry: &PortableRegistry, type_id: u32, input: &mut &[u8]) -> Result<(), String> { + let ty = registry + .resolve(type_id) + .ok_or_else(|| format!("unknown type id {type_id}"))?; + match &ty.type_def { + TypeDef::Composite(c) => { + for field in &c.fields { + skip(registry, field.ty.id, input)?; + } + } + TypeDef::Tuple(t) => { + for field in &t.fields { + skip(registry, field.id, input)?; + } + } + TypeDef::Array(a) => { + for _ in 0..a.len { + skip(registry, a.type_param.id, input)?; + } + } + TypeDef::Sequence(s) => { + let len = read_compact(input)?; + for _ in 0..len { + skip(registry, s.type_param.id, input)?; + } + } + TypeDef::Variant(v) => { + let index = read_u8(input)?; + let variant = v + .variants + .iter() + .find(|var| var.index == index) + .ok_or_else(|| format!("unknown variant index {index}"))?; + for field in &variant.fields { + skip(registry, field.ty.id, input)?; + } + } + TypeDef::Compact(_) => { + read_compact(input)?; + } + TypeDef::BitSequence(_) => { + let bits = read_compact(input)?; + advance(input, bits.div_ceil(8))?; + } + TypeDef::Primitive(p) => { + let len = match p { + TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => 1, + TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => 2, + TypeDefPrimitive::Char | TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => 4, + TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => 8, + TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => 16, + TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => 32, + // Length-prefixed UTF-8: compact byte length then the bytes. + TypeDefPrimitive::Str => read_compact(input)?, + }; + advance(input, len)?; + } + } + Ok(()) +} + +/// Walk composite `struct_type_id` to `field_name` and return the enum variant +/// name selected there (the field must be a fieldless/simple enum). +pub fn read_field_variant_name( + registry: &PortableRegistry, + struct_type_id: u32, + field_name: &str, + bytes: &[u8], +) -> Result { + let ty = registry + .resolve(struct_type_id) + .ok_or_else(|| format!("unknown type id {struct_type_id}"))?; + let TypeDef::Composite(composite) = &ty.type_def else { + return Err(format!("type {struct_type_id} is not a composite")); + }; + + let mut input = bytes; + for field in &composite.fields { + if field.name.as_deref() == Some(field_name) { + let field_ty = registry + .resolve(field.ty.id) + .ok_or_else(|| format!("unknown field type id {}", field.ty.id))?; + let TypeDef::Variant(variant) = &field_ty.type_def else { + return Err(format!("field `{field_name}` is not an enum")); + }; + let index = read_u8(&mut input)?; + return variant + .variants + .iter() + .find(|var| var.index == index) + .map(|var| var.name.clone()) + .ok_or_else(|| format!("unknown variant index {index} for `{field_name}`")); + } + skip(registry, field.ty.id, &mut input)?; + } + Err(format!("field `{field_name}` not found")) +} + +/// Walk composite `struct_type_id` to `field_name` and decode the field as a +/// SCALE `u32`. +pub fn read_field_u32( + registry: &PortableRegistry, + struct_type_id: u32, + field_name: &str, + bytes: &[u8], +) -> Result { + let ty = registry + .resolve(struct_type_id) + .ok_or_else(|| format!("unknown type id {struct_type_id}"))?; + let TypeDef::Composite(composite) = &ty.type_def else { + return Err(format!("type {struct_type_id} is not a composite")); + }; + + let mut input = bytes; + for field in &composite.fields { + if field.name.as_deref() == Some(field_name) { + let field_ty = registry + .resolve(field.ty.id) + .ok_or_else(|| format!("unknown field type id {}", field.ty.id))?; + if !matches!(field_ty.type_def, TypeDef::Primitive(TypeDefPrimitive::U32)) { + return Err(format!("field `{field_name}` is not a u32")); + } + return u32::decode(&mut input).map_err(|err| format!("field `{field_name}`: {err}")); + } + skip(registry, field.ty.id, &mut input)?; + } + Err(format!("field `{field_name}` not found")) +} + +/// Decode a SCALE compact-encoded length, advancing `input`. +fn read_compact(input: &mut &[u8]) -> Result { + let Compact(value) = Compact::::decode(input).map_err(|err| format!("compact: {err}"))?; + usize::try_from(value).map_err(|_| "compact length overflow".to_string()) +} + +/// Read one byte, advancing `input`. +fn read_u8(input: &mut &[u8]) -> Result { + let (&first, rest) = input + .split_first() + .ok_or_else(|| "unexpected end".to_string())?; + *input = rest; + Ok(first) +} + +/// Advance `input` by `n` bytes. +fn advance(input: &mut &[u8], n: usize) -> Result<(), String> { + if input.len() < n { + return Err(format!("need {n} bytes, have {}", input.len())); + } + *input = &input[n..]; + Ok(()) +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs new file mode 100644 index 00000000..f23125b1 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs @@ -0,0 +1,418 @@ +//! Signed-extension encoding for the unsigned General (v5) `AsResources` +//! extrinsic, driven by live chain metadata. +//! +//! The extension **order** and per-extension type ids come from the runtime +//! metadata (`state_getMetadata`, V14/V15); the per-extension `extra` / +//! `additional_signed` bytes come from a name-keyed encoder mirroring +//! signing-bot `src/core/create-transaction.ts` `encodeSignedExtensions`, with a +//! generic default for the personhood extensions (all `Option`/void). +//! +//! Two concatenations are derived from the same encoded list: +//! - the ring-VRF proof message (`build_proof_message`) over the extensions +//! strictly *after* `AsResources` (host-spec inherited implication), and +//! - the full extrinsic body's `Σ extra` (see `extrinsic.rs`), over *all* +//! extensions with `AsResources` carrying `Some(AsResourcesInfo)`. + +use std::collections::HashMap; + +use blake2_rfc::blake2b::blake2b; +use frame_metadata::RuntimeMetadata; +use frame_metadata::RuntimeMetadataPrefixed; +use parity_scale_codec::{Compact, Decode, Encode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + +/// Signed-extension identifier that carries the `AsResources` authorization. +pub const AS_RESOURCES: &str = "AsResources"; + +/// Chain state needed to fill the standard signed extensions. +#[derive(Debug, Clone, Copy)] +pub struct ChainState { + /// Runtime `specVersion` (CheckSpecVersion implicit). + pub spec_version: u32, + /// Runtime `transactionVersion` (CheckTxVersion implicit). + pub transaction_version: u32, + /// Genesis block hash (CheckGenesis / CheckMortality implicit). + pub genesis_hash: [u8; 32], + /// Account nonce (CheckNonce extra); ignored by the unsigned path. + pub nonce: u32, +} + +/// A signed extension's identifier plus the type ids of its `extra` and +/// `additional_signed` fields, in metadata order. +struct ExtensionDef { + identifier: String, + extra_type: u32, + additional_signed_type: u32, +} + +/// A signed extension encoded to its `extra` and `additional_signed` bytes. +pub struct EncodedExtension { + /// SCALE-encoded `extra` (goes into the extrinsic body). + pub extra: Vec, + /// SCALE-encoded `additional_signed` (the implicit, part of the signed data). + pub additional_signed: Vec, +} + +/// Decoded metadata: the ordered signed-extension defs, the type registry, and +/// each storage entry's value type id (`(pallet, entry) -> type id`). +pub struct Metadata { + extensions: Vec, + registry: PortableRegistry, + storage_values: HashMap<(String, String), u32>, + constants: HashMap<(String, String), Vec>, +} + +/// Collect extensions, type registry, storage value types, and pallet constants +/// from a decoded V14/V15 metadata; `$set` is the version's `StorageEntryType`. +macro_rules! collect_metadata { + ($m:expr, $set:path) => {{ + let extensions = $m + .extrinsic + .signed_extensions + .iter() + .map(|e| ExtensionDef { + identifier: e.identifier.clone(), + extra_type: e.ty.id, + additional_signed_type: e.additional_signed.id, + }) + .collect(); + let mut storage_values = HashMap::new(); + let mut constants = HashMap::new(); + for pallet in &$m.pallets { + for constant in &pallet.constants { + constants.insert( + (pallet.name.clone(), constant.name.clone()), + constant.value.clone(), + ); + } + let Some(storage) = &pallet.storage else { + continue; + }; + for entry in &storage.entries { + use $set as EntryType; + let value_type = match &entry.ty { + EntryType::Plain(ty) => ty.id, + EntryType::Map { value, .. } => value.id, + }; + storage_values.insert((pallet.name.clone(), entry.name.clone()), value_type); + } + } + (extensions, $m.types, storage_values, constants) + }}; +} + +macro_rules! collect_metadata_v16 { + ($m:expr) => {{ + let extension_indexes = $m + .extrinsic + .transaction_extensions_by_version + .get(&5) + .map(|indexes| { + indexes + .iter() + .map(|Compact(index)| *index as usize) + .collect::>() + }) + .unwrap_or_else(|| (0..$m.extrinsic.transaction_extensions.len()).collect()); + let extensions = extension_indexes + .into_iter() + .filter_map(|index| $m.extrinsic.transaction_extensions.get(index)) + .map(|e| ExtensionDef { + identifier: e.identifier.clone(), + extra_type: e.ty.id, + additional_signed_type: e.implicit.id, + }) + .collect(); + let mut storage_values = HashMap::new(); + let mut constants = HashMap::new(); + for pallet in &$m.pallets { + for constant in &pallet.constants { + constants.insert( + (pallet.name.clone(), constant.name.clone()), + constant.value.clone(), + ); + } + let Some(storage) = &pallet.storage else { + continue; + }; + for entry in &storage.entries { + use frame_metadata::v16::StorageEntryType as EntryType; + let value_type = match &entry.ty { + EntryType::Plain(ty) => ty.id, + EntryType::Map { value, .. } => value.id, + }; + storage_values.insert((pallet.name.clone(), entry.name.clone()), value_type); + } + } + (extensions, $m.types, storage_values, constants) + }}; +} + +impl Metadata { + /// Decode `state_getMetadata` bytes (a `RuntimeMetadataPrefixed`, V14 or + /// V15) into the ordered signed-extension defs, type registry, and storage + /// value types. + pub fn decode(bytes: &[u8]) -> Result { + let prefixed = RuntimeMetadataPrefixed::decode(&mut &bytes[..]) + .map_err(|err| format!("metadata decode failed: {err}"))?; + let (extensions, registry, storage_values, constants) = match prefixed.1 { + RuntimeMetadata::V14(m) => collect_metadata!(m, frame_metadata::v14::StorageEntryType), + RuntimeMetadata::V15(m) => collect_metadata!(m, frame_metadata::v15::StorageEntryType), + RuntimeMetadata::V16(m) => collect_metadata_v16!(m), + other => return Err(format!("unsupported metadata version {}", other.version())), + }; + Ok(Self { + extensions, + registry, + storage_values, + constants, + }) + } + + /// The type registry, for dynamic decoding of storage values. + pub fn registry(&self) -> &PortableRegistry { + &self.registry + } + + /// The value type id of storage entry `pallet::entry`, if present. + pub fn storage_value_type(&self, pallet: &str, entry: &str) -> Option { + self.storage_values + .get(&(pallet.to_string(), entry.to_string())) + .copied() + } + + /// The SCALE-encoded value bytes of pallet constant `pallet::name`. + pub fn constant(&self, pallet: &str, name: &str) -> Option<&[u8]> { + self.constants + .get(&(pallet.to_string(), name.to_string())) + .map(Vec::as_slice) + } + + /// Encode every signed extension in metadata order. + pub fn encode_signed_extensions(&self, state: &ChainState) -> Vec { + self.extensions + .iter() + .map(|ext| { + let (extra, additional_signed) = self.encode_one(ext, state); + EncodedExtension { + extra, + additional_signed, + } + }) + .collect() + } + + /// The signed-extension identifiers, in metadata order. + #[cfg(test)] + pub fn extension_ids(&self) -> Vec<&str> { + self.extensions + .iter() + .map(|e| e.identifier.as_str()) + .collect() + } + + /// Encode a single extension's `(extra, additional_signed)`, mirroring the + /// signing-bot switch; unknown personhood extensions fall back to the + /// metadata type default (`Option` -> None, void -> empty). + fn encode_one(&self, ext: &ExtensionDef, state: &ChainState) -> (Vec, Vec) { + match ext.identifier.as_str() { + "CheckNonce" => (Compact(state.nonce).encode(), Vec::new()), + "CheckSpecVersion" => (Vec::new(), state.spec_version.to_le_bytes().to_vec()), + "CheckTxVersion" => (Vec::new(), state.transaction_version.to_le_bytes().to_vec()), + "CheckGenesis" => (Vec::new(), state.genesis_hash.to_vec()), + // extra = Era::Immortal (0x00); implicit = genesis hash. + "CheckMortality" => (vec![0x00], state.genesis_hash.to_vec()), + // extra = first variant `Disabled` (void) = 0x00. + "VerifyMultiSignature" => (vec![0x00], Vec::new()), + // extra = { tip: compact(0), asset_id: None } = 0x00 0x00. + "ChargeAssetTxPayment" => (vec![0x00, 0x00], Vec::new()), + // extra = bool false = 0x00. + "RestrictOrigins" => (vec![0x00], Vec::new()), + _ => ( + self.encode_default(ext.extra_type), + self.encode_default(ext.additional_signed_type), + ), + } + } + + /// Encode the "disabled" default value for a metadata type: `Option` -> None + /// (`0x00`), void/empty tuple -> empty, enums -> first variant, primitives + /// -> zero. Matches signing-bot `defaultValueForType`. + fn encode_default(&self, type_id: u32) -> Vec { + let Some(ty) = self.registry.resolve(type_id) else { + return Vec::new(); + }; + match &ty.type_def { + TypeDef::Composite(c) => c + .fields + .iter() + .flat_map(|f| self.encode_default(f.ty.id)) + .collect(), + TypeDef::Tuple(t) => t + .fields + .iter() + .flat_map(|f| self.encode_default(f.id)) + .collect(), + TypeDef::Variant(v) => { + // Option encodes None as 0x00. + if ty.path.segments.last().map(String::as_str) == Some("Option") { + return vec![0x00]; + } + match v.variants.iter().min_by_key(|var| var.index) { + None => Vec::new(), + Some(first) => { + let mut out = vec![first.index]; + for field in &first.fields { + out.extend(self.encode_default(field.ty.id)); + } + out + } + } + } + TypeDef::Array(a) => { + let elem = self.encode_default(a.type_param.id); + elem.repeat(a.len as usize) + } + // Sequences / strings / bit-sequences encode an empty run as compact(0). + TypeDef::Sequence(_) | TypeDef::BitSequence(_) => vec![0x00], + TypeDef::Compact(_) => vec![0x00], + TypeDef::Primitive(p) => match p { + TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => vec![0], + TypeDefPrimitive::Char | TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => { + vec![0; 4] + } + TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => vec![0; 2], + TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => vec![0; 8], + TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => vec![0; 16], + TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => vec![0; 32], + // Length-prefixed string: empty = compact(0). + TypeDefPrimitive::Str => vec![0x00], + }, + } + } + + /// Index of `AsResources` in the extension list, if present. + pub fn as_resources_index(&self) -> Option { + self.extensions + .iter() + .position(|e| e.identifier == AS_RESOURCES) + } +} + +/// Build the ring-VRF proof message for an `AsResources`-authorized call: +/// `blake2b256(0x00 ‖ call ‖ Σ tail.extra ‖ Σ tail.additional_signed)`, where +/// the tail is the extensions ordered strictly after `AsResources`. The leading +/// `0x00` is the General-transaction extension-version byte. +pub fn build_proof_message( + metadata: &Metadata, + call_data: &[u8], + state: &ChainState, +) -> Result<[u8; 32], String> { + let all = metadata.encode_signed_extensions(state); + let tail_start = metadata + .as_resources_index() + .map(|i| i + 1) + .ok_or_else(|| format!("{AS_RESOURCES} extension not found in metadata"))?; + let tail = &all[tail_start..]; + + let mut payload = Vec::with_capacity(1 + call_data.len()); + payload.push(0x00); + payload.extend_from_slice(call_data); + for ext in tail { + payload.extend_from_slice(&ext.extra); + } + for ext in tail { + payload.extend_from_slice(&ext.additional_signed); + } + Ok(blake2b256(&payload)) +} + +/// BLAKE2b-256 of `message`. +pub fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b(32, &[], message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fixture metadata captured from paseo-next-v2 (raw `RuntimeMetadataPrefixed`). + const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + + /// The known-answer chain state frozen alongside the fixture. + fn fixture_state() -> ChainState { + ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + } + } + + /// `Resources.set_statement_store_account(period=7, seq=0, target=0)`. + fn fixture_call() -> Vec { + let mut call = vec![0x3f, 0x0a]; + call.extend_from_slice(&7u32.to_le_bytes()); + call.extend_from_slice(&0u32.to_le_bytes()); + call.extend_from_slice(&[0u8; 32]); + call + } + + #[test] + fn proof_message_matches_frozen_known_answer() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let msg = build_proof_message(&metadata, &fixture_call(), &fixture_state()).unwrap(); + assert_eq!( + hex::encode(msg), + "1d2e6d8d8f421b0857097c6076115507432d66fea47ebe0c3be282a369f6743c", + ); + } + + #[test] + fn as_resources_tail_is_indices_10_through_20() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let idx = metadata.as_resources_index().unwrap(); + // AsResources sits at index 9; the proof tail is everything after it. + assert_eq!(idx, 9); + let ids = metadata.extension_ids(); + assert_eq!( + ids[idx + 1..].to_vec(), + vec![ + "AuthorizeCall", + "RestrictOrigins", + "CheckNonZeroSender", + "CheckSpecVersion", + "CheckTxVersion", + "CheckGenesis", + "CheckMortality", + "CheckNonce", + "CheckWeight", + "ChargeAssetTxPayment", + "StorageWeightReclaim", + ], + ); + } + + #[test] + fn dropping_the_version_byte_changes_the_hash() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let state = fixture_state(); + let call = fixture_call(); + let all = metadata.encode_signed_extensions(&state); + let tail = &all[metadata.as_resources_index().unwrap() + 1..]; + let mut without = call.clone(); + for e in tail { + without.extend_from_slice(&e.extra); + } + for e in tail { + without.extend_from_slice(&e.additional_signed); + } + assert_ne!( + build_proof_message(&metadata, &call, &state).unwrap(), + blake2b256(&without), + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs new file mode 100644 index 00000000..a427e88a --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs @@ -0,0 +1,204 @@ +//! `Resources.set_statement_store_account` call + unsigned General (v5) +//! extrinsic assembly. Mirrors signing-bot `allocation.ts` / `extrinsic-submit.ts`. + +use parity_scale_codec::{Compact, Encode}; + +use super::extension::{ChainState, Metadata}; + +/// Pallet + call index for `Resources.set_statement_store_account` (63 / 10). +pub const SET_STATEMENT_STORE_ACCOUNT_CALL: [u8; 2] = [0x3f, 0x0a]; +/// Pallet + call index for `Resources.claim_long_term_storage` (63 / 12). +pub const CLAIM_LONG_TERM_STORAGE_CALL: [u8; 2] = [0x3f, 0x0c]; +/// `AsResourcesInfo::RegisterStatementStoreAllowance` variant index. +const REGISTER_STATEMENT_STORE_ALLOWANCE: u8 = 0x02; +/// `AsResourcesInfo::ClaimLongTermStorage` variant index. +const CLAIM_LONG_TERM_STORAGE: u8 = 0x03; +/// `MembershipCollection::LitePeople` variant index. +const MEMBERSHIP_COLLECTION_LITE_PEOPLE: u8 = 0x01; +/// General-transaction preamble byte: `0b01` (General) | version 5. +const GENERAL_V5_PREAMBLE: u8 = 0x45; +/// Current signed-extension version byte. +const EXTENSION_VERSION: u8 = 0x00; +/// `Option::Some` discriminant for the `AsResources` extension `extra`. +const OPTION_SOME: u8 = 0x01; + +/// Encode `Resources.set_statement_store_account(period, seq, target)`: +/// `3f 0a ‖ period_u32LE ‖ seq_u32LE ‖ target[32]`. +pub fn build_set_statement_store_account_call(period: u32, seq: u32, target: &[u8; 32]) -> Vec { + let mut call = Vec::with_capacity(2 + 4 + 4 + 32); + call.extend_from_slice(&SET_STATEMENT_STORE_ACCOUNT_CALL); + call.extend_from_slice(&period.to_le_bytes()); + call.extend_from_slice(&seq.to_le_bytes()); + call.extend_from_slice(target); + call +} + +/// Encode `Resources.claim_long_term_storage(period, counter, account_id)`: +/// `3f 0c ‖ period_u32LE ‖ counter_u8 ‖ account_id[32]`. +pub fn build_claim_long_term_storage_call( + period: u32, + counter: u8, + account_id: &[u8; 32], +) -> Vec { + let mut call = Vec::with_capacity(2 + 4 + 1 + 32); + call.extend_from_slice(&CLAIM_LONG_TERM_STORAGE_CALL); + call.extend_from_slice(&period.to_le_bytes()); + call.push(counter); + call.extend_from_slice(account_id); + call +} + +/// Encode the `AsResources` extension `extra` for a statement-store allowance: +/// `Some(RegisterStatementStoreAllowance { proof, ring_index, LitePeople })`. +pub fn build_as_resources_extra(proof: &[u8], ring_index: u32) -> Vec { + let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 1); + extra.push(OPTION_SOME); + extra.push(REGISTER_STATEMENT_STORE_ALLOWANCE); + extra.extend_from_slice(&Compact(proof.len() as u32).encode()); + extra.extend_from_slice(proof); + extra.extend_from_slice(&ring_index.to_le_bytes()); + extra.push(MEMBERSHIP_COLLECTION_LITE_PEOPLE); + extra +} + +/// Encode the `AsResources` extension `extra` for a long-term storage claim: +/// `Some(ClaimLongTermStorage { proof, ring_index, revision, LitePeople })`. +pub fn build_long_term_storage_extra(proof: &[u8], ring_index: u32, revision: u32) -> Vec { + let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 4 + 1); + extra.push(OPTION_SOME); + extra.push(CLAIM_LONG_TERM_STORAGE); + extra.extend_from_slice(&Compact(proof.len() as u32).encode()); + extra.extend_from_slice(proof); + extra.extend_from_slice(&ring_index.to_le_bytes()); + extra.extend_from_slice(&revision.to_le_bytes()); + extra.push(MEMBERSHIP_COLLECTION_LITE_PEOPLE); + extra +} + +/// Assemble the unsigned General (v5) extrinsic: +/// `compact(len) ‖ 0x45 ‖ 0x00 ‖ Σ(all extra, AsResources = Some(info)) ‖ call`. +pub fn build_unsigned_extrinsic( + metadata: &Metadata, + state: &ChainState, + call_data: &[u8], + as_resources_extra: &[u8], +) -> Result, String> { + let all = metadata.encode_signed_extensions(state); + let as_resources_index = metadata + .as_resources_index() + .ok_or_else(|| "AsResources extension not found in metadata".to_string())?; + + let mut body = vec![GENERAL_V5_PREAMBLE, EXTENSION_VERSION]; + for (i, ext) in all.iter().enumerate() { + if i == as_resources_index { + body.extend_from_slice(as_resources_extra); + } else { + body.extend_from_slice(&ext.extra); + } + } + body.extend_from_slice(call_data); + + let mut extrinsic = Compact(body.len() as u32).encode(); + extrinsic.extend_from_slice(&body); + Ok(extrinsic) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + + fn fixture_state() -> ChainState { + ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + } + } + + #[test] + fn call_layout_is_pallet_call_period_seq_target() { + let call = build_set_statement_store_account_call(7, 0, &[0u8; 32]); + assert_eq!( + call, + [ + vec![0x3f, 0x0a], + 7u32.to_le_bytes().to_vec(), + 0u32.to_le_bytes().to_vec(), + vec![0u8; 32], + ] + .concat() + ); + } + + #[test] + fn long_term_storage_call_layout_is_pallet_call_period_counter_account() { + let call = build_claim_long_term_storage_call(7, 3, &[0u8; 32]); + assert_eq!( + call, + [ + vec![0x3f, 0x0c], + 7u32.to_le_bytes().to_vec(), + vec![3], + vec![0u8; 32], + ] + .concat() + ); + } + + #[test] + fn as_resources_extra_wraps_proof_as_bytes() { + let proof = vec![0xEE; 785]; + let extra = build_as_resources_extra(&proof, 3); + // Some(0x01) ‖ variant(0x02) ‖ compact(785)=0x45,0x0c ‖ 785 bytes ‖ ringIndex LE ‖ LitePeople. + assert_eq!(&extra[..2], &[0x01, 0x02]); + assert_eq!(&extra[2..4], &Compact(785u32).encode()[..]); + assert_eq!(&extra[4..4 + 785], &proof[..]); + assert_eq!(&extra[4 + 785..4 + 785 + 4], &3u32.to_le_bytes()); + assert_eq!(extra[4 + 785 + 4], MEMBERSHIP_COLLECTION_LITE_PEOPLE); + } + + #[test] + fn long_term_storage_extra_wraps_revision() { + let proof = vec![0xEE; 785]; + let extra = build_long_term_storage_extra(&proof, 3, 9); + // Some(0x01) ‖ variant(0x03) ‖ compact(785)=0x45,0x0c ‖ proof + // ‖ ringIndex LE ‖ revision LE ‖ LitePeople. + assert_eq!(&extra[..2], &[0x01, 0x03]); + assert_eq!(&extra[2..4], &Compact(785u32).encode()[..]); + assert_eq!(&extra[4..4 + 785], &proof[..]); + assert_eq!(&extra[4 + 785..4 + 785 + 4], &3u32.to_le_bytes()); + assert_eq!(&extra[4 + 785 + 4..4 + 785 + 8], &9u32.to_le_bytes()); + assert_eq!(extra[4 + 785 + 8], MEMBERSHIP_COLLECTION_LITE_PEOPLE); + } + + #[test] + fn extrinsic_has_general_v5_preamble_and_embeds_call() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let call = build_set_statement_store_account_call(7, 0, &[0u8; 32]); + let extra = build_as_resources_extra(&vec![0xEE; 785], 0); + let xt = build_unsigned_extrinsic(&metadata, &fixture_state(), &call, &extra).unwrap(); + + // Strip the compact length prefix and check the body head + tail. + let body = &xt[compact_prefix_len(&xt)..]; + assert_eq!(&body[..2], &[GENERAL_V5_PREAMBLE, EXTENSION_VERSION]); + assert_eq!(&body[body.len() - call.len()..], &call[..]); + // The Some(info) extra appears verbatim in the body. + assert!( + body.windows(extra.len()).any(|w| w == extra), + "AsResources Some(info) extra should appear in the body", + ); + } + + /// Length of the SCALE compact prefix at the head of `xt`. + fn compact_prefix_len(xt: &[u8]) -> usize { + match xt[0] & 0b11 { + 0b00 => 1, + 0b01 => 2, + 0b10 => 4, + _ => 5, + } + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs new file mode 100644 index 00000000..b9dc567f --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs @@ -0,0 +1,111 @@ +//! Bandersnatch ring-VRF one-shot membership proof (prover side). +//! +//! Wraps `verifiable`'s prover-gated `open` + `create` into the single-shot +//! proof a `RegisterStatementStoreAllowance` needs: prove that our member key is +//! in the LitePeople ring, bound to a slot `context` and the extrinsic proof +//! `message`. Mirrors signing-bot `ring-proof.ts` `oneShotProof`. + +use verifiable::GenerateVerifiable; +use verifiable::ring::RingDomainSize; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +/// A single-context ring-VRF signature is exactly 785 bytes. +pub const RING_VRF_PROOF_LEN: usize = 785; + +/// Map an on-chain `RingExponent` (9 / 10 / 14) to the FFT domain size +/// (power = exponent + 2). +pub fn domain_for_ring_exponent(exponent: u8) -> Result { + match exponent { + 9 => Ok(RingDomainSize::Domain11), + 10 => Ok(RingDomainSize::Domain12), + 14 => Ok(RingDomainSize::Domain16), + other => Err(format!("unsupported ring exponent {other}")), + } +} + +/// The ring member key for a bandersnatch entropy (`blake2b256(bip39_entropy)`). +pub fn member_key(entropy: [u8; 32]) -> [u8; 32] { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + BandersnatchVrfVerifiable::member_from_secret(&secret) +} + +/// Produce the 785-byte ring-VRF membership proof over `members` (already +/// sliced to the ring's included prefix), bound to `context` and `message`. +/// +/// `entropy` is the bandersnatch entropy; its member key must be present in +/// `members` or `open` fails with `NotInRing`. +pub fn ring_vrf_proof( + domain: RingDomainSize, + entropy: [u8; 32], + members: &[[u8; 32]], + context: &[u8], + message: &[u8], +) -> Result, String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let member = BandersnatchVrfVerifiable::member_from_secret(&secret); + let commitment = BandersnatchVrfVerifiable::open(domain, &member, members.iter().copied()) + .map_err(|err| format!("ring-VRF open failed: {err:?}"))?; + let (proof, _alias) = BandersnatchVrfVerifiable::create(commitment, &secret, context, message) + .map_err(|err| format!("ring-VRF create failed: {err:?}"))?; + let bytes = proof.into_inner(); + if bytes.len() != RING_VRF_PROOF_LEN { + return Err(format!( + "ring-VRF proof is {} bytes, expected {RING_VRF_PROOF_LEN}", + bytes.len() + )); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exponent_maps_to_domain() { + assert_eq!( + domain_for_ring_exponent(9).unwrap(), + RingDomainSize::Domain11 + ); + assert_eq!( + domain_for_ring_exponent(10).unwrap(), + RingDomainSize::Domain12 + ); + assert_eq!( + domain_for_ring_exponent(14).unwrap(), + RingDomainSize::Domain16 + ); + assert!(domain_for_ring_exponent(11).is_err()); + } + + #[test] + fn proof_is_785_bytes_for_a_single_member_ring() { + let entropy = [0x11u8; 32]; + let member = member_key(entropy); + let members = vec![member]; + let proof = ring_vrf_proof( + RingDomainSize::Domain11, + entropy, + &members, + b"SSS_SLOT:test-context-padding..", + &[0x42; 32], + ) + .unwrap(); + assert_eq!(proof.len(), RING_VRF_PROOF_LEN); + } + + #[test] + fn open_fails_when_member_absent_from_ring() { + let entropy = [0x11u8; 32]; + let other = member_key([0x22u8; 32]); + let err = ring_vrf_proof( + RingDomainSize::Domain11, + entropy, + &[other], + b"SSS_SLOT:test-context-padding..", + &[0x42; 32], + ) + .unwrap_err(); + assert!(err.contains("open failed"), "unexpected error: {err}"); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs new file mode 100644 index 00000000..a461dcac --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs @@ -0,0 +1,197 @@ +//! LitePeople ring parameters from the People chain (`Members` pallet). +//! +//! Reads the on-chain ring so the membership proof is built against the same +//! members the runtime verifies against: the baked-in `included` prefix of the +//! current ring. Mirrors signing-bot `ring-proof.ts`. + +use parity_scale_codec::{Compact, Decode}; +use sp_crypto_hashing::{blake2_128, twox_64, twox_128}; + +use super::dynamic::{read_field_u32, read_field_variant_name}; +use super::extension::Metadata; +use super::rpc::RpcClient; + +/// LitePeople collection identifier: ASCII, exactly 32 bytes. +const LITE_PEOPLE_IDENTIFIER: &[u8; 32] = b"pop:polkadot.network/people-lite"; +/// Ring member public key length. +const MEMBER_LEN: usize = 32; + +/// On-chain LitePeople ring parameters for building a verifying proof. +pub struct RingParams { + /// Ring members, sliced to the baked-in `included` prefix. + pub members: Vec<[u8; 32]>, + /// Ring size exponent (9 / 10 / 14). + pub exponent: u8, + /// Ring index these members belong to. + pub ring_index: u32, +} + +/// `Members.CurrentRingIndex[id]` storage key. +fn current_ring_index_key() -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"CurrentRingIndex").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + ] + .concat() +} + +/// `Members.Collections[id]` storage key. +fn collections_key() -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"Collections").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + ] + .concat() +} + +/// `Members.RingKeysStatus[(id, ring_index)]` storage key. +fn ring_keys_status_key(ring_index: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeysStatus").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + ] + .concat() +} + +/// `Members.Root[(id, ring_index)]` storage key. +fn ring_root_key(ring_index: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"Root").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + ] + .concat() +} + +/// `Members.RingKeys[(id, ring_index, page)]` storage key. +fn ring_keys_key(ring_index: u32, page: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeys").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + &twox_64_concat(&page.to_le_bytes()), + ] + .concat() +} + +/// `Blake2_128Concat(x)` = `blake2_128(x) ‖ x`. +pub(super) fn blake2_128_concat(x: &[u8]) -> Vec { + [blake2_128(x).as_slice(), x].concat() +} + +/// `Twox64Concat(x)` = `twox_64(x) ‖ x`. +fn twox_64_concat(x: &[u8]) -> Vec { + [twox_64(x).as_slice(), x].concat() +} + +/// Map a `RingExponent` variant name to its exponent. +fn ring_exponent_from_name(name: &str) -> Result { + match name { + "R2e9" => Ok(9), + "R2e10" => Ok(10), + "R2e14" => Ok(14), + other => Err(format!("unsupported RingExponent variant `{other}`")), + } +} + +/// Read the current LitePeople ring index (absent => 0). +pub async fn read_current_ring_index(rpc: &RpcClient) -> Result { + match rpc + .get_storage(¤t_ring_index_key()) + .await + .map_err(|e| e.to_string())? + { + Some(bytes) => u32::decode(&mut &bytes[..]).map_err(|e| format!("ring index: {e}")), + None => Ok(0), + } +} + +/// Read the LitePeople ring size exponent from `Collections[LitePeople].ring_size`. +/// This is a chain constant, so read it once and reuse across ring indices. +pub async fn read_ring_exponent(rpc: &RpcClient, metadata: &Metadata) -> Result { + let collection = rpc + .get_storage(&collections_key()) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Members.Collections[LitePeople] missing".to_string())?; + let value_type = metadata + .storage_value_type("Members", "Collections") + .ok_or_else(|| "Members.Collections type not in metadata".to_string())?; + let variant = + read_field_variant_name(metadata.registry(), value_type, "ring_size", &collection)?; + ring_exponent_from_name(&variant) +} + +/// Read the members of `ring_index`, sliced to the baked-in `included` prefix. +pub async fn read_ring_members_at( + rpc: &RpcClient, + ring_index: u32, +) -> Result, String> { + // 1. Page through RingKeys collecting raw 32-byte members. + let mut members = Vec::new(); + for page in 0.. { + let Some(bytes) = rpc + .get_storage(&ring_keys_key(ring_index, page)) + .await + .map_err(|e| e.to_string())? + else { + break; + }; + let mut cursor = &bytes[..]; + let Compact(len) = + Compact::::decode(&mut cursor).map_err(|e| format!("ring keys len: {e}"))?; + if len == 0 { + break; + } + for i in 0..len as usize { + let start = i * MEMBER_LEN; + let member: [u8; 32] = cursor + .get(start..start + MEMBER_LEN) + .ok_or_else(|| "ring keys page truncated".to_string())? + .try_into() + .expect("slice is 32 bytes"); + members.push(member); + } + } + + // 2. Slice to the baked-in `included` prefix (absent status => all included). + if let Some(status) = rpc + .get_storage(&ring_keys_status_key(ring_index)) + .await + .map_err(|e| e.to_string())? + { + // RingStatus = { total: u32 LE, included: u32 LE, .. }. + let included = u32::decode(&mut &status[4..]).map_err(|e| format!("ring status: {e}"))?; + members.truncate(included as usize); + } + + Ok(members) +} + +/// Read `Members.Root[LitePeople][ring_index].revision` (absent => 0). +pub async fn read_ring_revision( + rpc: &RpcClient, + metadata: &Metadata, + ring_index: u32, +) -> Result { + match rpc + .get_storage(&ring_root_key(ring_index)) + .await + .map_err(|e| e.to_string())? + { + Some(bytes) => { + let value_type = metadata + .storage_value_type("Members", "Root") + .ok_or_else(|| "Members.Root type not in metadata".to_string())?; + read_field_u32(metadata.registry(), value_type, "revision", &bytes) + .map_err(|e| format!("ring revision: {e}")) + } + None => Ok(0), + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs new file mode 100644 index 00000000..870a5db1 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/rpc.rs @@ -0,0 +1,121 @@ +//! Host-backed JSON-RPC helpers for statement-store allowance registration. + +use core::time::Duration; + +use futures::{FutureExt, pin_mut}; +use serde_json::Value; +use subxt_rpcs::RpcClient as HostRpcClient; +use subxt_rpcs::client::{RpcParams, rpc_params}; + +/// Timeout for an allowance registration extrinsic to finalize. +const SUBMIT_TIMEOUT: Duration = Duration::from_secs(120); + +/// Thin adapter matching the allowance allocator's minimal RPC surface. +#[derive(Clone)] +pub struct RpcClient { + inner: HostRpcClient, +} + +impl RpcClient { + /// Wrap a platform-backed Subxt RPC client. + pub fn new(inner: HostRpcClient) -> Self { + Self { inner } + } + + /// Call `method` with JSON-array `params`, returning the result value. + pub async fn call(&self, method: &str, params: Value) -> Result { + self.inner + .request(method, value_to_params(params)?) + .await + .map_err(rpc_error_message) + } + + /// `state_getStorage(key)` -> raw value bytes, or `None` if absent. + pub async fn get_storage(&self, key: &[u8]) -> Result>, String> { + let key_hex = format!("0x{}", hex::encode(key)); + match self + .inner + .request::("state_getStorage", rpc_params![key_hex]) + .await + .map_err(rpc_error_message)? + { + Value::String(hex_value) => Ok(Some(decode_hex(&hex_value)?)), + _ => Ok(None), + } + } + + /// Submit an extrinsic and wait for `finalized`; returns the block hash. + pub async fn submit_and_watch(&self, extrinsic: &[u8]) -> Result { + let extrinsic_hex = format!("0x{}", hex::encode(extrinsic)); + let mut subscription = self + .inner + .subscribe::( + "author_submitAndWatchExtrinsic", + rpc_params![extrinsic_hex], + "author_unwatchExtrinsic", + ) + .await + .map_err(rpc_error_message)?; + let timeout = futures_timer::Delay::new(SUBMIT_TIMEOUT).fuse(); + pin_mut!(timeout); + + loop { + let next = subscription.next().fuse(); + pin_mut!(next); + let status = futures::select! { + item = next => item.ok_or_else(|| { + "author_submitAndWatchExtrinsic subscription ended".to_string() + })?.map_err(rpc_error_message)?, + () = timeout => return Err( + "timed out waiting for author_submitAndWatchExtrinsic finalization".to_string() + ), + }; + match extrinsic_status(&status) { + ExtrinsicStatus::Finalized(hash) => return Ok(hash), + ExtrinsicStatus::Rejected(reason) => return Err(format!("extrinsic {reason}")), + ExtrinsicStatus::Pending => {} + } + } + } +} + +enum ExtrinsicStatus { + Finalized(String), + Rejected(String), + Pending, +} + +fn extrinsic_status(status: &Value) -> ExtrinsicStatus { + if let Some(hash) = status.get("finalized").and_then(Value::as_str) { + return ExtrinsicStatus::Finalized(hash.to_string()); + } + for key in ["invalid", "dropped", "usurped", "finalityTimeout"] { + if status.get(key).is_some() { + return ExtrinsicStatus::Rejected(key.to_string()); + } + } + ExtrinsicStatus::Pending +} + +fn value_to_params(value: Value) -> Result { + let Value::Array(values) = value else { + return Err("RPC params must be a JSON array".to_string()); + }; + let mut params = RpcParams::new(); + for value in values { + params.push(value).map_err(rpc_error_message)?; + } + Ok(params) +} + +fn decode_hex(value: &str) -> Result, String> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)) + .map_err(|err| format!("decode hex storage value: {err}")) +} + +fn rpc_error_message(error: subxt_rpcs::Error) -> String { + match error { + subxt_rpcs::Error::User(error) => error.message, + other => other.to_string(), + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs new file mode 100644 index 00000000..a9a99b81 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -0,0 +1,247 @@ +//! StatementStore allowance slot selection. +//! +//! An allowance is claimed at `(period, seq)`. The slot is bound to a 32-byte +//! `SSS_SLOT` context; occupancy is read from +//! `Resources.StatementStoreAllowances[period][alias]`, where the alias is +//! derived from OUR bandersnatch entropy in that slot context. Mirrors +//! signing-bot `allowance.ts` / `allowance-slots.ts`. + +use sp_crypto_hashing::twox_128; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use super::extension::Metadata; +use super::ring::blake2_128_concat; +use super::rpc::RpcClient; + +/// StatementStore allowance period: one UTC day, in seconds. +pub const STATEMENT_STORE_PERIOD_SECONDS: u64 = 86_400; +/// Bulletin long-term-storage claim context prefix. +const LONG_TERM_STORAGE_CONTEXT_PREFIX: &[u8] = b"pop:polkadot.net/rsc-lts"; + +/// The current allowance period for `now_seconds`. +pub fn current_period(now_seconds: u64) -> u32 { + (now_seconds / STATEMENT_STORE_PERIOD_SECONDS) as u32 +} + +/// The current long-term-storage period for `now_seconds`. +pub fn current_long_term_storage_period( + now_seconds: u64, + period_duration: u32, +) -> Result { + if period_duration == 0 { + return Err("Resources.LongTermStoragePeriodDuration is zero".to_string()); + } + Ok((now_seconds / u64::from(period_duration)) as u32) +} + +/// Derive the 32-byte StatementStore slot context: +/// `"SSS_SLOT:" ‖ u32be(period) ‖ u32be(seq) ‖ 0x20 fill`. +pub fn derive_slot_context(period: u32, seq: u32) -> [u8; 32] { + let mut ctx = [0x20u8; 32]; + ctx[..9].copy_from_slice(b"SSS_SLOT:"); + ctx[9..13].copy_from_slice(&period.to_be_bytes()); + ctx[13..17].copy_from_slice(&seq.to_be_bytes()); + ctx +} + +/// Derive the 32-byte Bulletin long-term-storage slot context: +/// `"pop:polkadot.net/rsc-lts" ‖ u32be(period) ‖ counter ‖ zero fill`. +pub fn derive_long_term_storage_context(period: u32, counter: u8) -> [u8; 32] { + let mut ctx = [0u8; 32]; + ctx[..LONG_TERM_STORAGE_CONTEXT_PREFIX.len()].copy_from_slice(LONG_TERM_STORAGE_CONTEXT_PREFIX); + let offset = LONG_TERM_STORAGE_CONTEXT_PREFIX.len(); + ctx[offset..offset + 4].copy_from_slice(&period.to_be_bytes()); + ctx[offset + 4] = counter; + ctx +} + +/// The slot alias for our `entropy` at `(period, seq)`. +pub fn slot_alias(entropy: [u8; 32], period: u32, seq: u32) -> Result<[u8; 32], String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let context = derive_slot_context(period, seq); + BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("alias_in_context failed: {err:?}")) +} + +/// The long-term-storage slot alias for our `entropy` at `(period, counter)`. +pub fn long_term_storage_alias( + entropy: [u8; 32], + period: u32, + counter: u8, +) -> Result<[u8; 32], String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let context = derive_long_term_storage_context(period, counter); + BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("alias_in_context failed: {err:?}")) +} + +/// `Resources.StatementStoreAllowances[period][alias]` storage key. +/// key1 = Identity(u32be period); key2 = Blake2_128Concat(alias). +fn statement_store_allowance_key(period: u32, alias: &[u8; 32]) -> Vec { + [ + twox_128(b"Resources").as_slice(), + twox_128(b"StatementStoreAllowances").as_slice(), + &period.to_be_bytes(), + &blake2_128_concat(alias), + ] + .concat() +} + +/// `Resources.SpentLongTermStorageAliases[period][alias]` storage key. +/// key1 = Identity(u32be period); key2 = Blake2_128Concat(alias). +fn spent_long_term_storage_alias_key(period: u32, alias: &[u8; 32]) -> Vec { + [ + twox_128(b"Resources").as_slice(), + twox_128(b"SpentLongTermStorageAliases").as_slice(), + &period.to_be_bytes(), + &blake2_128_concat(alias), + ] + .concat() +} + +/// Max StatementStore slots per period from `Resources.LiteStmtStoreSlotsPerPeriod`. +fn max_slots(metadata: &Metadata) -> Result { + let bytes = metadata + .constant("Resources", "LiteStmtStoreSlotsPerPeriod") + .ok_or_else(|| "Resources.LiteStmtStoreSlotsPerPeriod constant missing".to_string())?; + let mut buf = [0u8; 4]; + let n = bytes.len().min(4); + buf[..n].copy_from_slice(&bytes[..n]); + Ok(u32::from_le_bytes(buf)) +} + +/// Max long-term-storage claims per period from +/// `Resources.LongTermStorageClaimsPerPeriod`. +fn long_term_storage_claims_per_period(metadata: &Metadata) -> Result { + metadata + .constant("Resources", "LongTermStorageClaimsPerPeriod") + .and_then(|bytes| bytes.first().copied()) + .ok_or_else(|| "Resources.LongTermStorageClaimsPerPeriod constant missing".to_string()) +} + +/// Long-term-storage period duration in seconds from +/// `Resources.LongTermStoragePeriodDuration`. +pub fn long_term_storage_period_duration(metadata: &Metadata) -> Result { + let bytes = metadata + .constant("Resources", "LongTermStoragePeriodDuration") + .ok_or_else(|| "Resources.LongTermStoragePeriodDuration constant missing".to_string())?; + let mut buf = [0u8; 4]; + let n = bytes.len().min(4); + buf[..n].copy_from_slice(&bytes[..n]); + Ok(u32::from_le_bytes(buf)) +} + +/// The account id occupying a slot entry, if the storage value is present. +/// Entry = `account_id(32) ‖ seq(u32 LE) ‖ since(u64 LE)`. +fn entry_account_id(bytes: &[u8]) -> Option<[u8; 32]> { + bytes.get(..32).map(|s| s.try_into().expect("32 bytes")) +} + +/// Outcome of scanning for a slot to register `target` in. +pub enum SlotSelection { + /// A free `seq` we should claim. + Free(u32), + /// `target` already holds `seq` this period; no registration needed. + AlreadyAllocated(u32), +} + +/// Scan slots `0..max` for `period`, returning the first non-excluded free seq +/// (or detecting that `target` already holds one). `entropy` is our +/// bandersnatch entropy. +pub async fn scan_slot_excluding( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + period: u32, + target: &[u8; 32], + excluded: &[u32], +) -> Result { + let max = max_slots(metadata)?; + let mut first_free: Option = None; + for seq in 0..max { + let alias = slot_alias(entropy, period, seq)?; + let key = statement_store_allowance_key(period, &alias); + match rpc.get_storage(&key).await.map_err(|e| e.to_string())? { + None => { + if first_free.is_none() && !excluded.contains(&seq) { + first_free = Some(seq); + } + } + Some(bytes) => { + if entry_account_id(&bytes) == Some(*target) { + return Ok(SlotSelection::AlreadyAllocated(seq)); + } + } + } + } + first_free + .map(SlotSelection::Free) + .ok_or_else(|| format!("no free StatementStore slot in period {period} (max {max})")) +} + +/// Scan long-term-storage aliases `0..max` for `period`, returning the first +/// free counter not listed in `excluded`. `entropy` is our bandersnatch entropy. +pub async fn scan_long_term_storage_counter_excluding( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + period: u32, + excluded: &[u8], +) -> Result { + let max = long_term_storage_claims_per_period(metadata)?; + for counter in 0..max { + if excluded.contains(&counter) { + continue; + } + let alias = long_term_storage_alias(entropy, period, counter)?; + let key = spent_long_term_storage_alias_key(period, &alias); + if rpc + .get_storage(&key) + .await + .map_err(|e| e.to_string())? + .is_none() + { + return Ok(counter); + } + } + Err(format!( + "no free long-term-storage slot in period {period} (max {max})" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slot_context_layout() { + let ctx = derive_slot_context(7, 3); + assert_eq!(&ctx[..9], b"SSS_SLOT:"); + assert_eq!(&ctx[9..13], &7u32.to_be_bytes()); + assert_eq!(&ctx[13..17], &3u32.to_be_bytes()); + assert!(ctx[17..].iter().all(|&b| b == 0x20)); + } + + #[test] + fn long_term_storage_context_layout() { + let ctx = derive_long_term_storage_context(7, 3); + assert_eq!(&ctx[..24], b"pop:polkadot.net/rsc-lts"); + assert_eq!(&ctx[24..28], &7u32.to_be_bytes()); + assert_eq!(ctx[28], 3); + assert!(ctx[29..].iter().all(|&b| b == 0)); + } + + #[test] + fn period_is_utc_day_index() { + assert_eq!(current_period(86_400 * 20_000 + 5), 20_000); + } + + #[test] + fn long_term_storage_period_uses_chain_duration() { + assert_eq!( + current_long_term_storage_period(1_209_600 * 20 + 5, 1_209_600).unwrap(), + 20, + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index b55d34d4..34297aaf 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -384,7 +384,7 @@ mod tests { use super::super::{LocalActivation, RuntimeServices, SigningHostRole}; use super::*; use crate::host_logic::product_account::{ - SR25519_SIGNING_CONTEXT, derive_product_keypair, derive_root_keypair_from_entropy, + SR25519_SIGNING_CONTEXT, derive_product_keypair, derive_sr25519_hard_path, }; use crate::test_support::{ StubPlatform, account_id, new_statements_frame, runtime_config, signed_statement, @@ -420,7 +420,7 @@ mod tests { fn signing_host_runtime(product_id: &str) -> (ProductRuntimeHost, Arc) { let platform: Arc = Arc::new(StubPlatform::default()); let services = RuntimeServices::new(platform.clone(), [0; 32], [0xbb; 32], test_spawner()); - let signing_host = SigningHostRole::new(platform); + let signing_host = SigningHostRole::new(platform, services.clone()); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); let host = ProductRuntimeHost::from_services( @@ -432,27 +432,70 @@ mod tests { } #[test] - fn statement_store_create_proof_pairing_host_does_not_use_session_key() { - let host = - ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); - host.test_session_state().set_session(sso_session_info()); + fn statement_store_create_proof_pairing_host_routes_exact_signing_over_sso() { + let mut session = sso_session_info(); + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + session.public_key = wallet.public.to_bytes(); + let statement = statement(); + let payload = statement_payload(statement.clone()); + let product_keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); + let expected_signer = product_keypair.public.to_bytes(); + let signature = product_keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &payload, &product_keypair.public) + .to_bytes(); + let platform = Arc::new(StubPlatform { + sso_response_script: Some(sso_success_response_script( + &session, + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-proof-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::StatementStoreProductSignResponse( + crate::host_logic::sso::messages::StatementStoreProductSignResponse { + responding_to: "proof-1".to_string(), + signature: Ok(signature.to_vec()), + }, + ), + ), + }, + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); let cx = CallContext::new(); let request = RemoteStatementStoreCreateProofRequest::V1( v01::RemoteStatementStoreCreateProofRequest { product_account_id: account_id("myapp.dot", 0), - statement: statement(), + statement, }, ); - let err = futures::executor::block_on(StatementStore::create_proof(&host, &cx, request)) - .unwrap_err(); + let response = + futures::executor::block_on(StatementStore::create_proof(&host, &cx, request)).unwrap(); - assert!(matches!( - err, - CallError::Domain(RemoteStatementStoreCreateProofError::V1( - v01::RemoteStatementStoreCreateProofError::UnableToSign - )) - )); + let RemoteStatementStoreCreateProofResponse::V1(inner) = response; + let v01::StatementProof::Sr25519 { signer, signature } = inner.proof else { + panic!("expected sr25519 statement proof"); + }; + assert_eq!(signer, expected_signer); + assert_sr25519_signature(signer, signature, &payload); + + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::StatementStoreProductSignRequest( + request, + ), + ) = message.data + else { + panic!("expected statement-store product sign request"); + }; + assert_eq!(request.product_account_id, account_id("myapp.dot", 0)); + assert_eq!(request.payload, payload); } #[test] @@ -460,8 +503,8 @@ mod tests { let (host, _signing_host) = signing_host_runtime("myapp.dot"); let statement = statement(); let payload = statement_payload(statement.clone()); - let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let product_keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let product_keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); let expected_signer = product_keypair.public.to_bytes(); let cx = CallContext::new(); let request = RemoteStatementStoreCreateProofRequest::V1( @@ -577,7 +620,9 @@ mod tests { #[test] fn statement_store_submit_posts_signed_statement_and_waits_for_ack() { let platform = Arc::new(StubPlatform { - rpc_responses: vec![r#"{"jsonrpc":"2.0","id":"truapi:1","result":"0xok"}"#.to_string()], + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":{"status":"new"}}"#.to_string(), + ], ..Default::default() }); let host = ProductRuntimeHost::new( diff --git a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs index d54ac0c9..a3dc132b 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs @@ -109,16 +109,24 @@ pub(super) async fn subscribe_match_all( subscribe(rpc_client, TopicFilterKind::MatchAll, topics).await } -/// Submit a SCALE-encoded statement and wait for the JSON-RPC ack. +/// Submit a SCALE-encoded statement and confirm the store accepted it. +/// +/// `statement_submit` returns an RPC error only for internal failures; a +/// rejected or invalid statement (e.g. `NoAllowance`, `BadProof`) comes back as +/// `Ok(SubmitResult)`. Treat only `new`/`known` as success, so allowance/proof +/// rejections surface instead of being silently dropped. pub(super) async fn submit(rpc_client: &RpcClient, statement: Vec) -> Result<(), String> { - rpc_client + let result = rpc_client .request::( SUBMIT_STATEMENT_METHOD, rpc_params![format!("0x{}", hex::encode(&statement))], ) .await - .map(|_| ()) - .map_err(rpc_error_message) + .map_err(rpc_error_message)?; + match result.get("status").and_then(Value::as_str) { + Some("new") | Some("known") => Ok(()), + _ => Err(format!("statement_submit not accepted: {result}")), + } } /// Statement-store topic filter encoded as JSON-RPC params. diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 62bd9baf..0963dc5d 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -420,10 +420,12 @@ pub(crate) fn subscribe_ack_frame(request_id: &str, subscription_id: &str) -> St } fn statement_submit_ack_frame(request_id: &str) -> String { + // Mirror the real `statement_submit` result shape (`SubmitResult`); `submit` + // treats only `new`/`known` as accepted. serde_json::json!({ "jsonrpc": "2.0", "id": request_id, - "result": "0xok", + "result": { "status": "new" }, }) .to_string() } @@ -917,6 +919,9 @@ fn retarget_sso_response(mut response: RemoteMessage, message_id: &str) -> Remot RemoteMessageData::V1(v1::RemoteMessage::CreateTransactionResponse(response)) => { response.responding_to = message_id.to_string(); } + RemoteMessageData::V1(v1::RemoteMessage::StatementStoreProductSignResponse(response)) => { + response.responding_to = message_id.to_string(); + } _ => {} } response diff --git a/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata.scale b/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata.scale new file mode 100644 index 00000000..28a7ac3f Binary files /dev/null and b/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata.scale differ diff --git a/rust/crates/truapi/src/api/chain.rs b/rust/crates/truapi/src/api/chain.rs index 158dd12f..d2d73fa2 100644 --- a/rust/crates/truapi/src/api/chain.rs +++ b/rust/crates/truapi/src/api/chain.rs @@ -362,9 +362,17 @@ pub trait Chain: Send + Sync { /// ```ts /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; /// + /// // Start a broadcast, then stop it using the returned operation id. + /// const broadcast = await truapi.chain.broadcastTransaction({ + /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// transaction: "0x", + /// }); + /// assert(broadcast.isOk(), "broadcastTransaction failed:", broadcast); + /// assert(broadcast.value.operationId, "broadcastTransaction returned no operation id"); + /// /// const result = await truapi.chain.stopTransaction({ /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, - /// operationId: "op-id", + /// operationId: broadcast.value.operationId, /// }); /// assert(result.isOk(), "stopTransaction failed:", result); /// console.log("transaction broadcast stopped");