diff --git a/.changeset/cli-light-client.md b/.changeset/cli-light-client.md new file mode 100644 index 000000000..e37a8d6f7 --- /dev/null +++ b/.changeset/cli-light-client.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi-host": patch +--- + +The `truapi-host` CLI can reach the chains through the embedded smoldot light client instead of the public RPC nodes: set `TRUAPI_LIGHT_CLIENT=1`. The host's own chain traffic (statement store, allowance renewal, preimages, PGAS claims) then needs no RPC node, so a node that rate-limits a shared CI address no longer stops the host at startup. RPC stays the default. diff --git a/.changeset/lucky-moons-smile.md b/.changeset/lucky-moons-smile.md index a87c5a8d5..a4f216e2a 100644 --- a/.changeset/lucky-moons-smile.md +++ b/.changeset/lucky-moons-smile.md @@ -7,3 +7,7 @@ from the active session's root, so a suite can fund or assert on that account without reading it out of the product's own UI. `productAccountAddress` works the same address out with no host running, which is what a suite funding that account once, in setup, reaches for. + +The signing runtime serves the fixture through its existing asynchronous +`productSubtreePublicKey` method, matching the pairing runtime and retaining +the optional timeout argument. diff --git a/.changeset/pvm-host-runtime-authority.md b/.changeset/pvm-host-runtime-authority.md new file mode 100644 index 000000000..9c2c4ead3 --- /dev/null +++ b/.changeset/pvm-host-runtime-authority.md @@ -0,0 +1,16 @@ +--- +"@parity/truapi-host": minor +--- + +Add the product-scoped PolkaVM application runtime and its version-two guest transport. Route host capabilities through +runtime authority checks and preserve the existing consent requirements for signing and resource allocation. + +Keep the SDK usable in no-std guest builds, including Pocket message types. + +Preserve that guest boundary with the consolidated `truapi` crate: host API +traits use the `host-api` feature, and native/browser packaging explicitly +requests its shared or static library artifacts. + +Build each iOS XCFramework slice with its own `cargo rustc` invocation. +Explicit static-library output cannot be combined with multiple target triples +in one invocation. diff --git a/.changeset/safari-worker-startup.md b/.changeset/safari-worker-startup.md new file mode 100644 index 000000000..1598a2438 --- /dev/null +++ b/.changeset/safari-worker-startup.md @@ -0,0 +1,6 @@ +--- +"@parity/truapi-host": patch +--- + +Give Web Worker WASM loading and runtime construction separate 30-second startup deadlines. Slow Safari startup no +longer terminates a worker that has already reached its next initialization milestone. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f3646a2e..26c074c27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,8 @@ jobs: - name: cargo build run: cargo build --workspace --all-targets --all-features + - name: Build no-std guest SDK + run: cargo build --locked -p truapi -p truapi-client --no-default-features - name: Check Rust library documentation env: RUSTDOCFLAGS: "-D warnings" @@ -236,6 +238,7 @@ jobs: js/packages/truapi-host/src/generated playground/test/generated rust/crates/truapi-host-cli/js/script-types.d.ts + rust/crates/truapi-client/src/generated.rs rust/crates/truapi/src/generated rust/crates/truapi/src/wasm @@ -814,7 +817,10 @@ jobs: - uses: ./.github/actions/rust-cache - name: Install wasm-pack - run: cargo install wasm-pack --locked + run: cargo install wasm-pack --version 0.14.0 --locked + + - name: Install core WASM artifact tools + run: bash scripts/install-wasm-artifact-tools.sh - name: Download codegen output uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -912,6 +918,9 @@ jobs: - name: Install wasm-pack run: cargo install wasm-pack --version 0.14.0 --locked + - name: Install core WASM artifact tools + run: bash scripts/install-wasm-artifact-tools.sh + - name: Download codegen output uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/dev-publish.yml b/.github/workflows/dev-publish.yml index 9dab5ded0..032d63e67 100644 --- a/.github/workflows/dev-publish.yml +++ b/.github/workflows/dev-publish.yml @@ -84,6 +84,9 @@ jobs: - name: Install wasm-pack run: cargo install wasm-pack --version 0.14.0 --locked + - name: Install core WASM artifact tools + run: bash scripts/install-wasm-artifact-tools.sh + # Stamp -dev-[-suffix].N (sorts below any future stable) # into package.json; never committed. - name: Compute dev version and dist-tag diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 66a1dc018..8e1ed7e31 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -246,6 +246,10 @@ jobs: if: steps.version.outputs.proceed == 'true' && (contains(steps.version.outputs.packages, '@parity/truapi-host|') || contains(steps.version.outputs.packages, '@parity/truapi-provider|')) run: cargo install wasm-pack --version 0.14.0 --locked + - name: Install core WASM artifact tools + if: steps.version.outputs.proceed == 'true' && contains(steps.version.outputs.packages, '@parity/truapi-host|') + run: bash scripts/install-wasm-artifact-tools.sh + - name: Build packages if: steps.version.outputs.proceed == 'true' run: | diff --git a/AGENTS.md b/AGENTS.md index 2f7b2d299..f2192e541 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,8 @@ rust/crates/ One implementation module per macro; lib.rs holds entry points truapi-provider/ network provider backends (WebSocket RPC or smoldot light-client); its `platform` module holds the chain-access traits + truapi-polkavm-host/ optional native composition pinned to + paritytech/polkavm-host-runtime truapi-verifiable/ ring-VRF operations over `verifiable`; a lazily loaded WASM module in the browser truapi-host-cli/ CLI pairing/signing hosts; Bun scripts share the container web API gates js/packages/ @@ -89,6 +91,13 @@ nightly-toolchain the dated nightly for rustfmt, CI clippy and rustdoc documents intentionally unpublished npm package versions ``` +The PolkaVM runtime implementation, wire crates, browser package, and ABI +contract live in `paritytech/polkavm-host-runtime`. The base `truapi` +must remain PolkaVM-free. Native integrations use the optional +`truapi-polkavm-host` composition crate; browser integrations consume +`@parity/polkavm-browser-runtime` directly. Never copy or export browser assets +from this repository. + ### Crate + binding invariants - `truapi` holds the canonical protocol definitions and, behind its default diff --git a/CHANGELOG.md b/CHANGELOG.md index ab264086e..d4c52d4ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to the TrUAPI protocol are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), generated from [Conventional Commits](https://www.conventionalcommits.org/). +## [Unreleased] + +### Changed + +- isolate the optional PolkaVM runtime in `truapi-polkavm-host`; keep the base + server and browser asset distribution independent, and pin cooperative update + deadlines, multi-touch input, and image clipboard output (#540) +- let browser signing hosts request personhood-backed Statement Store + allowances for products instead of reporting the allocator as native-only +- migrate the generic runtime and Rust client to SDK 0.16's scoped wire codec 2; + product guests must be rebuilt rather than sending codec-1 frames + +### Added + +- report local-wallet registration stages and retryable chain-read errors through + a request-scoped browser callback without resubmitting accepted claims +- expose local signing-wallet username registration and chain-verified identity + refresh through the browser worker, with native UID proofs and RFC-0004 + X25519 identifier keys +- Generate a transport-neutral `no_std` Rust client with typed request responses, + subscription interrupts, and host-initiated Renderer subscription codecs. +- Generate complete App, Widget, Worker, and Worker-only method catalogs from + the canonical protocol schema. + +### Fixed + +- keep host-backed allowance helpers available on Wasm with browser-compatible + polling clocks, while excluding the native-only renewal driver (#540) +- report the immutable PolkaVM runtime revision actually pinned by the optional + composition crate (#540) +- return a decode error instead of trapping when subscription helpers receive a + request descriptor + ## [0.23.0] - 2026-09-29 ### Added @@ -48,7 +81,6 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). - recover products after the app returns from the background (#974) - protect permission checks without breaking libraries (#916) - give the Android preview the real backend identifiers (#975) - ## [0.21.0] - 2026-09-24 ### Added @@ -69,7 +101,6 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). - mark the debugger private, since it is not published (#911) - initialize built-in personhood keys (#924) - ## [0.20.0] - 2026-09-23 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de8bc637b..91033e5af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,10 +97,22 @@ make playground # rebuild the playground against the refreshed snapshot make test # Rust + TypeScript client tests make check # full suite: build, fmt, clippy, test, TS tests, playground build + lint ``` + +To apply the same Rust formatter as CI, run from the repository root: + +```bash +cargo +"$(cat nightly-toolchain)" fmt --all +``` + Every target that compiles the `truapi` runtime depends on `check-generated`, so a missing generated file names itself and points at `make codegen` instead of failing inside rustc. +The codegen integration test runs the generator twice to check deterministic +output. Generated callback contracts are exercised by the Rust/WASM builds, +TypeScript consumers, and host runtime tests, rather than checked-in copies of +generated source. + ## Pull requests Maintainers merge pull requests by squashing all commits and editing the commit message if necessary using the GitHub diff --git a/Cargo.lock b/Cargo.lock index fa1aa05eb..a51e3d57d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,6 +45,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -126,7 +135,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.52.0", "x11rb", ] @@ -472,6 +481,15 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + [[package]] name = "askama" version = "0.16.0" @@ -497,7 +515,7 @@ dependencies = [ "memchr", "proc-macro2", "quote", - "rustc-hash", + "rustc-hash 2.1.3", "serde", "serde_derive", "syn 2.0.119", @@ -518,7 +536,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" dependencies = [ - "rustc-hash", + "rustc-hash 2.1.3", "serde", "serde_derive", "unicode-ident", @@ -731,6 +749,21 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitcoin_hashes" version = "0.14.101" @@ -740,11 +773,20 @@ dependencies = [ "hex-conservative", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "bitvec" @@ -788,6 +830,12 @@ dependencies = [ "constant_time_eq 0.4.2", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.9.0" @@ -864,6 +912,20 @@ name = "bytemuck" version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] [[package]] name = "byteorder" @@ -1054,6 +1116,16 @@ dependencies = [ "error-code", ] +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width 0.1.14", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -1160,6 +1232,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1176,6 +1258,17 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1252,7 +1345,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags", + "bitflags 2.13.1", "crossterm_winapi", "derive_more 2.1.1", "document-features", @@ -1500,7 +1593,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", ] @@ -1607,7 +1700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1654,7 +1747,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" dependencies = [ - "foldhash", + "foldhash 0.2.0", "libm", "portable-atomic", "siphasher", @@ -1741,12 +1834,45 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1983,6 +2109,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -1992,7 +2130,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", "wasm-bindgen", ] @@ -2007,6 +2145,17 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + [[package]] name = "glob" version = "0.3.4" @@ -2059,6 +2208,27 @@ dependencies = [ "web-sys", ] +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + [[package]] name = "goblin" version = "0.8.2" @@ -2070,6 +2240,57 @@ dependencies = [ "scroll", ] +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "h2" version = "0.4.15" @@ -2117,6 +2338,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", + "foldhash 0.1.5", ] [[package]] @@ -2127,7 +2349,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", "serde", "serde_core", ] @@ -2140,7 +2362,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2179,6 +2401,12 @@ dependencies = [ "arrayvec 0.7.8", ] +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + [[package]] name = "hkdf" version = "0.12.4" @@ -2708,7 +2936,7 @@ dependencies = [ "parking_lot", "pin-project", "rand 0.8.7", - "rustc-hash", + "rustc-hash 2.1.3", "serde", "serde_json", "thiserror 1.0.69", @@ -2811,6 +3039,23 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "konst" version = "0.2.20" @@ -2832,12 +3077,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -2910,7 +3171,7 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -2970,6 +3231,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2997,6 +3267,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "metal" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + [[package]] name = "minicov" version = "0.3.8" @@ -3051,6 +3336,28 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "685a9ac4b61f4e728e1d2c6a7844609c16527aeb5e6c865915c08e619c16410f" +[[package]] +name = "naga" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" +dependencies = [ + "arrayvec 0.7.8", + "bit-set", + "bitflags 2.13.1", + "cfg_aliases", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash 1.1.0", + "spirv", + "strum 0.26.3", + "termcolor", + "thiserror 2.0.19", + "unicode-xid", +] + [[package]] name = "nanoid" version = "0.4.0" @@ -3060,6 +3367,15 @@ dependencies = [ "rand 0.8.7", ] +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -3149,6 +3465,15 @@ dependencies = [ "libc", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc2" version = "0.6.4" @@ -3164,7 +3489,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -3176,7 +3501,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.1", "dispatch2", "objc2", ] @@ -3187,7 +3512,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags", + "bitflags 2.13.1", "dispatch2", "objc2", "objc2-core-foundation", @@ -3206,7 +3531,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", ] @@ -3217,7 +3542,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", ] @@ -3252,6 +3577,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "palette" version = "0.7.7" @@ -3372,6 +3706,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "picosimd" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7823ffd00d2b55ebe51750a19f47f2a33cb1f1d135f5cba893379b81c4d44856" + [[package]] name = "pin-project" version = "1.1.13" @@ -3437,7 +3777,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -3445,35 +3785,113 @@ dependencies = [ ] [[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +name = "polkavm" +version = "0.37.0" +source = "git+https://github.com/paritytech/polkavm.git?rev=c160c13c3c29bf3219ce1404ec95976000235a94#c160c13c3c29bf3219ce1404ec95976000235a94" dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.2", + "libc", + "log", + "picosimd", + "polkavm-assembler", + "polkavm-common", + "polkavm-linux-raw", ] [[package]] -name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +name = "polkavm-assembler" +version = "0.37.0" +source = "git+https://github.com/paritytech/polkavm.git?rev=c160c13c3c29bf3219ce1404ec95976000235a94#c160c13c3c29bf3219ce1404ec95976000235a94" dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", + "log", ] [[package]] -name = "portable-atomic" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +name = "polkavm-common" +version = "0.37.0" +source = "git+https://github.com/paritytech/polkavm.git?rev=c160c13c3c29bf3219ce1404ec95976000235a94#c160c13c3c29bf3219ce1404ec95976000235a94" +dependencies = [ + "log", + "picosimd", + "polkavm-assembler", +] + +[[package]] +name = "polkavm-gpu-wire" +version = "0.3.0" +source = "git+https://github.com/paritytech/polkavm-host-runtime.git?rev=e60a6135be6e00c72c90ca4abe23d2002f4eb962#e60a6135be6e00c72c90ca4abe23d2002f4eb962" + +[[package]] +name = "polkavm-host-runtime" +version = "0.3.0" +source = "git+https://github.com/paritytech/polkavm-host-runtime.git?rev=e60a6135be6e00c72c90ca4abe23d2002f4eb962#e60a6135be6e00c72c90ca4abe23d2002f4eb962" +dependencies = [ + "anyhow", + "getrandom 0.3.4", + "log", + "polkavm", + "polkavm-gpu-wire", + "polkavm-motion-wire", + "polkavm-ui-wire", + "pollster", + "serde", + "serde_json", + "thiserror 1.0.69", + "uniffi", + "wasm-encoder", + "wgpu", +] + +[[package]] +name = "polkavm-linux-raw" +version = "0.37.0" +source = "git+https://github.com/paritytech/polkavm.git?rev=c160c13c3c29bf3219ce1404ec95976000235a94#c160c13c3c29bf3219ce1404ec95976000235a94" + +[[package]] +name = "polkavm-motion-wire" +version = "0.3.0" +source = "git+https://github.com/paritytech/polkavm-host-runtime.git?rev=e60a6135be6e00c72c90ca4abe23d2002f4eb962#e60a6135be6e00c72c90ca4abe23d2002f4eb962" + +[[package]] +name = "polkavm-ui-wire" +version = "0.3.0" +source = "git+https://github.com/paritytech/polkavm-host-runtime.git?rev=e60a6135be6e00c72c90ca4abe23d2002f4eb962#e60a6135be6e00c72c90ca4abe23d2002f4eb962" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "potential_utf" @@ -3499,6 +3917,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "primitive-types" version = "0.13.1" @@ -3552,6 +3976,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + [[package]] name = "pxfm" version = "0.1.30" @@ -3581,7 +4011,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "socket2", "thiserror 2.0.19", @@ -3602,7 +4032,7 @@ dependencies = [ "rand 0.10.2", "rand_pcg", "ring", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", @@ -3623,7 +4053,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3635,6 +4065,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -3703,6 +4139,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + [[package]] name = "ratatui" version = "0.30.2" @@ -3722,7 +4164,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags", + "bitflags 2.13.1", "compact_str", "hashbrown 0.17.1", "itertools 0.14.0", @@ -3734,7 +4176,7 @@ dependencies = [ "thiserror 2.0.19", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -3755,7 +4197,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags", + "bitflags 2.13.1", "hashbrown 0.17.1", "indoc", "instability", @@ -3766,9 +4208,15 @@ dependencies = [ "strum 0.28.0", "time", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "rayon" version = "1.12.0" @@ -3795,7 +4243,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -3815,6 +4263,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "reqwest" version = "0.12.28" @@ -3899,7 +4353,7 @@ version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ - "bitflags", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3918,6 +4372,12 @@ dependencies = [ "rusqlite", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3945,11 +4405,11 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3995,7 +4455,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -4007,7 +4467,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs 0.26.11", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4264,8 +4724,8 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", - "core-foundation", + "bitflags 2.13.1", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4508,6 +4968,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallstr" version = "0.3.1" @@ -4787,6 +5256,15 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "spki" version = "0.7.3" @@ -4827,6 +5305,15 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + [[package]] name = "strum" version = "0.27.2" @@ -4845,6 +5332,19 @@ dependencies = [ "strum_macros 0.28.0", ] +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + [[package]] name = "strum_macros" version = "0.27.2" @@ -5134,7 +5634,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5440,7 +5940,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -5586,6 +6086,14 @@ dependencies = [ "zeroize", ] +[[package]] +name = "truapi-client" +version = "0.16.0" +dependencies = [ + "parity-scale-codec", + "truapi", +] + [[package]] name = "truapi-codegen" version = "0.1.0" @@ -5638,7 +6146,8 @@ dependencies = [ "tracing", "tracing-subscriber", "truapi", - "unicode-width", + "truapi-provider", + "unicode-width 0.2.2", "zeroize", ] @@ -5652,6 +6161,14 @@ dependencies = [ "trybuild", ] +[[package]] +name = "truapi-polkavm-host" +version = "0.2.0" +dependencies = [ + "polkavm-host-runtime", + "truapi", +] + [[package]] name = "truapi-provider" version = "0.1.0" @@ -5807,9 +6324,15 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools 0.14.0", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -6110,6 +6633,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -6204,6 +6736,16 @@ version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920" +[[package]] +name = "wasm-encoder" +version = "0.240.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d642d8c5ecc083aafe9ceb32809276a304547a3a6eeecceb5d8152598bc71f" +dependencies = [ + "leb128fmt", + "wasmparser 0.240.0", +] + [[package]] name = "wasmi" version = "0.40.0" @@ -6217,7 +6759,7 @@ dependencies = [ "wasmi_collections", "wasmi_core", "wasmi_ir", - "wasmparser", + "wasmparser 0.221.3", ] [[package]] @@ -6251,7 +6793,18 @@ version = "0.221.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" dependencies = [ - "bitflags", + "bitflags 2.13.1", +] + +[[package]] +name = "wasmparser" +version = "0.240.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b722dcf61e0ea47440b53ff83ccb5df8efec57a69d150e4f24882e4eba7e24a4" +dependencies = [ + "bitflags 2.13.1", + "indexmap", + "semver", ] [[package]] @@ -6325,6 +6878,115 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wgpu" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353" +dependencies = [ + "arrayvec 0.7.8", + "bitflags 2.13.1", + "cfg_aliases", + "document-features", + "js-sys", + "log", + "naga", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" +dependencies = [ + "arrayvec 0.7.8", + "bit-vec", + "bitflags 2.13.1", + "cfg_aliases", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.19", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "24.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259" +dependencies = [ + "android_system_properties", + "arrayvec 0.7.8", + "ash", + "bit-set", + "bitflags 2.13.1", + "block", + "bytemuck", + "cfg_aliases", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.19", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-types" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" +dependencies = [ + "bitflags 2.13.1", + "js-sys", + "log", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -6347,7 +7009,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6356,6 +7018,51 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -6363,39 +7070,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-sys" -version = "0.45.0" +name = "windows-result" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" dependencies = [ - "windows-targets 0.42.2", + "windows-targets 0.52.6", ] [[package]] -name = "windows-sys" -version = "0.52.0" +name = "windows-strings" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ + "windows-result", "windows-targets 0.52.6", ] [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-targets 0.52.6", + "windows-targets 0.42.2", ] [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.53.5", + "windows-targets 0.52.6", ] [[package]] @@ -6431,30 +7139,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -6467,12 +7158,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -6485,12 +7170,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -6503,24 +7182,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -6533,12 +7200,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -6551,12 +7212,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -6569,12 +7224,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -6587,12 +7236,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "1.0.4" @@ -6602,6 +7245,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" @@ -6656,6 +7305,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + [[package]] name = "yap" version = "0.12.0" diff --git a/Cargo.toml b/Cargo.toml index a992a4cd4..22a903037 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ clap = "4" console_error_panic_hook = "0.1" convert_case = "0.6" crossterm = "0.29" -derive_more = "2" +derive_more = { version = "2", default-features = false } flate2 = "1" frame-metadata = { version = "23", default-features = false } fs2 = "0.4" @@ -33,7 +33,7 @@ futures = "0.3" futures-timer = "3" futures-util = "0.3" getrandom = "0.2" -hex = "0.4" +hex = { version = "0.4", default-features = false, features = ["alloc"] } hkdf = "0.12" image = { version = "0.25", default-features = false } indoc = "2" @@ -47,8 +47,9 @@ log = "0.4" merlin = "3" nanoid = "0.4" parking_lot = "0.12" -parity-scale-codec = "3" +parity-scale-codec = { version = "3", default-features = false } pin-project = "1" +polkavm-host-runtime = { git = "https://github.com/paritytech/polkavm-host-runtime.git", rev = "e60a6135be6e00c72c90ca4abe23d2002f4eb962", version = "=0.3.0" } proc-macro2 = "1" qrcode = { version = "0.14.1", default-features = false } quote = "1" @@ -85,6 +86,7 @@ tokio-stream = "0.1" tokio-tungstenite = "0.24" tracing = "0.1" tracing-subscriber = { version = "0.3", default-features = false } +truapi-provider = { path = "rust/crates/truapi-provider", default-features = false } trybuild = "1" unicode-normalization = "0.1" unicode-width = "0.2" diff --git a/Makefile b/Makefile index 4ee1897e1..1c136beb0 100644 --- a/Makefile +++ b/Makefile @@ -163,7 +163,7 @@ UNIFFI_SWIFT_TMP := target/uniffi-swift-out PROVIDER_SWIFT_TMP := target/uniffi-provider-swift-out uniffi: check-generated ## Generate Swift bindings from the truapi cdylib into target/uniffi-swift-out (consumed by ios/truapi-host/scripts/rebuild.sh). - $(CARGO) build -p truapi --profile codegen + $(CARGO) rustc -p truapi --lib --crate-type cdylib --profile codegen rm -rf $(UNIFFI_SWIFT_TMP) mkdir -p $(UNIFFI_SWIFT_TMP) $(CARGO) run -p uniffi-bindgen-cli -- generate \ @@ -261,7 +261,7 @@ ios-chat-all: ios-chat-run ios-chat-host-playground-run ## Run both local iOS Ch UNIFFI_KOTLIN_OUT := android/truapi-host/src/main/kotlin/generated uniffi-kotlin: check-generated ## Regenerate Kotlin UniFFI bindings from the truapi cdylib. - $(CARGO) build -p truapi --profile codegen + $(CARGO) rustc -p truapi --lib --crate-type cdylib --profile codegen rm -rf $(UNIFFI_KOTLIN_OUT) mkdir -p $(UNIFFI_KOTLIN_OUT) $(CARGO) run -p uniffi-bindgen-cli -- generate \ @@ -278,7 +278,7 @@ android-jni: check-generated ## Cross-compile libtruapi.so for Android ABIs into @command -v cargo-ndk >/dev/null || { echo "cargo-ndk not found: cargo install cargo-ndk"; exit 1; } $(CARGO) ndk $(foreach abi,$(ANDROID_ABIS),-t $(abi)) \ -o $(ANDROID_JNILIBS) \ - build --release -p truapi + rustc -p truapi --lib --crate-type cdylib --release # cargo-ndk also copies dependency cdylib intermediates (hash-suffixed, # statically linked into libtruapi.so already); keep only ours. find $(ANDROID_JNILIBS) -name '*.so' ! -name 'libtruapi.so' -delete @@ -504,14 +504,13 @@ XCFRAMEWORK_TARGETS ?= $(if $(SIM_ONLY_ON),$(IOS_SIM_TARGET),$(IOS_DEVICE_TARGET XCFRAMEWORK_PROFILE ?= release XCFRAMEWORK_CARGO_FLAGS := $(if $(filter release,$(XCFRAMEWORK_PROFILE)),--release,) -# One cargo invocation carrying every slice, so the target graphs are scheduled -# together: the release profile's single codegen unit and fat LTO leave a long -# serial tail per slice, which the other slice fills. +# Explicit crate types require one target per cargo rustc invocation. xcframework: uniffi ## Build truapi_server.xcframework for iOS device + simulator (SIM_ONLY=1 for simulator only). rustup target add $(XCFRAMEWORK_TARGETS) - IPHONEOS_DEPLOYMENT_TARGET=$(IOS_DEPLOYMENT_TARGET) $(CARGO) build -p truapi \ - $(XCFRAMEWORK_CARGO_FLAGS) \ - $(XCFRAMEWORK_TARGETS:%=--target %) + set -e; for target in $(XCFRAMEWORK_TARGETS); do \ + IPHONEOS_DEPLOYMENT_TARGET=$(IOS_DEPLOYMENT_TARGET) $(CARGO) rustc -p truapi --lib --crate-type staticlib \ + $(XCFRAMEWORK_CARGO_FLAGS) --target "$$target"; \ + done rm -rf $(XCFRAMEWORK_OUT) $(XCFRAMEWORK_HEADERS) mkdir -p $(XCFRAMEWORK_HEADERS) cp $(UNIFFI_SWIFT_TMP)/truapiFFI.h $(XCFRAMEWORK_HEADERS)/ diff --git a/README.md b/README.md index 596e3f7cd..d0d77b8cb 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,20 @@ handles discoverable; proof creation still checks permission and ring membership The `listRingVrfKeys` example checks that both built-in keys are discoverable under `peopl.paseo` on Paseo. +Preimage lookups that miss the core's cache read the selected network's Bulletin +node through `bitswap_v1_get`. The CLI verifies the returned bytes against the +requested key and keeps missing lookups subscribed until the blob arrives. + +Bulletin submissions read the nonce and runtime metadata from current best-block +state, but bind the 64-block mortal signature to a finalized checkpoint. A best +block abandoned after dry-run therefore cannot invalidate the mortality anchor. +If finality lags beyond that window, submission fails before broadcast rather +than silently signing against an expired checkpoint. Existing submission +deadlines and bounded rejection retries are unchanged. + Product scripts and `truapi-host dev` use the same web API permission checks from `js/container`. Dev loads the container through a blocking script tag in your existing browser. Scripts run in Bun and retain filesystem, environment and process access. -To build from source, run `make headless install` with stable Rust, nightly Rust with rustfmt, Node.js 22 or newer, and +To build from source, run `make headless install` with stable Rust, the nightly pinned in `nightly-toolchain` with rustfmt, Node.js 22 or newer, and Bun installed. The target installs missing workspace build tools and regenerates the Rust and TypeScript sources before compiling. CI tests this command in both a fresh checkout and one with stale generated files, then runs a product script through the installed CLI. Code generation and the workspace documentation check reject rustdoc warnings. These checks @@ -106,9 +117,11 @@ rust/crates/ truapi/ Rust traits, versioned envelopes, latest payload re-exports, and the host runtime (feature `runtime`): dispatcher, typed SCALE logic, chain signing, WASM surface, host syscall traits + truapi-client/ generated transport-neutral no-std client codecs and execution catalogs truapi-codegen/ rustdoc JSON to TypeScript client + Rust dispatcher truapi-macros/ TrUAPI wire annotations and inter-host SSO proc macros truapi-provider/ Network provider backends (WebSocket RPC or smoldot light-client) and chain-access traits + truapi-polkavm-host/ Optional native composition of truapi and a pinned PolkaVM runtime truapi-verifiable/ Ring-VRF operations over `verifiable`; a lazily loaded WASM module in the browser js/packages/ truapi/ @parity/truapi TypeScript client @@ -142,6 +155,14 @@ scripts/battery.sh Run the generated battery against both headless CLI h scripts/bundle-size.mjs Measure the JS and WASM the truapi-* packages ship, against a baseline ``` +The PolkaVM application runtime, GPU/UI wire contracts, and browser runtime +live in +[`paritytech/polkavm-host-runtime`](https://github.com/paritytech/polkavm-host-runtime). +Native hosts that need both runtimes link the optional `truapi-polkavm-host` +composition crate; the base `truapi` remains PolkaVM-free. Browser hosts +consume `@parity/polkavm-browser-runtime` directly; browser assets are not +shipped from this repository. + Taking a screenshot opens **Report app issue** wherever the shake-opened Debug menu is, which is every build except the store submission: `DEBUG_TOOLS_ENABLED` on Android, false only for the `release` build type, and `TESTNET_FEATURE` on @@ -179,6 +200,12 @@ The [container permission boundary](js/container/README.md) documents the protec operations and the built-ins that remain mutable for product compatibility. Native bindings expose the canonical Rust domain and protocol value types; native-only adapter types are limited to lifecycle and callback behavior. +Native hosts must create an app-private directory excluded from device backups +and pass its path as `HostRuntimeConfig.database_directory` (`databaseDirectory` +in Swift and Kotlin). The shared core opens SQLite once; product executions keep +their own callbacks and consent scope while sharing that database. The store is +not compiled into the browser WASM bundles. See the +[core database contract](rust/crates/truapi/RUNTIME.md#core-database). On iOS, a wallet host that manages its own statement-store SSO session can call `handleSsoRequest` (routes one decrypted remote message through the core, returning a typed outcome: response bytes to post back, a disconnect marker, or @@ -202,6 +229,10 @@ a single package with tree-shakeable subpath entries: - `@parity/truapi-host/worker-runtime` is the Web Worker entrypoint so the WASM core can run off the page main thread. +`createWorkerHostRuntime` shares the native core while `createProvider(product, callbacks)` binds platform callbacks to +one product execution. The host UI disposes that execution's pending consent when its provider closes; wallet +authentication and storage remain core-owned. See the [host SDK](js/packages/truapi-host/README.md) for lifecycle details. + ### Chain transport A host that serves chain traffic itself embeds the `truapi-provider` crate: an @@ -262,8 +293,8 @@ so `make dev` leaves the board empty with no error. ## How it works 1. The protocol is defined as Rust traits in [`rust/crates/truapi/`](rust/crates/truapi/), with each trait tagged `#[wire_trait(id = N)]` and each method tagged `#[wire(id = N)]` for a stable byte-level `(trait, method)` dispatch table. Every method's doc comment must carry a ` ```ts ` example, which codegen extracts into the playground's EXAMPLE tab; the build fails if any method is missing one. -2. `truapi-codegen` reads rustdoc JSON for that crate and generates the TypeScript client under git-ignored paths in `js/packages/truapi/`. -3. Higher-level SDKs wrap the typed client; the transport encodes SCALE frames and ships them over WebSocket, `MessagePort`, or `postMessage` in iframe mode to the host. +2. `truapi-codegen` reads rustdoc JSON for that crate and generates the TypeScript client under git-ignored paths in `js/packages/truapi/`, the Rust host dispatcher, and the transport-neutral `no_std` Rust client. The Rust client exports typed method markers plus complete App, Widget, Worker, and Worker-only catalogs from the same wire schema. +3. Higher-level SDKs wrap the generated client; each runtime provides only its native frame transport. Browser products use `MessagePort` (or `postMessage` in iframe mode), while sandboxed runtimes such as PolkaVM supply explicit host imports. 4. The host decodes the frame, dispatches to the matching trait method, encodes the response, and ships it back. Wire ids are append-only per trait: a trait id is never reassigned and a method id is never renumbered or reused within its trait, so deployed products stay compatible across protocol revisions. New methods take the next free method ids in their own trait and leave every other trait untouched. Trait 255 is permanently reserved for a correlated protocol error, allowing either peer to reject API messages introduced after it was released instead of leaving the caller pending. @@ -283,6 +314,10 @@ make wasm # rebuild truapi WASM artifacts under js/packages/truapi-host/dist CI regenerates the shared bindings before building and testing both npm packages, so generated client and host callback changes are checked together. +CLI transcript tests share process-wide UI output. Match captured events by +request identity rather than queue position: other tests may emit unrelated +logs through the installed UI while the suite runs in parallel. + The native `truapi-host` utility runs pairing and signing hosts against the real SSO transport for local end-to-end work. See [Install the CLI](#install-the-cli) to get it, and the [`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index fd2db3646..45b6bf96d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -57,3 +57,21 @@ Regenerate: ( cd playground && npx license-checker-rseidelsohn --summary ) ( cd explorer && npx license-checker-rseidelsohn --summary ) ``` + +### Bundled playground fonts + +The playground serves unmodified font files through `next/font/local`, avoiding +build-time Google Fonts CSS requests. These assets are separate from the npm +dependency summaries above and retain the SIL Open Font License 1.1. + +All files come from [`google/fonts` revision +`23e54b51ddffbc7713c583748e3bd86f62b1fa4a`](https://github.com/google/fonts/tree/23e54b51ddffbc7713c583748e3bd86f62b1fa4a): + +| Family | Upstream files | Bundled licence and copyright notice | +|--------|----------------|--------------------------------------| +| Instrument Serif | `ofl/instrumentserif/InstrumentSerif-Regular.ttf`, `InstrumentSerif-Italic.ttf` | [InstrumentSerif-OFL.txt](playground/public/fonts/InstrumentSerif-OFL.txt) | +| Geist | `ofl/geist/Geist[wght].ttf` | [Geist-OFL.txt](playground/public/fonts/Geist-OFL.txt) | +| JetBrains Mono | `ofl/jetbrainsmono/JetBrainsMono[wght].ttf` | [JetBrainsMono-OFL.txt](playground/public/fonts/JetBrainsMono-OFL.txt) | + +The font files live in `playground/src/app/fonts/`; the licence notices are also +included in the deployed site's `/fonts/` directory. diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md index a5f0d7286..7dc203e0d 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -72,6 +72,8 @@ The public surface lives in [`src/main/kotlin/io/parity/truapi/TrUAPIHost.kt`](s - `ChatHostBridge` - native Chat storage and UI, implemented by hosts that serve the Chat modality and passed to `openProductExecution`. Hosts without it pass nothing and Chat calls answer unsupported. - `PocketHostBridge` - the host's Pocket card collection, implemented by hosts with a Pocket surface and passed as `pocket` to `openProductExecution`. The execution then offers `notifyPocketCardsChanged`. `removeCard` suspends, and decides and removes together, returning `NativePocketRemoval.Removed`, `Absent` or `Privileged`, so a card cannot be pinned between the check and the removal. Like Chat, Pocket is reachable only from a Worker execution with an active session, so without `activateLocalSession` every Pocket call answers `Denied`. Hosts without the bridge pass nothing and Pocket calls answer unsupported. +`HostBridge.currentLocale` includes the system BCP 47 language tag and actual time-zone identifier. Its default `localizeTimestamps` implementation uses `java.time` to format each instant in the requested language and zone, including daylight-saving transitions; grouping keys are Gregorian `YYYY-MM-DD`. The SDK has no application `Context`, so embeddings must observe app-language and `ACTION_TIMEZONE_CHANGED` updates and call `execution.notifyLocaleChanged`, unregistering observers on teardown. The in-repository product host does this for each execution. Custom language selectors must also override `currentLocale`. Direct users of generated callbacks must implement `localizeTimestamps`, either supplying a formatter or throwing `HostRejection.Rejected` when conversion is unavailable. + ## Chat A host serving the Chat modality implements `ChatHostBridge` (`createRoom`, `registerBot`, `postMessage`, `listRooms`) and opens the execution with `ProductExecutionKind.CHAT`: @@ -115,6 +117,7 @@ val runtime = TrUAPIHostRuntime( bulletinChainGenesisHash = bulletinChainGenesisHash, assetHubChainGenesisHash = assetHubChainGenesisHash, networkSuffix = "dot", + databaseDirectory = context.noBackupFilesDir.resolve("truapi").apply { mkdirs() }.absolutePath, ), ) // Chat needs an active session; without one every Chat call answers `Denied`. @@ -400,6 +403,7 @@ val runtimeConfig = HostRuntimeConfig( // `trustedProducts` grant. assetHubChainGenesisHash = ByteArray(32) { 1.toByte() }, networkSuffix = "dot", + databaseDirectory = webView.context.noBackupFilesDir.resolve("truapi").apply { mkdirs() }.absolutePath, // Optional: activate a local signing session from host-held BIP-39 entropy // (no SSO pairing). Omit for the QR pairing flow. localSessionSecret = null, @@ -451,7 +455,7 @@ When iterating on the core from a source checkout instead of the published artif make android-jni # needs cargo-ndk, the NDK, and the three Android rust targets ``` -or point the `mozilla-rust-android-gradle` plugin at `rust/crates/truapi` from the host app's own build (polkadot-app-android-v2 does this while it still builds from a checkout). +Ordinary `cargo build -p truapi` produces only the Rust library. `make android-jni` runs `cargo ndk` with `rustc -p truapi --lib --crate-type cdylib --release` to produce `libtruapi.so` for each ABI. Custom native build integrations must likewise request `cdylib` explicitly rather than relying on `cargo build`. `make uniffi-kotlin` requests a host `cdylib` with the unstripped `codegen` profile for binding generation. ## Maintainers: cutting a release diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt index 84ebd50eb..1c34fcdfa 100644 --- a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt @@ -26,6 +26,10 @@ package io.parity.truapi import java.util.Locale +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.CancellationException @@ -39,6 +43,9 @@ import uniffi.truapi.HostChatActionSubscribeItem import uniffi.truapi.HostDevicePermissionRequest import uniffi.truapi.HostFeatureSupportedRequest import uniffi.truapi.HostLocaleSubscribeItem +import uniffi.truapi.HostLocaleLocalizeTimestampsRequest +import uniffi.truapi.HostLocaleLocalizeTimestampsResponse +import uniffi.truapi.HostLocaleLocalizedTimestamp import uniffi.truapi.PocketCard import uniffi.truapi.HostPushNotificationRequest import uniffi.truapi.HostRendererActionSubscribeItem @@ -88,6 +95,7 @@ import uniffi.truapi.HostContactLookup import uniffi.truapi.HostContactMatches import uniffi.truapi.HostContactPick import uniffi.truapi.NativeContactsCallbacks +import uniffi.truapi.SsoRequestOutcome /** Package metadata. */ object TrUAPIHost { @@ -270,7 +278,31 @@ interface HostBridge { */ @Throws(HostRejection::class) fun currentLocale(): HostLocaleSubscribeItem = - HostLocaleSubscribeItem(Locale.getDefault().toLanguageTag()) + HostLocaleSubscribeItem(Locale.getDefault().toLanguageTag(), ZoneId.systemDefault().id) + + /** Format instants in the requested language and zone, including timestamp-specific DST. */ + @Throws(HostRejection::class) + suspend fun localizeTimestamps( + request: HostLocaleLocalizeTimestampsRequest, + ): HostLocaleLocalizeTimestampsResponse = withHostRejection { + require(request.languageTag.isNotBlank() && request.timeZone.isNotBlank()) + require(request.timestampsMs.size <= 128 && request.timestampsMs.all { it <= 253_402_300_799_999uL }) + val locale = Locale.Builder().setLanguageTag(request.languageTag).build() + val zone = ZoneId.of(request.timeZone) + val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale) + val date = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(locale) + val detail = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL, FormatStyle.LONG).withLocale(locale) + HostLocaleLocalizeTimestampsResponse(request.timestampsMs.map { timestamp -> + val instant = Instant.ofEpochMilli(timestamp.toLong()).atZone(zone) + require(instant.year in 1..9999) + HostLocaleLocalizedTimestamp( + localDate = instant.toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE), + time = instant.format(time), + date = instant.format(date), + dateTime = instant.format(detail), + ) + }) + } /** * Answer a feature-support query. Invoked on the dispatcher thread; must @@ -508,6 +540,11 @@ private class HostCallbackAdapter(private val bridge: HostBridge) : HostCallback override fun currentLocale(): HostLocaleSubscribeItem = withHostRejection { bridge.currentLocale() } + override suspend fun localizeTimestamps( + request: HostLocaleLocalizeTimestampsRequest, + ): HostLocaleLocalizeTimestampsResponse = + withHostRejection { bridge.localizeTimestamps(request) } + override suspend fun featureSupported(request: HostFeatureSupportedRequest): Boolean = withHostRejection { bridge.featureSupported(request) } @@ -839,6 +876,14 @@ class TrUAPIHostRuntime @Throws(NativeRuntimeConfigException::class) constructor inner.activateLocalSession(secret, liteUsername) } + /** Handle a paired session's opaque SSO request, awaiting native user consent. */ + @Throws(HostRejection::class) + suspend fun handleSsoRequest(message: ByteArray): SsoRequestOutcome = + inner.handleSsoRequest(message) + + /** Encode the disconnect notification; the caller routes it to the ending session. */ + fun prepareDisconnectRequest(): ByteArray = inner.prepareDisconnectRequest() + /** Push a JSON-RPC response from a native chain connection into the runtime. */ fun notifyChainResponse(connectionId: UInt, json: String) { inner.notifyChainResponse(connectionId, json) diff --git a/deny.toml b/deny.toml index 1a454ec77..e76a7185f 100644 --- a/deny.toml +++ b/deny.toml @@ -41,6 +41,12 @@ exceptions = [ # BSL-1.0 is permissive, but scope it so it cannot spread to other crates. { name = "clipboard-win", allow = ["BSL-1.0"] }, { name = "error-code", allow = ["BSL-1.0"] }, + # The host-neutral PolkaVM runtime and wire protocols are intentionally + # MPL-2.0 so their Rust implementations retain file-level copyleft. + { name = "polkavm-gpu-wire", allow = ["MPL-2.0"] }, + { name = "polkavm-motion-wire", allow = ["MPL-2.0"] }, + { name = "polkavm-ui-wire", allow = ["MPL-2.0"] }, + { name = "polkavm-host-runtime", allow = ["MPL-2.0"] }, { name = "uniffi", allow = ["MPL-2.0"] }, { name = "uniffi_bindgen", allow = ["MPL-2.0"] }, { name = "uniffi_checksum_derive", allow = ["MPL-2.0"] }, diff --git a/docs/issue-drafts/pointer-capture-control.md b/docs/issue-drafts/pointer-capture-control.md new file mode 100644 index 000000000..509573f68 --- /dev/null +++ b/docs/issue-drafts/pointer-capture-control.md @@ -0,0 +1,146 @@ +# Add host-mediated pointer capture to TrUAPI + +## Status + +Parked prototype note. Do not start an RFC or implementation from this draft +yet. Current hosts capture non-Tri2D surfaces after a primary click, which is +sufficient for the existing game prototypes. Revisit only after prototypes +demonstrate that products need runtime capture/release control beyond the +host's click-to-capture and Escape-to-release policy. + +## Summary + +Pointer lock is host functionality. A product should request and release it through TrUAPI rather than declaring a separate `relative-pointer` feature in its manifest. The host owns the application surface, enforces user activation, calls the platform pointer-lock API, and reports lifecycle changes. High-frequency movement remains on the bounded device-input data path. + +This separates three concerns: + +- The App manifest declares ordinary `pointer` input support. +- TrUAPI controls whether pointer lock is free, armed, or captured. +- Request denial is a call error, and an older host reports the protocol's + standard unsupported-method error. +- The runtime input ABI delivers absolute positions while free and relative + deltas while captured. + +## Motivation + +Graphics profile is not a pointer policy. Some framebuffer and WebGPU products are first-person games that need unbounded relative motion; others are cursor-driven tools that must leave the system pointer free. Defaulting all non-Tri2D surfaces to pointer lock surprises cursor-driven products, while a static manifest feature cannot express when a mixed menu/game product wants capture. + +Pointer lock also cannot be delegated to sandboxed product code. Browser and native hosts own the trusted surface and must preserve an unconditional escape path. + +## Proposed control plane + +Names are placeholders for the RFC: + +```ts +input.pointerCapture.request(): Promise> + +input.pointerCapture.release(): Promise> + +input.pointerCapture.subscribe(): Subscription< + \"free\" | \"armed\" | \"captured\" +> +``` + +The subscription follows the existing `theme.subscribe()` and +`locale.subscribe()` convention: it emits the current state immediately and +then every transition. A separate `status()` request would duplicate the first +subscription item. + +A browser generally cannot honor `request()` immediately because pointer lock +requires a transient user activation. In that case `request()` returns +`armed`; the host acquires lock on the next eligible primary click on the +requesting product's active surface. + +`release()` is idempotent and always permitted. Escape always releases capture +and is consumed by the host for that transition rather than forwarded to the +product as a key press. + +`denied` is a request error rather than a durable capture state: after denial +the surface remains `free`. A host that predates these methods already has a +standard answer—the TrUAPI protocol-error frame for an unknown discriminant—so +`unsupported` does not belong in the domain state enum. + +## Fit with the existing TrUAPI architecture + +The API should be a small `Input` method group, backed by an optional +`PointerCaptureHost` platform callback. The TrUAPI server is product-scoped, so +the callback already runs in the identity and lifecycle context of the calling +product. Web, desktop, and other surface-owning hosts implement the callback; +CLI or non-pointer hosts omit it and receive the existing `Unsupported` +behavior. + +This should not be added to `HostDevicePermissionRequest`. RFC 0002 defines +device permission decisions as indefinitely persisted grants or denials, often +paired with an operating-system permission. Pointer lock is transient surface +state, requires a current user gesture, and must always be releasable. A future +host policy may gate the request, but the first API does not need a persisted +permission. + +The RFC must allocate new append-only wire discriminants for request, release, +and the subscription lifecycle. Existing discriminants cannot be reused. + +## Input data plane + +Pointer movement does not travel through TrUAPI. The existing bounded runtime input channel remains the data plane: + +- Free pointer: event type `5`, absolute canvas `x`/`y`. +- Captured pointer: event type `6`, signed relative `dx`/`dy`. +- Buttons: event types `3` and `4` in either state. + +Hosts should discard the first cursor-warp sample after lock acquisition, reject non-finite or implausible discontinuities, and coalesce high-rate deltas to the runtime's bounded update cadence. + +The browser operation is Pointer Lock (`requestPointerLock()`), not DOM pointer capture (`setPointerCapture()`). DOM pointer capture only preserves event routing during a drag and does not provide an unbounded relative pointer. + +## Host invariants + +- Pointer lock defaults to off for every graphics profile. +- Only the product bound to the active verified surface may request capture. +- Capture requires a real user gesture on that surface. +- A product cannot suppress Escape release. +- Losing focus, replacing the product, navigating, or destroying the surface releases capture. +- Relative events are emitted only while the surface actually owns pointer lock. +- Request denial and every state transition are observable by the product so it + can stop mouse-look or present fallback controls. +- Hosts that predate the API return TrUAPI's standard unsupported-method error; + products decide whether keyboard, touch, or controller input is an acceptable + fallback. + +## Manifest relationship + +No separate `relative-pointer` manifest feature is required. TrUAPI already +provides generic unsupported-method handling, the runtime request expresses +current intent, and the host remains the authority. + +A future RFC may revisit install-time metadata if hosts need to warn that a product cannot function without pointer lock. Such metadata would describe compatibility or user experience; it would not replace runtime request/release control. + +## Future user policy + +A host may eventually expose a per-product policy such as `Ask`, `Allow`, or `Block`, plus a global `Never allow pointer lock` setting. This is intentionally deferred. + +If added, the setting should authorize or deny product requests; it should not force an arbitrary cursor-driven product into relative mode. The policy should be scoped to a verified product identity, show a visible capture indicator, and preserve Escape as an unconditional release path. + +## Revisit criteria + +Keep the existing host behavior while prototypes mature. Promote this note to +an RFC only when at least one real product demonstrates a concrete need such +as: + +- a cursor-driven framebuffer or WebGPU surface that must never capture; +- a mixed menu/game product that must release and reacquire capture without + relying on Escape and a new click; +- a cross-host inconsistency that cannot be fixed as host policy; +- a product that needs to observe capture denial or loss to provide a usable + fallback. + +If one of those cases appears, the RFC should specify request, release, the +immediate state subscription, wire ids, user-gesture semantics, and errors. +Only after that should the shared Rust core, host callbacks, Epoca, Dotli, and +products be changed. + +## Unresolved questions + +- Whether `request()` should be a one-shot request or remain armed across Escape release until the product cancels it. +- Whether capture permission persists per product, per publisher, per artifact version, or only for the current session. +- Whether native hosts need a distinct result for operating-system policy denial. +- Whether products need an optional install-time declaration that pointer lock is essential rather than merely supported. +- Whether a host should expose a standard visual indicator or leave presentation to each host implementation. diff --git a/hosts/android/bindings/truapi-host/build.gradle.kts b/hosts/android/bindings/truapi-host/build.gradle.kts index 9fc3a106f..6f611b4da 100644 --- a/hosts/android/bindings/truapi-host/build.gradle.kts +++ b/hosts/android/bindings/truapi-host/build.gradle.kts @@ -114,6 +114,13 @@ cargo { // workspace target dir, not a crate-local one. Point the plugin there so it // can find and copy the per-ABI .so into rustJniLibs. targetDirectory = "$truapiDir/target" + // The shared plugin defaults to `cargo build`; truapi packaging needs a cdylib. + exec = { spec, _ -> + val args = spec.args!!.toMutableList() + args[args.indexOf("build")] = "rustc" + args.addAll(listOf("-p", "truapi", "--lib", "--crate-type", "cdylib")) + spec.setArgs(args) + } } // The nightly rustdoc the core's codegen runs on, pinned by date in @@ -179,7 +186,7 @@ val generateCoreDispatcher by tasks.registering(Exec::class) { val buildHostCdylib by tasks.registering(Exec::class) { dependsOn(generateCoreDispatcher) workingDir = file(truapiDir) - commandLine("cargo", "build", "-p", "truapi", "--profile", "codegen") + commandLine("cargo", "rustc", "-p", "truapi", "--lib", "--crate-type", "cdylib", "--profile", "codegen") inputs.files( fileTree("$truapiDir/rust/crates") { include("**/*.rs", "**/*.js", "**/Cargo.toml") }, "$truapiDir/Cargo.toml", diff --git a/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/LocaleFormattingTest.kt b/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/LocaleFormattingTest.kt new file mode 100644 index 000000000..435c4af47 --- /dev/null +++ b/hosts/android/bindings/truapi-host/src/androidTest/kotlin/io/parity/truapi/LocaleFormattingTest.kt @@ -0,0 +1,77 @@ +package io.parity.truapi + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import uniffi.truapi.HostDevicePermissionRequest +import uniffi.truapi.HostFeatureSupportedRequest +import uniffi.truapi.HostLocaleLocalizeTimestampsRequest +import uniffi.truapi.HostRejection +import uniffi.truapi.PermissionDecision +import uniffi.truapi.ProductExecutionConfig +import uniffi.truapi.RemotePermission + +@RunWith(AndroidJUnit4::class) +class LocaleFormattingTest { + private val bridge = object : HostBridge { + override val storage: HostStorage get() = error("Formatting must not access storage") + override val coreStorage: HostCoreStorage get() = error("Formatting must not access core storage") + override suspend fun navigateTo(url: String) = Unit + override suspend fun devicePermission(product: ProductExecutionConfig, request: HostDevicePermissionRequest) = + PermissionDecision.DENY + override suspend fun remotePermission(product: ProductExecutionConfig, request: RemotePermission) = + PermissionDecision.DENY + override suspend fun featureSupported(request: HostFeatureSupportedRequest) = false + } + + @Test + fun timestampsUseDSTAndLocalMidnightAndNewContext() = runBlocking { + val instants = listOf( + "2024-03-10T06:59:00Z", "2024-03-10T07:01:00Z", + "2024-03-10T04:59:00Z", "2024-03-10T05:01:00Z", + "2024-11-03T05:30:00Z", "2024-11-03T06:30:00Z", + ).map(Instant::parse) + val timestamps = instants.map { it.toEpochMilli().toULong() } + val response = bridge.localizeTimestamps( + HostLocaleLocalizeTimestampsRequest(timestamps, "en-US", "America/New_York"), + ).timestamps + assertEquals("2024-03-09", response[2].localDate) + assertEquals("2024-03-10", response[3].localDate) + val expected = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + .withLocale(Locale.US).withZone(ZoneId.of("America/New_York")) + assertEquals(expected.format(instants[0]), response[0].time) + assertEquals(expected.format(instants[1]), response[1].time) + assertEquals(response[4].time, response[5].time) + assertNotEquals(response[4].dateTime, response[5].dateTime) + val changed = bridge.localizeTimestamps( + HostLocaleLocalizeTimestampsRequest(timestamps, "fr-FR", "Europe/Paris"), + ).timestamps + assertEquals("2024-03-10", changed[2].localDate) + assertNotEquals(response[0].date, changed[0].date) + } + + @Test + fun dateKeysStayGregorianAndUnknownZonesAreRejected() = runBlocking { + val response = bridge.localizeTimestamps( + HostLocaleLocalizeTimestampsRequest(listOf(0uL), "th-TH-u-ca-buddhist", "Asia/Bangkok"), + ) + assertEquals("1970-01-01", response.timestamps.single().localDate) + assertThrows(HostRejection.Rejected::class.java) { + runBlocking { + bridge.localizeTimestamps( + HostLocaleLocalizeTimestampsRequest(listOf(0uL), "en-US", "Not/AZone"), + ) + } + } + Unit + } +} diff --git a/hosts/android/docs/DEPLOYMENT.md b/hosts/android/docs/DEPLOYMENT.md index fef69401b..9042a33b4 100644 --- a/hosts/android/docs/DEPLOYMENT.md +++ b/hosts/android/docs/DEPLOYMENT.md @@ -100,6 +100,12 @@ rust.rustcCommand=/Users/me/.cargo/bin/rustc rust.pythonCommand=/usr/bin/python3 ``` +The `:bindings:truapi-host` module uses the Rust plugin's `exec` hook to run +`cargo rustc -p truapi --lib --crate-type cdylib` with the plugin's existing +Android target, profile and linker settings. Its host binding-generation build +requests the same crate type with `--profile codegen`. Ordinary `cargo build` +produces only the Rust library, not the `libtruapi.so` packaged in the APK. + `developer-tools/setup.sh` only installs the Detekt pre-commit hook; it does not install any part of the toolchain. diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt index dbc715071..83ab4a707 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt @@ -1,5 +1,17 @@ package io.paritytech.polkadotapp.feature_products_impl.domain.truapi +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import io.paritytech.polkadotapp.feature_settings_api.domain.language.AppLanguageProvider +import java.time.ZoneId +import java.util.Locale +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import uniffi.truapi.HostLocaleSubscribeItem import androidx.core.net.toUri import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -75,6 +87,8 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( private val appLifecycleObserver: AppLifecycleObserver, private val dotNsTldProvider: DotNsTldProvider, private val pocketCardStore: PocketCardStore, + @param:ApplicationContext private val context: Context, + private val appLanguageProvider: AppLanguageProvider, @Assisted private val scope: CoroutineScope, ) { @AssistedFactory @@ -103,6 +117,12 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( private var execution: TrUAPIProductExecution? = null private var pocketBridge: ProductPocketHostBridge? = null + private val cachedLanguageTag = AtomicReference(Locale.getDefault().toLanguageTag()) + private var localeJob: Job? = null + private var localeReceiver: BroadcastReceiver? = null + + private fun currentLocale() = + HostLocaleSubscribeItem(cachedLanguageTag.get(), ZoneId.systemDefault().id) init { // Tear the execution down with the owning scope: otherwise a closed @@ -125,6 +145,9 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( override val coreStorage: HostCoreStorage = EncryptedHostCoreStorage(encryptedPreferences) + override fun currentLocale(): HostLocaleSubscribeItem = + this@ProductTrUAPIHostBridge.currentLocale() + override fun onCoreLog(marker: String, detail: String) { Timber.tag("truapi.core").d("%s: %s", marker, detail) } @@ -251,6 +274,7 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( // and the callers launch this into scopes that have no handler for a throw. return runCatching { cachedChains.set(chains) + cachedLanguageTag.set(appLanguageProvider.languageTag.first()) val pocket = ProductPocketHostBridge(productId, pocketCardStore, scope) val opened = runtime.openProductExecution( bridge = buildBridge(productId, navigationPolicy), @@ -268,6 +292,7 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( ) val endpoint = opened.startWsBridge() observeAppTheme() + observeAppLocale() observeAppLifecycle() onReadyToInject(LocalhostBridgeBootstrap.script(endpoint.port, endpoint.token)) opened @@ -293,6 +318,27 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( } } + private fun observeAppLocale() { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + execution?.notifyLocaleChanged(currentLocale()) + } + } + ContextCompat.registerReceiver( + context, + receiver, + IntentFilter(Intent.ACTION_TIMEZONE_CHANGED), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + localeReceiver = receiver + localeJob = scope.launch { + appLanguageProvider.languageTag.collect { languageTag -> + cachedLanguageTag.set(languageTag) + execution?.notifyLocaleChanged(currentLocale()) + } + } + } + private fun setTheme(theme: HostThemeSubscribeItem) { cachedTheme.set(theme) execution?.notifyThemeChanged(theme) @@ -307,6 +353,10 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( fun stop() { val opened = execution ?: return execution = null + localeJob?.cancel() + localeJob = null + localeReceiver?.let(context::unregisterReceiver) + localeReceiver = null // Before the execution closes: a card change arriving afterwards would republish through a // handle that no longer exists. pocketBridge?.stop() diff --git a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridgeTest.kt b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridgeTest.kt index c8f066911..9318fcafc 100644 --- a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridgeTest.kt +++ b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridgeTest.kt @@ -1,5 +1,8 @@ package io.paritytech.polkadotapp.feature_products_impl.domain.truapi +import android.content.Context +import io.paritytech.polkadotapp.feature_settings_api.domain.language.AppLanguageProvider +import kotlinx.coroutines.flow.flowOf import uniffi.truapi.ProductExecutionKind import io.parity.truapi.TrUAPIHostRuntime import io.paritytech.polkadotapp.common.data.storage.preferences.encrypted.EncryptedPreferences @@ -31,6 +34,10 @@ class ProductTrUAPIHostBridgeTest { appLifecycleObserver = mock(AppLifecycleObserver::class.java), dotNsTldProvider = mock(DotNsTldProvider::class.java), pocketCardStore = mock(PocketCardStore::class.java), + context = mock(Context::class.java), + appLanguageProvider = object : AppLanguageProvider { + override val languageTag = flowOf("en-US") + }, scope = CoroutineScope(StandardTestDispatcher(testScheduler)), ) diff --git a/hosts/dotli b/hosts/dotli index ab652eb0b..4192dee5d 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit ab652eb0b0b95b33769c2738481988939b4de7ea +Subproject commit 4192dee5d53f948ded217b2b0eda1db5c9fa9399 diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index 248a953b9..62b8c0bb4 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -50,6 +50,11 @@ targets: make uniffi && ./ios/truapi-host/scripts/sync-bindings.sh ``` +Synchronization also removes generated Swift sources and FFI headers from the +former `truapi_server` and `truapi_platform` namespaces. Only the unified `truapi` +bindings belong in the package and its release tag. The binary asset remains +`truapi_server.xcframework`, and its Swift FFI module is `truapiFFI`. + CI's `iOS bindings (uniffi)` job runs the same two commands. With nothing committed to diff against, what it gates is that bindgen still produces a binding for every UniFFI-exposed type. It runs on Linux, so it never compiles @@ -72,6 +77,8 @@ For local iteration without publishing, set `TRUAPI_USE_LOCAL_BINARY=1` so the r The embedding app implements `HostBridge` (defined in `TrUAPIHost.swift`): navigation, push, permissions, auth state, scoped + core storage, chain JSON-RPC, confirmations, preimage, theme, feature support, and the served chain set. UI-decision callbacks are `async` and awaited by the Rust core. `HostCallbackAdapter` translates it to the UniFFI-generated `HostCallbacks` protocol; `TrUAPIHostRuntime` and each product execution retain their own adapter. Conform to `HostBridge` rather than to the generated protocol: its extension defaults the optional callbacks, so a newly added one does not break the build. Storage arrives as the `storage` and `coreStorage` sub-objects, which the adapter flattens. +The default `currentLocale` includes the system BCP 47 language tag and actual time-zone identifier. `localizeTimestamps` uses Foundation to format each instant in the requested language and zone, including historical daylight-saving offsets; grouping keys are always Gregorian `YYYY-MM-DD`. Product executions observe system locale and time-zone changes and remove those observers on close. Hosts with an in-app language picker override `currentLocale` and call `notifyLocaleChanged` when that selection changes, preserving the actual time zone. Direct users of generated callbacks must implement `localizeTimestamps`, either supplying a formatter or throwing `HostRejection.Rejected` when conversion is unavailable. + ## Integrating in an iOS app Add the package as an SPM dependency and link the `TrUAPIHost` product into the app target: @@ -159,7 +166,8 @@ let runtime = try TrUAPIHostRuntime( peopleChainGenesisHash: peopleChainGenesisHash, // exactly 32 bytes bulletinChainGenesisHash: bulletinChainGenesisHash, assetHubChainGenesisHash: assetHubChainGenesisHash, - networkSuffix: "dot" + networkSuffix: "dot", + databaseDirectory: databaseDirectory // existing app-private directory, excluded from backups ) ) // Chat needs an active session; without one every Chat call answers denied. @@ -528,7 +536,8 @@ let runtimeConfig = HostRuntimeConfig( // all-zero is the "no Asset Hub" sentinel and refuses every cross-product // `trustedProducts` grant. assetHubChainGenesisHash: Data(repeating: 1, count: 32), - networkSuffix: "dot" + networkSuffix: "dot", + databaseDirectory: databaseDirectory // existing app-private directory, excluded from backups ) let runtime = try TrUAPIHostRuntime(bridge: bridge, runtimeConfig: runtimeConfig) try runtime.activateLocalSession(secret: entropyBytes, liteUsername: nil) @@ -606,6 +615,8 @@ Build the generated JavaScript SDK before the container: from the repository roo `./scripts/rebuild.sh` orchestrates everything; the underlying pieces, should you need one in isolation: +Ordinary `cargo build -p truapi` produces only the Rust library. The packaging targets explicitly request a `staticlib` with `cargo rustc -p truapi --lib --crate-type staticlib` for each iOS slice, and a `cdylib` with `cargo rustc -p truapi --lib --crate-type cdylib --profile codegen` for binding generation. Use the Make targets to retain their profiles, target selection and deployment settings. + - **xcframework** — `make xcframework` (repo root) builds `truapi` for `aarch64-apple-ios` and `aarch64-apple-ios-sim` and bundles `target/truapi_server.xcframework`; the script copies it into `Binaries/` and strips the per-slice `module.modulemap` (module resolution comes from the `systemLibrary` target; the slice copy collides with other xcframeworks in Xcode's flat include dir). - **bindings** — `make uniffi` (run automatically by `make xcframework`) emits the Swift bindings into `target/uniffi-swift-out/` via the workspace `uniffi-bindgen-cli`; `scripts/sync-bindings.sh` copies them into `Sources/TrUAPIHost/truapi.swift` and `Sources/truapiFFI/include/`, renaming the emitted `truapiFFI.modulemap` to `module.modulemap` so the SwiftPM `systemLibrary` target picks it up. `rebuild.sh` calls it, and so does the `iOS package (Swift + WebKit)` job, which is what puts Swift sources into the package before `xcodebuild` runs. - **container** — `npm run build` in `js/container/` (repo root) bundles `src/index.ts` into `Sources/TrUAPIHost/Resources/truapi-container.js`. diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index a0f982623..7ff83c5cb 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -140,6 +140,10 @@ public protocol HostBridge: AnyObject, Sendable { /// tag. Hosts with no in-app language picker report the system language. func currentLocale() throws -> HostLocaleSubscribeItem + /// Format timestamps using the requested language and time zone. + func localizeTimestamps(request: HostLocaleLocalizeTimestampsRequest) async throws + -> HostLocaleLocalizeTimestampsResponse + /// Answer a feature-support query. Invoked on the dispatcher thread; must /// return promptly. func featureSupported(request: HostFeatureSupportedRequest) async throws -> Bool @@ -296,9 +300,51 @@ public extension HostBridge { } func currentLocale() throws -> HostLocaleSubscribeItem { HostLocaleSubscribeItem( - languageTag: Locale.current.language.languageCode?.identifier ?? "en" + languageTag: Locale.current.identifier(.bcp47), + timeZone: TimeZone.current.identifier ) } + func localizeTimestamps(request: HostLocaleLocalizeTimestampsRequest) async throws + -> HostLocaleLocalizeTimestampsResponse { + guard !request.languageTag.isEmpty, + let zone = TimeZone(identifier: request.timeZone), + request.timestampsMs.count <= 128, + request.timestampsMs.allSatisfy({ $0 <= 253_402_300_799_999 }) else { + throw HostRejection.Rejected(reason: "Invalid timestamp localization request") + } + let locale = Locale(identifier: request.languageTag) + let key = DateFormatter() + key.locale = Locale(identifier: "en_US_POSIX") + key.calendar = Calendar(identifier: .gregorian) + key.timeZone = zone + key.dateFormat = "yyyy-MM-dd" + let time = DateFormatter() + time.locale = locale + time.timeZone = zone + time.timeStyle = .short + let date = DateFormatter() + date.locale = locale + date.timeZone = zone + date.dateStyle = .long + let detail = DateFormatter() + detail.locale = locale + detail.timeZone = zone + detail.dateStyle = .full + detail.timeStyle = .long + return try HostLocaleLocalizeTimestampsResponse(timestamps: request.timestampsMs.map { + let instant = Date(timeIntervalSince1970: Double($0) / 1_000) + let localDate = key.string(from: instant) + guard localDate.count == 10 else { + throw HostRejection.Rejected(reason: "Local date is outside the four-digit year range") + } + return HostLocaleLocalizedTimestamp( + localDate: localDate, + time: time.string(from: instant), + date: date.string(from: instant), + dateTime: detail.string(from: instant) + ) + }) + } func supportedChains() throws -> HostChainSet { HostChainSet(network: "", chains: []) } func workerDemandChanged(productId: String, transition: WorkerTransition) {} func devicePaired(device: PairedSsoPeer) {} @@ -585,6 +631,13 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable { } } + func localizeTimestamps(request: HostLocaleLocalizeTimestampsRequest) async throws + -> HostLocaleLocalizeTimestampsResponse { + try await withHostRejection { + try await bridge.localizeTimestamps(request: request) + } + } + func featureSupported(request: HostFeatureSupportedRequest) async throws -> Bool { try await withHostRejection { try await bridge.featureSupported(request: request) @@ -1005,6 +1058,7 @@ public final class TrUAPIProductExecution: TrUAPIProductExecutionProtocol, @unch private let callbackRetainer: HostCallbacks private let chatRetainer: NativeChatCallbacks? private let pocketRetainer: NativePocketCallbacks? + private let localeObservers: [NSObjectProtocol] fileprivate init( inner: NativeProductExecution, @@ -1016,9 +1070,19 @@ public final class TrUAPIProductExecution: TrUAPIProductExecutionProtocol, @unch self.callbackRetainer = callbackRetainer self.chatRetainer = chatRetainer self.pocketRetainer = pocketRetainer + localeObservers = [ + NSLocale.currentLocaleDidChangeNotification, + NSNotification.Name.NSSystemTimeZoneDidChange, + ].map { name in + NotificationCenter.default.addObserver(forName: name, object: nil, queue: nil) { _ in + guard let locale = try? callbackRetainer.currentLocale() else { return } + inner.notifyLocaleChanged(locale: locale) + } + } } deinit { + localeObservers.forEach(NotificationCenter.default.removeObserver) inner.shutdown() } @@ -1031,6 +1095,7 @@ public final class TrUAPIProductExecution: TrUAPIProductExecutionProtocol, @unch } public func close() { + localeObservers.forEach(NotificationCenter.default.removeObserver) inner.shutdown() } diff --git a/ios/truapi-host/Tests/HostBridgeDefaultsTests.swift b/ios/truapi-host/Tests/HostBridgeDefaultsTests.swift index 8b5212f52..130273389 100644 --- a/ios/truapi-host/Tests/HostBridgeDefaultsTests.swift +++ b/ios/truapi-host/Tests/HostBridgeDefaultsTests.swift @@ -17,4 +17,48 @@ struct HostBridgeDefaultsTests { #expect(first != 0) #expect(second != 0) } + + @Test + func localizedTimestampsRespectDSTAndLocalMidnight() async throws { + let bridge = StubHostBridge() + let parser = ISO8601DateFormatter() + let instants = [ + "2024-03-10T06:59:00Z", "2024-03-10T07:01:00Z", + "2024-03-10T04:59:00Z", "2024-03-10T05:01:00Z", + "2024-11-03T05:30:00Z", "2024-11-03T06:30:00Z", + ].map { UInt64(parser.date(from: $0)!.timeIntervalSince1970 * 1_000) } + let response = try await bridge.localizeTimestamps(request: .init( + timestampsMs: instants, languageTag: "en-US", timeZone: "America/New_York" + )) + #expect(response.timestamps[2].localDate == "2024-03-09") + #expect(response.timestamps[3].localDate == "2024-03-10") + let expected = DateFormatter() + expected.locale = Locale(identifier: "en-US") + expected.timeZone = TimeZone(identifier: "America/New_York") + expected.timeStyle = .short + #expect(response.timestamps[0].time == expected.string(from: parser.date(from: "2024-03-10T06:59:00Z")!)) + #expect(response.timestamps[1].time == expected.string(from: parser.date(from: "2024-03-10T07:01:00Z")!)) + #expect(response.timestamps[4].time == response.timestamps[5].time) + #expect(response.timestamps[4].dateTime != response.timestamps[5].dateTime) + + let changed = try await bridge.localizeTimestamps(request: .init( + timestampsMs: instants, languageTag: "fr-FR", timeZone: "Europe/Paris" + )) + #expect(changed.timestamps[2].localDate == "2024-03-10") + #expect(changed.timestamps[0].date != response.timestamps[0].date) + } + + @Test + func localizedDateKeysStayGregorianAndUnknownZonesAreRejected() async throws { + let bridge = StubHostBridge() + let response = try await bridge.localizeTimestamps(request: .init( + timestampsMs: [0], languageTag: "th-TH-u-ca-buddhist", timeZone: "Asia/Bangkok" + )) + #expect(response.timestamps[0].localDate == "1970-01-01") + await #expect(throws: HostRejection.self) { + try await bridge.localizeTimestamps(request: .init( + timestampsMs: [0], languageTag: "en-US", timeZone: "Not/AZone" + )) + } + } } diff --git a/ios/truapi-host/scripts/sync-bindings.sh b/ios/truapi-host/scripts/sync-bindings.sh index 0582d4e09..313261c22 100755 --- a/ios/truapi-host/scripts/sync-bindings.sh +++ b/ios/truapi-host/scripts/sync-bindings.sh @@ -21,6 +21,13 @@ if [ ! -d "$UNIFFI_OUT" ]; then exit 66 fi +# SwiftPM compiles every Swift source in this directory, including bindings +# left by the former server/platform namespaces. +rm -f "$PACKAGE_ROOT/Sources/TrUAPIHost/truapi_server.swift" \ + "$PACKAGE_ROOT/Sources/TrUAPIHost/truapi_platform.swift" +rm -rf "$PACKAGE_ROOT/Sources/truapi_serverFFI" \ + "$PACKAGE_ROOT/Sources/truapi_platformFFI" + for namespace in $NAMESPACES; do mkdir -p "$PACKAGE_ROOT/Sources/TrUAPIHost" \ "$PACKAGE_ROOT/Sources/${namespace}FFI/include" diff --git a/js/packages/truapi-host/CHANGELOG.md b/js/packages/truapi-host/CHANGELOG.md index 5419489a5..5ba952d7f 100644 --- a/js/packages/truapi-host/CHANGELOG.md +++ b/js/packages/truapi-host/CHANGELOG.md @@ -1,5 +1,13 @@ # @parity/truapi-host +## Unreleased + +### Patch Changes + +- Keep monitoring accepted Lite username claims until chain ownership is confirmed + or the wallet activation is disposed. Recover from transient chain-read failures + without resubmitting registration or reporting a fixed polling cutoff as failure. + ## 0.23.0 ### Minor Changes @@ -43,7 +51,6 @@ - Updated dependencies [de343d4] - Updated dependencies [3ef2191] - @parity/truapi@0.22.0 - ## 0.21.0 ### Minor Changes @@ -98,7 +105,6 @@ - Updated dependencies [5a9f4b9] - Updated dependencies [cf1702f] - @parity/truapi@0.21.0 - ## 0.20.0 ### Patch Changes diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 35d34e3a1..eaf1d70ad 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -1,54 +1,121 @@ # @parity/truapi-host -WASM-backed TrUAPI host runtime. It embeds the `truapi` Rust core (compiled to WASM) -behind a Web Worker provider, plus per-environment integration entry points. It is the -counterpart to the native Android/iOS host shells. +WASM-backed TrUAPI host runtime. It embeds the `truapi` Rust core (compiled to WASM) behind a Web Worker provider, plus +per-environment integration entry points. It is the counterpart to the native Android/iOS host shells. ## Entry points The package exposes tree-shakeable subpath exports — import only what your environment needs: -| Import | Provides | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | -| `@parity/truapi-host` | Shared runtime types plus generated typed host callback contracts. | -| `@parity/truapi-host/web` | Browser pairing host: `createIframeHost` (iframe MessageChannel handshake) and `createWebWorkerPairingHostRuntime`. | -| `@parity/truapi-host/worker-runtime` | Web Worker entrypoint (import with your bundler's `?worker` suffix) so the WASM core runs off the page main thread. | -| `@parity/truapi-host/wasm/web` | The raw browser `wasm-bindgen` glue, if you need to instantiate the core yourself. | -| `@parity/truapi-host/testing` | `createMockHost`: the in-memory host seam a product is tested against. | -| `@parity/truapi-host/testing/playwright` | Playwright fixture that boots the test host and embeds the product in an iframe. | -| `@parity/truapi-host/testing/server` | Node server that serves the host page and its bundle. | -| `@parity/truapi-host/testing/client` | `createMockClient`: a product client over the mock with no iframe, for unit tests. | -| `@parity/truapi-host/testing/dev-accounts` | Named dev accounts derived from fixed BIP-39 entropy, which sign for real. | -| `@parity/truapi-host/testing/host-page` | The browser half the fixture drives, for a suite that boots its own page. | -| `@parity/truapi-host/wasm/testing` | The raw glue for the signing-enabled bundle the test host runs on. | - -`scripts/build-wasm.mjs` builds two WASM bundles, both `--no-default-features`. -`wasm/web` is the production browser host and excludes -`WasmSigningHostRuntime`; `wasm/testing` adds the Rust `wasm-signing-host` and -`test-host` features, which is what lets the test host hold keys and answer -resource allocation as granted without allocating anything. -`ProductRuntimeConfig` configures the pairing host and requires no network -suffix. The signing constructor's configuration requires -`runtimeConfig.networkSuffix` in addition: the bare TLD (`dot`, `paseo`, or -`testnet`) matching the People chain and the wallet's onboarding -configuration. - -`runtimeConfig.assetHub` is required by both configurations, pairing and -signing. It is the Asset Hub genesis hash, in the same shape as -`runtimeConfig.people` and `runtimeConfig.bulletin`. Product manifests are read -from the dotNS contracts deployed there, so it is what makes a -`trustedProducts` grant resolvable: without a usable value no manifest resolves, -so every cross-product grant not already cached is refused, and the refusal is -indistinguishable from the other product having granted nothing. A config -omitting it is rejected. +| Import | Provides | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| `@parity/truapi-host` | Shared runtime types plus generated typed host callback contracts. | +| `@parity/truapi-host/web` | Browser pairing and signing hosts: `createIframeHost`, `createWebWorkerPairingHostRuntime`, and `createWebWorkerSigningHostRuntime`. | +| `@parity/truapi-host/worker-runtime` | Web Worker entrypoint (import with your bundler's `?worker` suffix) so the WASM core runs off the page main thread. | +| `@parity/truapi-host/wasm/web` | The raw browser `wasm-bindgen` glue, if you need to instantiate the core yourself. | +| `@parity/truapi-host/testing` | `createMockHost`: the in-memory host seam a product is tested against. | +| `@parity/truapi-host/testing/playwright` | Playwright fixture that boots the test host and embeds the product in an iframe. | +| `@parity/truapi-host/testing/server` | Node server that serves the host page and its bundle. | +| `@parity/truapi-host/testing/client` | `createMockClient`: a product client over the mock with no iframe, for unit tests. | +| `@parity/truapi-host/testing/dev-accounts` | Named dev accounts derived from fixed BIP-39 entropy, which sign for real. | +| `@parity/truapi-host/testing/host-page` | The browser half the fixture drives, for a suite that boots its own page. | +| `@parity/truapi-host/wasm/testing` | The raw glue for the signing-enabled bundle the test host runs on. | + +`scripts/build-wasm.mjs` builds two WASM bundles, both `--no-default-features`. `wasm/web` is the production browser +host and excludes `WasmSigningHostRuntime`; `wasm/testing` adds the Rust `wasm-signing-host` and `test-host` features, +which is what lets the test host hold keys and answer resource allocation as granted without allocating anything. A real +browser wallet instead needs a web bundle built with `--no-default-features --features wasm-signing-host`, without +`test-host`. That enables native signing and wallet administration without the testing-only allocation shortcuts. Build +that wallet variant with `npm run build:wasm -- --web-only --signing-host`. The default web build remains pairing-only. +Run the package tests against the generated web/testing bundles, then exercise the consuming host's real worker with +the selected variant. `ProductRuntimeConfig` configures the pairing host and requires no network suffix. The signing +constructor's configuration requires `runtimeConfig.networkSuffix` in addition: the bare TLD (`dot`, `paseo`, or +`testnet`) matching the People chain and the wallet's onboarding configuration. + +`locale.subscribe` V2 reports the host's BCP 47 language tag and optional actual time-zone identifier. +Implement `locale.localizeTimestamps` with the exported `localizeTimestamps` helper to format batches of up +to 128 Unix-millisecond instants using JavaScript `Intl` and historical daylight-saving rules. Each result +contains a Gregorian `YYYY-MM-DD` grouping key and localized time, date and detailed date/time strings. +Invalid zones and out-of-range instants reject rather than substituting UTC. Publish a new locale +subscription value when the host language or zone changes. A missing time zone means local conversion +is unavailable; it is not UTC. The headless CLI deliberately has no local-time formatting engine. + +`runtime.createProvider(product, callbacks?)` optionally binds platform callbacks to one product execution while +retaining the same shared native host. Omit the second argument to use the host's default callbacks. Pass a complete +`WebWorkerHostCallbacks` bundle (not a partial override) when each iframe or worker connection owns its own consent UI. +Dispose that UI scope when the connection closes, provider creation fails, or the whole host retires; a replacement +connection must get a fresh scope. Provider disposal, frame failure, failed creation, and host teardown remove the SDK's +callback route, so stale requests cannot fall through to another connection's callbacks. + +Shared authentication, core storage, chain transport, and signing-wallet authority remain owned by the native host; the +optional bundle does not replace them. Raw Wasm consumers can use +`productRuntime(product, coreCallbacks, platformCallbacks?)` for the same execution-local adapters. This is callback +routing isolation, not per-call `AbortSignal` cancellation: hosts must still retire their connection-owned interactive +UI explicitly. + +`runtimeConfig.assetHub` is required by both configurations, pairing and signing. It is the Asset Hub genesis hash, in +the same shape as `runtimeConfig.people` and `runtimeConfig.bulletin`. Product manifests are read from the dotNS +contracts deployed there, so it is what makes a `trustedProducts` grant resolvable: without a usable value no manifest +resolves, so every cross-product grant not already cached is refused, and the refusal is indistinguishable from the +other product having granted nothing. A config omitting it is rejected. + +When a product requests a Statement Store allowance, the signing runtime first uses `confirmUserAction` for host-owned +approval UI, then registers the product-scoped allowance account through the platform's People-chain connection. +Registration requires the activated wallet root to belong to an eligible personhood ring. A randomly generated browser +account can still sign, but it cannot spend another identity's personhood allowance; use a pairing host with the mobile +Account Holder when that identity remains on the phone. + +After activating a local signing session, call `refreshLocalIdentity()` to read the configured Asset Hub's dotNS +ownership and install verified username metadata. +`registerLocalLiteUsername(baseUsername, identityBackendBaseUrl, onProgress?)` authenticates to the identity backend +with native UID proofs, submits the registration with the real RFC-0004 X25519 identifier key and Asset Hub time, then +monitors chain ownership until it is confirmed or the activation is disconnected/replaced. Backend acceptance alone is +not registration success. Slow confirmation and transient chain-read failures do not resubmit the claim or require +repeated manual refreshes. + +The optional callback receives `LocalIdentityProgress` (exported from `@parity/truapi-host/web`): `checking`, +`authenticating`, `submitting`, `confirming`, or `retrying` with a chain-read `error`. Stages reflect actual work, not +elapsed-time estimates. `confirming` means the backend accepted the request, not that the username is owned yet; only +the resolved promise confirms ownership. A retry does not submit another registration. Observer exceptions do not +interrupt the operation, and settled or disposed requests receive no further progress. + +Both methods return `LocalIdentity` (exported from `@parity/truapi-host/web`): the canonical lowercase `0x`-prefixed +`identityAccountId` and an optional verified `liteUsername`. The backend must allow the worker's origin, or the host +must provide an approved same-origin proxy. Secret material stays in the signing runtime. Disconnecting or replacing the +local activation invalidates an in-flight identity operation; concurrent identity operations are rejected. + +### Read-only wallet allowance inspection + +After activating a local signing session, trusted shell code can call: + +```ts +const snapshot = await runtime.getWalletAllowanceSnapshot(["example.paseo"]); +``` + +The result is a `WalletAllowanceSnapshot` exported from `@parity/truapi-host/web`. It reports Statement Store publishing +slots, PGAS claim capacity and balances, and Bulletin claim capacity and storage quotas. This is a signing-host admin +API, not a product protocol method. A bare local identity can inspect it without registering or refreshing a username. +Inspection never allocates, renews, signs, or changes wallet storage. + +Pass at most 32 unique canonical product IDs from trusted shell state. An empty list still inspects wallet-wide slots +and claims. PGAS balances cover each requested product's `Index(0)` account; Bulletin quotas use its distinct storage +allowance account. Other products, derivations, spending and historical attribution are outside this snapshot. + +Each section is independently available or unavailable. Available values carry finalized chain provenance and chain +time, with exact integer amounts and runtime-defined limits. PGAS claims also carry `membershipObservation` for the +separate People membership source. Missing metadata stays unknown, and a nonmember's policy limit is not presented as +usable capacity. + +Activation changes, disconnect, disposal and worker failure invalidate pending reads. The worker API rejects after 30 +seconds rather than leaving the caller pending; native read work may finish later, but cannot populate a replaced +wallet. Shells must additionally fence their selected network/product context. ## Bundler requirements -The worker imports the WASM glue by a literal specifier, so every bundler -resolves it statically and emits `truapi_server.js` as a chunk. Whether the -`truapi_server_bg.wasm` payload comes with it depends on the bundler: emitting -it requires treating `new URL("truapi_server_bg.wasm", import.meta.url)` inside -the glue as an asset reference, and not all of them do. +The worker imports the WASM glue by a literal specifier, so every bundler resolves it statically and emits +`truapi_server.js` as a chunk. Whether the `truapi_server_bg.wasm` payload comes with it depends on the bundler: +emitting it requires treating `new URL("truapi_server_bg.wasm", import.meta.url)` inside the glue as an asset reference, +and not all of them do. | Bundler | Emits the `.wasm`? | Host action | | ------------------- | ------------------ | -------------------------------------------------------------- | @@ -58,33 +125,27 @@ the glue as an asset reference, and not all of them do. | esbuild | No | Copy manually (see below). | | Bun (`bun build`) | No | Copy manually (see below). | -esbuild and Bun pass `new URL(..., import.meta.url)` through verbatim: the build -succeeds and the glue chunk is emitted, but no `.wasm` is written and the worker -404s at runtime. No flag changes this — `--loader:.wasm=file` only fires on -`import` statements, never on `new URL`. Hosts on those bundlers must copy -`truapi_server_bg.wasm` out of `@parity/truapi-host/dist/wasm/web/` into the same -output directory as the emitted `truapi_server-*.js` chunk, since the glue -resolves the payload relative to its own URL. +esbuild and Bun pass `new URL(..., import.meta.url)` through verbatim: the build succeeds and the glue chunk is emitted, +but no `.wasm` is written and the worker 404s at runtime. No flag changes this — `--loader:.wasm=file` only fires on +`import` statements, never on `new URL`. Hosts on those bundlers must copy `truapi_server_bg.wasm` out of +`@parity/truapi-host/dist/wasm/web/` into the same output directory as the emitted `truapi_server-*.js` chunk, since the +glue resolves the payload relative to its own URL. -Running Vite under Bun (`bunx --bun vite build`) uses Vite's bundler and is -unaffected; only `bun build` is. +Running Vite under Bun (`bunx --bun vite build`) uses Vite's bundler and is unaffected; only `bun build` is. -The literal import makes the worker a code-split chunk, so a Vite host must ask -for ES workers; the default `iife` format cannot code-split and fails the build: +The literal import makes the worker a code-split chunk, so a Vite host must ask for ES workers; the default `iife` +format cannot code-split and fails the build: ```ts export default defineConfig({ worker: { format: "es" } }); ``` -Only the `.wasm` the glue references is emitted, and bundlers content-hash it — -Vite writes `assets/truapi_server_bg-.wasm`, webpack writes a bare -`.wasm`. The `.wasm.gz` / `.wasm.br` sidecars under `dist/wasm/web/` -therefore cannot be copied into a host's output: `gzip_static` / -`brotli_static` serve `.gz` / `.br`, and the request path now -carries the bundler's hash. Hosts that serve precompressed assets should -generate them from their own build output, after hashing — either a post-build -pass over `dist` (gzip level 9 and brotli max quality reproduce the sidecars -byte for byte) or a bundler plugin: +Only the `.wasm` the glue references is emitted, and bundlers content-hash it — Vite writes +`assets/truapi_server_bg-.wasm`, webpack writes a bare `.wasm`. The `.wasm.gz` / `.wasm.br` sidecars under +`dist/wasm/web/` therefore cannot be copied into a host's output: `gzip_static` / `brotli_static` serve +`.gz` / `.br`, and the request path now carries the bundler's hash. Hosts that serve precompressed assets +should generate them from their own build output, after hashing — either a post-build pass over `dist` (gzip level 9 and +brotli max quality reproduce the sidecars byte for byte) or a bundler plugin: ```ts import { compression } from "vite-plugin-compression2"; @@ -101,17 +162,15 @@ export default defineConfig({ }); ``` -webpack hosts get the same result from `compression-webpack-plugin`. Skipping -this ships the full 1.4 MB `.wasm` where about 600 kB (gzip) or 470 kB (brotli) -would do — and a server configured with `gzip_static` but no dynamic `gzip on` +webpack hosts get the same result from `compression-webpack-plugin`. Skipping this ships the full 1.4 MB `.wasm` where +about 600 kB (gzip) or 470 kB (brotli) would do — and a server configured with `gzip_static` but no dynamic `gzip on` has no fallback. ## Optional capabilities -`HostCallbacks` groups are required except those listed on the Rust -`OptionalPlatform` super-trait, which are emitted as optional members. Omit one -and the core answers its product calls with `Unsupported`; supply it and the -whole group must be implemented: +`HostCallbacks` groups are required except those listed on the Rust `OptionalPlatform` super-trait, which are emitted as +optional members. Omit one and the core answers its product calls with `Unsupported`; supply it and the whole group must +be implemented: ```ts const callbacks: HostCallbacks = { @@ -125,25 +184,21 @@ const callbacks: HostCallbacks = { }; ``` -`permissionStatus.devicePermissionStatus` must answer from the OS without -prompting. Supply it and the core revalidates a stored device grant against it -before answering the product, so a capability the OS has since revoked or reset -stops reading as usable. Omit it and a stored grant answers on its own. +`permissionStatus.devicePermissionStatus` must answer from the OS without prompting. Supply it and the core revalidates +a stored device grant against it before answering the product, so a capability the OS has since revoked or reset stops +reading as usable. Omit it and a stored grant answers on its own. -`pocket` serves the host's card collection. `subscribePocketCards` emits the -calling product's cards and every later replacement, and `removePocketCard` -takes one out. The host owns the collection: removing an absent card succeeds, -and a card the host pins is refused with `Privileged`. +`pocket` serves the host's card collection. `subscribePocketCards` emits the calling product's cards and every later +replacement, and `removePocketCard` takes one out. The host owns the collection: removing an absent card succeeds, and a +card the host pins is refused with `Privileged`. -Under `createWebWorkerPairingHostRuntime` the presence of each optional group is -reported to the worker in its `init` message, so the core sees the same -capability set on both sides of the boundary. +Under `createWebWorkerPairingHostRuntime` the presence of each optional group is reported to the worker in its `init` +message, so the core sees the same capability set on both sides of the boundary. ### Product-rendered bodies -A host can ask a product to draw one body — a chat message, an input-widget -candidate, a Pocket card — and send back what the user does with it. Both entry -points live on the product provider and are present only on runtimes holding a +A host can ask a product to draw one body — a chat message, an input-widget candidate, a Pocket card — and send back +what the user does with it. Both entry points live on the product provider and are present only on runtimes holding a live channel to the core: ```ts @@ -174,56 +229,43 @@ await provider.publishRendererAction!({ stop(); // stop rendering; safe to call more than once ``` -A `TextField` value change instead carries the UTF-8 bytes of the new value, -with no length prefix. - -`render` reports failure through `onError` rather than throwing, so one dead -render cannot take the surrounding surface with it. Exactly one terminal fires -per render: `onComplete` means the last tree delivered stands, `onError` means -it is partial and must not be shown as final. A product that declines the -render, a tree that fails to decode, a closed connection, and a throwing -renderer all arrive as `onError`. An open render holds one worker reference for -the provider's product, released when the stream ends or the disposer runs, so -the product's worker stays up for as long as something is being drawn. - -`publishChatAction` is the path for posted messages, commands and host-drawn -`Actions` buttons. Each action entry point sits behind its own service's access -policy: the renderer refuses a connection that is not a `Worker` execution, and +A `TextField` value change instead carries the UTF-8 bytes of the new value, with no length prefix. + +`render` reports failure through `onError` rather than throwing, so one dead render cannot take the surrounding surface +with it. Exactly one terminal fires per render: `onComplete` means the last tree delivered stands, `onError` means it is +partial and must not be shown as final. A product that declines the render, a tree that fails to decode, a closed +connection, and a throwing renderer all arrive as `onError`. An open render holds one worker reference for the +provider's product, released when the stream ends or the disposer runs, so the product's worker stays up for as long as +something is being drawn. + +`publishChatAction` is the path for posted messages, commands and host-drawn `Actions` buttons. Each action entry point +sits behind its own service's access policy: the renderer refuses a connection that is not a `Worker` execution, and chat additionally requires a live session. -Two rules the core cannot check are the host's to keep: send a render context -only for a surface the product's manifest `includes`, and publish a renderer -action only from the current tree of an open render stream. +Two rules the core cannot check are the host's to keep: send a render context only for a surface the product's manifest +`includes`, and publish a renderer action only from the current tree of an open render stream. ## Product account addresses -A host that stores the core's `ProductSubtree` slot can name the account a -review will sign with, so the review can carry an address and a fee rather than -a bare derivation path. Both calls are pure and need no runtime or session, so +A host that stores the core's `ProductSubtree` slot can name the account a review will sign with, so the review can +carry an address and a fee rather than a bare derivation path. Both calls are pure and need no runtime or session, so `default()` alone is enough: ```ts -import init, { - deriveProductAccountPublicKey, - productAccountAddress, -} from "@parity/truapi-host/wasm/web"; +import init, { deriveProductAccountPublicKey, productAccountAddress } from "@parity/truapi-host/wasm/web"; import { DerivationIndex } from "@parity/truapi"; await init(); // `subtreePublicKey` is the 32 bytes read from the host's own // `ProductSubtree { sessionId, productId }` slot. -const publicKey = deriveProductAccountPublicKey( - subtreePublicKey, - DerivationIndex.enc(account.derivationIndex), -); +const publicKey = deriveProductAccountPublicKey(subtreePublicKey, DerivationIndex.enc(account.derivationIndex)); const address = productAccountAddress(publicKey); ``` -The index crosses as a SCALE-encoded `DerivationIndex`, the same value a review -already carries, so the 32-byte chain code behind it stays core-owned and a host -never reconstructs it. `productAccountAddress` applies the prefix host-spec C.6 -fixes, rather than leaving each host to choose one. +The index crosses as a SCALE-encoded `DerivationIndex`, the same value a review already carries, so the 32-byte chain +code behind it stays core-owned and a host never reconstructs it. `productAccountAddress` applies the prefix host-spec +C.6 fixes, rather than leaving each host to choose one. ### The same name in a test suite @@ -269,15 +311,32 @@ contacts from both. See the contacts RFC (`docs/rfcs/contacts-api.md`). ## Generated WASM artefacts -The ignored bundle under `dist/wasm/web/` is built with host-owned chain access. -Hosts wire their JSON-RPC provider through `chainConnect`; if they omit it, -chain calls fail with the core's standard unavailable error. Release builds use -the workspace size-optimized Rust profile plus `wasm-opt -Oz`, validate that -debug/name/producers custom sections were stripped, and emit `.wasm.gz` and -`.wasm.br` sidecars for hosts that serve precompressed assets. +The ignored bundle under `dist/wasm/web/` is built with host-owned chain access. Hosts wire their JSON-RPC provider +through `chainConnect`; if they omit it, chain calls fail with the core's standard unavailable error. Release builds use +the workspace size-optimized Rust profile plus `wasm-opt -Oz`, validate that debug/name/producers custom sections were +stripped, and emit `.wasm.gz` and `.wasm.br` sidecars for hosts that serve precompressed assets. + +The core stays an `rlib` for Rust/no_std consumers. This build explicitly requests a `cdylib` with `cargo rustc`, then +runs `wasm-bindgen` and `wasm-opt` using the profile settings in the core's Cargo metadata. The separate +`truapi-verifiable` module still builds with `wasm-pack`; its optimized hash is embedded into both cores. +`TRUAPI_WASM_PROFILE` accepts `release` (default), `dev`, or `profiling`, with the same profile behavior as wasm-pack. + +Prerequisites on PATH: -Build them after editing `rust/crates/truapi` and before packaging, publishing, or running -tests that load the raw WASM bundle (requires `wasm-pack` on PATH): +- Rust with `rustup target add wasm32-unknown-unknown`. +- `wasm-pack` 0.14.0 (`cargo install wasm-pack --version 0.14.0 --locked`). +- `wasm-bindgen-cli` at the exact resolved `wasm-bindgen` version in `Cargo.lock` + (`cargo install wasm-bindgen-cli --version --locked --force`). The build rejects a mismatched CLI + and prints the required install command. +- Binaryen 117's `wasm-opt`, matching wasm-pack 0.14.0's optimizer. Download the appropriate + [Binaryen version_117 archive](https://github.com/WebAssembly/binaryen/releases/tag/version_117) and add its `bin` + directory to PATH. + +From the repository root, `bash scripts/install-wasm-artifact-tools.sh` installs the matching bindgen CLI and +checksum-verified Binaryen archive, then prints the directory to add to PATH. CI uses the same installer. + +Build after editing `rust/crates/truapi` and before packaging, publishing, or running tests that load the raw WASM +bundle: ```bash npm run build:wasm # or `make wasm` from the repo root @@ -289,13 +348,9 @@ npm run build:wasm # or `make wasm` from the repo root import HostWorker from "@parity/truapi-host/worker-runtime?worker"; import { createWebWorkerPairingHostRuntime } from "@parity/truapi-host/web"; -const runtime = await createWebWorkerPairingHostRuntime( - new HostWorker(), - callbacks, - { - hostConfig, - }, -); +const runtime = await createWebWorkerPairingHostRuntime(new HostWorker(), callbacks, { + hostConfig, +}); const firstProvider = await runtime.createProvider({ productId: "first.dot" }); const secondProvider = await runtime.createProvider({ @@ -303,17 +358,15 @@ const secondProvider = await runtime.createProvider({ }); ``` -`@parity/truapi-host/web` also exports `createIframeHost` for the -protocol-iframe MessageChannel handshake. Host code creates one worker runtime -and then opens one provider per product id. +`@parity/truapi-host/web` also exports `createIframeHost` for the protocol-iframe MessageChannel handshake. Host code +creates one worker runtime and then opens one provider per product id. ## Session lifecycle -The core owns the session; the host owns persistence. At boot the core restores -the `AuthSession` slot on its own and reports the outcome through the `auth` -callback, `Disconnected` included, so a host waits for the first -`authStateChanged` instead of treating silence as "signed out". Every -transition below reports the resulting `AuthState` the same way. +The core owns the session; the host owns persistence. At boot the core restores the `AuthSession` slot on its own and +reports the outcome through the `auth` callback, `Disconnected` included, so a host waits for the first +`authStateChanged` instead of treating silence as "signed out". Every transition below reports the resulting `AuthState` +the same way. | Runtime method | Use it to | | ------------------------------- | ---------------------------------------------------------------------------- | @@ -327,11 +380,7 @@ transition below reports the resulting `AuthState` the same way. The boot order is create the runtime, restore, then open providers: ```ts -const runtime = await createWebWorkerPairingHostRuntime( - new HostWorker(), - callbacks, - { hostConfig }, -); +const runtime = await createWebWorkerPairingHostRuntime(new HostWorker(), callbacks, { hostConfig }); // Resolves once product frames may use the restored session; rejects when // there was nothing to restore. @@ -342,28 +391,25 @@ const provider = await runtime.createProvider({ productId: "first.dot" }); ### Statement-store traffic of the host's own -A host that runs its own statement-store traffic — P2P chat, device sync — signs -with the account its device advertises in the pairing QR. The paired wallet -registers that account's statement-store allowance while answering the -handshake, so it is the only account whose statements the network accepts from -this host. A locally minted key gets no allowance, and every submission fails -with `no allowance set for account`. +A host that runs its own statement-store traffic — P2P chat, device sync — signs with the account its device advertises +in the pairing QR. The paired wallet registers that account's statement-store allowance while answering the handshake, +so it is the only account whose statements the network accepts from this host. A locally minted key gets no allowance, +and every submission fails with `no allowance set for account`. | Value | Where | | ---------------------------------------- | -------------------------------------- | | `SessionUiInfo.deviceStatementAccountId` | On every `AuthState.Connected` | | `getDeviceStatementKey()` | Runtime method, 64-byte sr25519 secret | -Peers are told to address that same account, so it is also what a host derives -its own statement topics from. Both are `undefined` without an active session, -and the account is rotated per login: read it from the current session rather +Peers are told to address that same account, so it is also what a host derives its own statement topics from. Both are +`undefined` without an active session, and the account is rotated per login: read it from the current session rather than caching it across sign-ins. ## Worker lifecycle -A product has one worker, and the core keeps one reference count per worker. The host takes a reference while a -modality holder is on screen or in flight, such as a chat room the product serves, and releases it when the holder -leaves. The host runs and stops the worker executable itself; the runtime only tells it when demand crosses zero. +A product has one worker, and the core keeps one reference count per worker. The host takes a reference while a modality +holder is on screen or in flight, such as a chat room the product serves, and releases it when the holder leaves. The +host runs and stops the worker executable itself; the runtime only tells it when demand crosses zero. | Runtime method | Use it to | | --------------------------------- | ---------------------------------------------------------------- | @@ -386,18 +432,14 @@ once, after the last. A `wanted: false` is permission to stop, not an order: a h ## Debugging (dev-only) -The worker can stream every product↔core wire frame to the wire debugger. It is -off by default and the embedding host decides; the product needs no changes. -Two conditions must **both** hold or nothing dials and the core installs no tap: +The worker can stream every product↔core wire frame to the wire debugger. It is off by default and the embedding host +decides; the product needs no changes. Two conditions must **both** hold or nothing dials and the core installs no tap: -1. **The host page is a dev build.** The dial sits behind a hard - `import.meta.env.DEV` gate, which bundlers replace with a boolean literal: in - a production bundle that gate is false, so no option can turn the tap on. A - production build that shows no frames is this gate, not a broken debugger. - `NODE_ENV=development` is what opens the gate under Vite. -2. **A `ws://` loopback URL reaches the runtime**, from one of two places. The - host's own value wins over the build's, so the build's is a default and never - an override: +1. **The host page is a dev build.** The dial sits behind a hard `import.meta.env.DEV` gate, which bundlers replace with + a boolean literal: in a production bundle that gate is false, so no option can turn the tap on. A production build + that shows no frames is this gate, not a broken debugger. `NODE_ENV=development` is what opens the gate under Vite. +2. **A `ws://` loopback URL reaches the runtime**, from one of two places. The host's own value wins over the build's, + so the build's is a default and never an override: ```ts // 1. the host passes it, the normal path, where the host stays in control @@ -413,45 +455,37 @@ Two conditions must **both** hold or nothing dials and the core installs no tap: VITE_TRUAPI_DEBUGGER_URL=ws://127.0.0.1:9231 vite build ``` - Passing `null` or `""` is how a host refuses the dial even when the build - carries one; omitting the field takes the build's value. - - While a dial is live the host shows a small fixed-position badge naming every - endpoint frames are going to, so a tap left on from an earlier session is - visible rather than buried in a console line. Pass `debuggerIndicator: false` - to suppress it, and only when the host renders its own signal, since the - point is that a host streaming frames is never silent about it. One runtime - suppressing the badge leaves another runtime's badge alone. - - The dial is resolved once, when the runtime is created, and cannot be changed - from the page afterwards, so whether this session is observed is a property - of the build and the host, not of anything typed into a console later. - -Run the debugger at the other end (`@parity/truapi-debugger`, `npm run serve`, -`127.0.0.1:9231`). On the next runtime boot the worker dials that URL and (via -the Rust core's `DebugSink` tap) sends each frame as `{ channelId, dir, frame }`. - -The URL must be `ws://` on a loopback host. Anything else — `wss://`, `http://`, -a LAN or public address, a non-loopback hostname — yields an inert link and a -`wire debugger URL rejected` console warning; there is no certificate or `wss` -path. Prefer the literal `127.0.0.1` over `localhost`: `localhost` passes the -gate, but it resolves `::1` first on macOS while the debugger binds `127.0.0.1` -alone, so the same URL handed to a native host (`truapi`'s `WsDebugSink` -dials the first resolved address) silently never connects. - -The debugger owns all decoding and decodes every frame it can, including signing -and payment payloads; its safety is the dev-build gate above, not redaction. See -`js/packages/truapi-debugger/README.md` for the tap, the envelope, and the + Passing `null` or `""` is how a host refuses the dial even when the build carries one; omitting the field takes the + build's value. + + While a dial is live the host shows a small fixed-position badge naming every endpoint frames are going to, so a tap + left on from an earlier session is visible rather than buried in a console line. Pass `debuggerIndicator: false` to + suppress it, and only when the host renders its own signal, since the point is that a host streaming frames is never + silent about it. One runtime suppressing the badge leaves another runtime's badge alone. + + The dial is resolved once, when the runtime is created, and cannot be changed from the page afterwards, so whether + this session is observed is a property of the build and the host, not of anything typed into a console later. + +Run the debugger at the other end (`@parity/truapi-debugger`, `npm run serve`, `127.0.0.1:9231`). On the next runtime +boot the worker dials that URL and (via the Rust core's `DebugSink` tap) sends each frame as +`{ channelId, dir, frame }`. + +The URL must be `ws://` on a loopback host. Anything else — `wss://`, `http://`, a LAN or public address, a non-loopback +hostname — yields an inert link and a `wire debugger URL rejected` console warning; there is no certificate or `wss` +path. Prefer the literal `127.0.0.1` over `localhost`: `localhost` passes the gate, but it resolves `::1` first on macOS +while the debugger binds `127.0.0.1` alone, so the same URL handed to a native host (`truapi`'s `WsDebugSink` dials the +first resolved address) silently never connects. + +The debugger owns all decoding and decodes every frame it can, including signing and payment payloads; its safety is the +dev-build gate above, not redaction. See `js/packages/truapi-debugger/README.md` for the tap, the envelope, and the host-dials-debugger topology. ## Publishing -This package is published by the root `Release` workflow through -`paritytech/npm_publish_automation`. Do not run `npm publish` locally. Cut a -`release:` PR with a changeset for `@parity/truapi-host`; the workflow builds -the generated host bindings, the browser WASM bundle, packs the tarball, and -publishes it when the `@parity/truapi-host@` tag does not already -exist. +This package is published by the root `Release` workflow through `paritytech/npm_publish_automation`. Do not run +`npm publish` locally. Cut a `release:` PR with a changeset for `@parity/truapi-host`; the workflow builds the generated +host bindings, the browser WASM bundle, packs the tarball, and publishes it when the `@parity/truapi-host@` tag +does not already exist. ## Architecture diff --git a/js/packages/truapi-host/scripts/build-wasm.mjs b/js/packages/truapi-host/scripts/build-wasm.mjs index db0aa6290..9785c2124 100644 --- a/js/packages/truapi-host/scripts/build-wasm.mjs +++ b/js/packages/truapi-host/scripts/build-wasm.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // Rebuild the browser WASM artefacts of the `truapi` runtime generated under -// `dist/wasm/`. wasm-pack is required. The core keeps its published -// `truapi_server` file names, which bundler setups copy by name. +// `dist/wasm/`. Requires wasm-pack, a matching wasm-bindgen CLI, and wasm-opt. +// The core keeps its published `truapi_server` file names. // // Two bundles are built, and the difference is deliberate: // @@ -21,8 +21,8 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { cp, readFile, rm, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { cp, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { @@ -178,25 +178,141 @@ async function writeCompressedSidecars(wasmPath) { ); } +// The core is an rlib for no_std consumers. Request its cdylib only here; +// wasm-pack requires cdylib in Cargo.toml even when rustc can emit it explicitly. +async function buildCore(outName, target, outDir, features, env) { + const crateDir = resolve(repoRoot, "rust/crates/truapi"); + const cargoArgs = [ + "--manifest-path", resolve(crateDir, "Cargo.toml"), + "--no-default-features", + "--features", features.join(","), + ]; + const options = { + cwd: repoRoot, + env: { ...process.env, ...env }, + maxBuffer: 32 * 1024 * 1024, + }; + const { stdout: metadataJson } = await execFileAsync("cargo", [ + "metadata", "--format-version", "1", + "--filter-platform", "wasm32-unknown-unknown", + ...cargoArgs, + ], options); + const metadata = JSON.parse(metadataJson); + const pkg = metadata.packages.find((pkg) => pkg.name === "truapi"); + const coreNode = metadata.resolve.nodes.find((node) => node.id === pkg.id); + const bindgenId = coreNode.deps.find((dep) => dep.name === "wasm_bindgen").pkg; + const bindgenVersion = metadata.packages.find((pkg) => pkg.id === bindgenId).version; + const { stdout: cliVersion } = await execFileAsync("wasm-bindgen", ["--version"], options); + if (cliVersion.trim() !== `wasm-bindgen ${bindgenVersion}`) { + throw new Error( + `wasm-bindgen CLI must match ${bindgenVersion}; run ` + + `cargo install wasm-bindgen-cli --version ${bindgenVersion} --locked --force`, + ); + } + const { stdout, stderr } = await execFileAsync("cargo", [ + "rustc", "--lib", "--crate-type", "cdylib", + "--target", "wasm32-unknown-unknown", + ...(wasmProfile === "dev" ? [] : ["--release"]), + "--message-format", "json-render-diagnostics", + ...cargoArgs, + ], options); + process.stderr.write(stderr); + const artifacts = stdout.trim().split("\n").map((line) => JSON.parse(line)); + const wasmFiles = artifacts + .filter((artifact) => artifact.reason === "compiler-artifact" && artifact.package_id === pkg.id) + .flatMap((artifact) => artifact.filenames) + .filter((file) => file.endsWith(".wasm")); + if (wasmFiles.length !== 1) { + throw new Error(`Expected one truapi Wasm artifact, found ${wasmFiles.length}`); + } + + const profile = pkg.metadata["wasm-pack"]?.profile?.[wasmProfile] ?? {}; + const bindgen = profile["wasm-bindgen"] ?? {}; + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + await execFileAsync("wasm-bindgen", [ + wasmFiles[0], "--out-dir", outDir, "--out-name", outName, + "--target", target, "--typescript", + ...((bindgen["debug-js-glue"] ?? (wasmProfile === "dev")) ? ["--debug"] : []), + ...(bindgen["demangle-name-section"] === false ? ["--no-demangle"] : []), + ...(bindgen["dwarf-debug-info"] ? ["--keep-debug"] : []), + ...(bindgen["omit-default-module-path"] ? ["--omit-default-module-path"] : []), + ...(bindgen["split-linked-modules"] ? ["--split-linked-modules"] : []), + ], options); + const optimize = profile["wasm-opt"] ?? (wasmProfile !== "dev"); + if (optimize !== false) { + const wasmPath = resolve(outDir, `${outName}_bg.wasm`); + const optimizedPath = resolve(outDir, `${outName}_bg.wasm-opt.wasm`); + await execFileAsync("wasm-opt", [ + wasmPath, "-o", optimizedPath, + ...(Array.isArray(optimize) ? optimize : ["-O"]), + ], options); + await rename(optimizedPath, wasmPath); + } + + // Include workspace licenses for this crate's inherited license, and let + // any crate-local licenses override files with the same name. + for (const dir of [metadata.workspace_root, crateDir]) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (entry.isFile() && entry.name.startsWith("LICENSE")) { + await cp(resolve(dir, entry.name), resolve(outDir, entry.name)); + } + } + } + if (pkg.readme) { + await cp(resolve(crateDir, pkg.readme), resolve(outDir, "README.md")); + } + if (pkg.license_file) { + await cp(resolve(crateDir, pkg.license_file), resolve(outDir, basename(pkg.license_file))); + } + const generatedFiles = await readdir(outDir); + const dependencies = generatedFiles.includes("package.json") + ? JSON.parse(await readFile(resolve(outDir, "package.json"), "utf8")) + : undefined; + await writeFile(resolve(outDir, "package.json"), JSON.stringify({ + name: pkg.name, + ...(target === "web" ? { type: "module", sideEffects: ["./snippets/*"] } : {}), + description: pkg.description, + version: pkg.version, + license: pkg.license ?? (pkg.license_file ? `SEE LICENSE IN ${basename(pkg.license_file)}` : undefined), + ...(pkg.repository ? { repository: { type: "git", url: pkg.repository } } : {}), + ...(pkg.homepage ? { homepage: pkg.homepage } : {}), + ...(pkg.authors.length ? { collaborators: pkg.authors } : {}), + ...(pkg.keywords.length ? { keywords: pkg.keywords } : {}), + files: [ + `${outName}_bg.wasm`, `${outName}.js`, `${outName}.d.ts`, + ...generatedFiles.filter((file) => file.startsWith("LICENSE") && file !== "LICENSE"), + ...(generatedFiles.includes("snippets") ? ["snippets"] : []), + ], + main: `${outName}.js`, + types: `${outName}.d.ts`, + dependencies, + }, null, 2) + "\n"); +} + async function build(crate, outName, target, subdir, features = [], env = {}) { const outDir = resolve(pkgRoot, "dist/wasm", subdir); process.stdout.write( - `wasm-pack build ${crate} --target ${target} --${wasmProfile}${ + `${crate === "truapi" ? "cargo rustc + wasm-bindgen + wasm-opt" : "wasm-pack build"} ${crate} --target ${target} --${wasmProfile}${ features.length > 0 ? ` --features ${features.join(",")}` : "" } → ${outDir}\n`, ); try { - await execFileAsync("wasm-pack", args(crate, outName, target, outDir, features), { - cwd: repoRoot, - env: { ...process.env, ...env }, - }); + const packArgs = args(crate, outName, target, outDir, features); + if (crate === "truapi") { + await buildCore(outName, target, outDir, features, env); + } else { + await execFileAsync("wasm-pack", packArgs, { + cwd: repoRoot, + env: { ...process.env, ...env }, + }); + } } catch (err) { if (err?.code === "ENOENT") { console.error( - "wasm-pack is required. Install it with `cargo install wasm-pack` " + - "or see https://rustwasm.github.io/wasm-pack/installer/", + "Wasm builds require cargo, wasm-pack, wasm-bindgen matching Cargo.lock, " + + "and wasm-opt (Binaryen 117) on PATH. See the package README.", ); - process.exit(1); } throw err; } @@ -214,7 +330,7 @@ async function build(crate, outName, target, subdir, features = [], env = {}) { } // The cores are built against this hash and load no other module. It is staged -// apart, since each wasm-pack build writes its own `package.json`, and only +// apart, since each bundle writes its own `package.json`, and only // the module's glue and payload are copied into each bundle. const verifiableStage = resolve(pkgRoot, "dist/wasm/.verifiable"); const verifiableWasm = await build( @@ -233,7 +349,10 @@ const env = { const bundles = process.argv.includes("--web-only") ? ["web"] : ["web", "testing"]; -await build("truapi", "truapi_server", "web", "web", ["runtime"], env); +const webFeatures = process.argv.includes("--signing-host") + ? ["wasm-signing-host"] + : ["runtime"]; +await build("truapi", "truapi_server", "web", "web", webFeatures, env); if (bundles.includes("testing")) { await build( "truapi", diff --git a/js/packages/truapi-host/src/index.ts b/js/packages/truapi-host/src/index.ts index b3744f178..5568b3e3d 100644 --- a/js/packages/truapi-host/src/index.ts +++ b/js/packages/truapi-host/src/index.ts @@ -1,3 +1,4 @@ export type { Payload, ProtocolMessage, WireProvider } from "@parity/truapi"; export * from "./runtime.js"; +export { localizeTimestamps } from "./locale.js"; diff --git a/js/packages/truapi-host/src/locale.test.ts b/js/packages/truapi-host/src/locale.test.ts new file mode 100644 index 000000000..14396dbb8 --- /dev/null +++ b/js/packages/truapi-host/src/locale.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "bun:test"; +import { localizeTimestamps } from "./locale.js"; + +const clock = (time: string) => time.match(/\d+/g)?.map(Number); + +async function localized( + instants: string[], + timeZone = "America/New_York", + languageTag = "en-GB", +) { + return ( + await localizeTimestamps({ + languageTag, + timestampsMs: instants.map((iso) => BigInt(Date.parse(iso))), + timeZone, + }) + ).timestamps; +} + +describe("timestamp localization", () => { + it("uses the offset at each instant across spring and autumn DST transitions", async () => { + const [before, after, firstHour, repeatedHour] = await localized([ + "2024-03-10T06:59:00Z", + "2024-03-10T07:00:00Z", + "2024-11-03T05:30:00Z", + "2024-11-03T06:30:00Z", + ]); + expect(clock(before!.time)).toEqual([1, 59]); + expect(clock(after!.time)).toEqual([3, 0]); + expect(before!.localDate).toBe("2024-03-10"); + expect(after!.localDate).toBe(before!.localDate); + expect(clock(firstHour!.time)).toEqual([1, 30]); + expect(repeatedHour!.time).toBe(firstHour!.time); + expect(repeatedHour!.dateTime).not.toBe(firstHour!.dateTime); + }); + + it("groups by local midnight, not UTC midnight, independently of locale calendars", async () => { + const instants = ["2024-01-01T14:59:00Z", "2024-01-01T15:00:00Z"]; + const tokyo = await localized(instants, "Asia/Tokyo", "ar-SA-u-ca-islamic-nu-arab"); + const newYork = await localized(instants); + expect(tokyo.map((value) => value.localDate)).toEqual(["2024-01-01", "2024-01-02"]); + expect(newYork.map((value) => value.localDate)).toEqual(["2024-01-01", "2024-01-01"]); + expect(tokyo[0]!.time).toMatch(/[\u0660-\u0669]/); + expect(tokyo[0]!.date).toMatch(/[\u0660-\u0669]/); + }); + + it("uses the requested language and zone afresh for the same timestamp", async () => { + const instants = ["2024-07-01T02:30:00Z"]; + const english = await localized(instants); + const german = await localized(instants, "America/New_York", "de-DE"); + const berlin = await localized(instants, "Europe/Berlin", "de-DE"); + expect(english[0]!.date).not.toBe(german[0]!.date); + expect(english[0]!.localDate).toBe(german[0]!.localDate); + expect(german[0]!.localDate).toBe("2024-06-30"); + expect(berlin[0]!.localDate).toBe("2024-07-01"); + expect(clock(german[0]!.time)).toEqual([22, 30]); + expect(clock(berlin[0]!.time)).toEqual([4, 30]); + }); + + it("handles the epoch and the supported final UTC instant without rounding", async () => { + const result = await localizeTimestamps({ + timestampsMs: [0n, 253402300799999n], + languageTag: "en-GB", + timeZone: "UTC", + }); + expect(result.timestamps.map((value) => value.localDate)).toEqual([ + "1970-01-01", + "9999-12-31", + ]); + const [epochWest] = await localized(["1970-01-01T00:00:00Z"]); + expect(epochWest!.localDate).toBe("1969-12-31"); + }); + + it("returns errors instead of substituting UTC or formatting an invalid instant", async () => { + const valid = { timestampsMs: [0n], languageTag: "en-GB", timeZone: "UTC" }; + for (const request of [ + { ...valid, timeZone: "Not/AZone" }, + { ...valid, timeZone: "" }, + { ...valid, languageTag: "" }, + { ...valid, languageTag: "not_a_locale" }, + { ...valid, timestampsMs: [-1n] }, + { ...valid, timestampsMs: [253402300800000n] }, + { ...valid, timestampsMs: Array(129).fill(0n) }, + { ...valid, timestampsMs: [253402300799999n], timeZone: "Pacific/Kiritimati" }, + ]) { + await expect(localizeTimestamps(request)).rejects.toBeInstanceOf(RangeError); + } + }); +}); diff --git a/js/packages/truapi-host/src/locale.ts b/js/packages/truapi-host/src/locale.ts new file mode 100644 index 000000000..75576468e --- /dev/null +++ b/js/packages/truapi-host/src/locale.ts @@ -0,0 +1,59 @@ +import type { LocaleHost } from "./generated/host-callbacks.js"; + +/** Localize each instant using the requested zone's historical offset and DST. */ +export const localizeTimestamps: NonNullable = async ( + request, +) => { + if (!request.languageTag.trim() || !request.timeZone.trim()) { + throw new RangeError("A language tag and time zone are required"); + } + if ( + request.timestampsMs.length > 128 || + request.timestampsMs.some( + (timestamp) => timestamp < 0n || timestamp > 253402300799999n, + ) + ) { + throw new RangeError("Timestamp batch or instant is out of range"); + } + const { languageTag, timeZone } = request; + const localDate = new Intl.DateTimeFormat("en-US", { + calendar: "gregory", + numberingSystem: "latn", + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const time = new Intl.DateTimeFormat(languageTag, { + timeZone, + hour: "numeric", + minute: "2-digit", + }); + const date = new Intl.DateTimeFormat(languageTag, { + timeZone, + dateStyle: "long", + }); + const dateTime = new Intl.DateTimeFormat(languageTag, { + timeZone, + dateStyle: "full", + timeStyle: "long", + }); + return { + timestamps: request.timestampsMs.map((timestamp) => { + const instant = Number(timestamp); + const parts = localDate.formatToParts(instant); + const year = parts.find((part) => part.type === "year")!.value; + const month = parts.find((part) => part.type === "month")!.value; + const day = parts.find((part) => part.type === "day")!.value; + if (year.length > 4) { + throw new RangeError("Local date is outside the four-digit year range"); + } + return { + localDate: `${year.padStart(4, "0")}-${month}-${day}`, + time: time.format(instant), + date: date.format(instant), + dateTime: dateTime.format(instant), + }; + }), + }; +}; diff --git a/js/packages/truapi-host/src/test-support.ts b/js/packages/truapi-host/src/test-support.ts index 3cc461098..9aee31201 100644 --- a/js/packages/truapi-host/src/test-support.ts +++ b/js/packages/truapi-host/src/test-support.ts @@ -1,4 +1,5 @@ import type { RequiredHostCallbacks } from "./generated/host-callbacks.js"; +import { localizeTimestamps } from "./locale.js"; /** `HostCallbacks` with every optional member required, for exhaustive test fixtures. */ export type CompleteHostCallbacks = RequiredHostCallbacks; @@ -53,7 +54,7 @@ export function makeHostCallbacks( async *lookupPreimage() {}, }, theme: { async *subscribeTheme() {} }, - locale: { async *subscribeLocale() {} }, + locale: { async *subscribeLocale() {}, localizeTimestamps }, chain: { connect: async () => ({ send() {}, diff --git a/js/packages/truapi-host/src/testing/host-page.ts b/js/packages/truapi-host/src/testing/host-page.ts index 0a46b2173..f78f4b096 100644 --- a/js/packages/truapi-host/src/testing/host-page.ts +++ b/js/packages/truapi-host/src/testing/host-page.ts @@ -26,7 +26,7 @@ import { DerivationIndex } from "@parity/truapi"; import { createIframeHost } from "../web/create-iframe-host.js"; -import { createWebWorkerPairingHostRuntime } from "../web/create-worker-host-runtime.js"; +import { createWebWorkerSigningHostRuntime } from "../web/create-worker-host-runtime.js"; import { createMockHost, mockRuntimeConfig, @@ -258,7 +258,7 @@ export async function startTestHost( worker = new Worker(options.workerUrl ?? "/test-host-worker.js", { type: "module", }); - workerRuntime = (await createWebWorkerPairingHostRuntime( + workerRuntime = (await createWebWorkerSigningHostRuntime( worker, host.callbacks, { hostConfig: hostConfig as never, role: "signing" }, diff --git a/js/packages/truapi-host/src/testing/wasm-bundle.test.ts b/js/packages/truapi-host/src/testing/wasm-bundle.test.ts index 0f217d4d3..c1db2f7b3 100644 --- a/js/packages/truapi-host/src/testing/wasm-bundle.test.ts +++ b/js/packages/truapi-host/src/testing/wasm-bundle.test.ts @@ -1,12 +1,7 @@ -// The testing WASM bundle exists to carry a signing host: dev accounts sign -// locally instead of waiting on a wallet that is not there. That depends on a -// build flag (`--features wasm-signing-host` in `scripts/build-wasm.mjs`), -// and a flag is exactly the kind of thing that gets dropped in a refactor -// without anything failing — the bundle would still build, still load, and -// simply have no signing host in it. +// Precompressed WASM sidecars belong to host deployments, not SDK archives. import { describe, expect, it } from "bun:test"; import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -15,16 +10,9 @@ import { wasmIsBuilt } from "./require-wasm.js"; const packageRoot = dirname( fileURLToPath(new URL("../../package.json", import.meta.url)), ); -const testingGlue = fileURLToPath( - new URL("../../dist/wasm/testing/truapi_server.d.ts", import.meta.url), -); -const webGlue = fileURLToPath( - new URL("../../dist/wasm/web/truapi_server.d.ts", import.meta.url), -); - const suite = wasmIsBuilt( - "testing/truapi_server.d.ts", - "web/truapi_server.d.ts", + "testing/truapi_server_bg.wasm", + "web/truapi_server_bg.wasm", ) ? describe : describe.skip; @@ -37,33 +25,6 @@ const sidecarsBuilt = existsSync( : it.skip; suite("testing wasm bundle", () => { - it("carries a signing host", () => { - expect(readFileSync(testingGlue, "utf8")).toContain( - "export class WasmSigningHostRuntime", - ); - }); - - it("is the only bundle that does", () => { - // The production browser host pairs with a wallet and must not ship a - // key-holding runtime; that separation is the reason for two bundles. - expect(readFileSync(webGlue, "utf8")).not.toContain( - "export class WasmSigningHostRuntime", - ); - }); - - it("is the only bundle that can answer allocation as granted", () => { - // `setGrantAllowancesUnchecked` hands a product a grant nothing allocated. - // It is gated on the non-default `test-host` Cargo feature, which only - // `scripts/build-wasm.mjs` turns on and only for this bundle, so a shipping - // host has no entry point to it at all. - expect(readFileSync(testingGlue, "utf8")).toContain( - "setGrantAllowancesUnchecked", - ); - expect(readFileSync(webGlue, "utf8")).not.toContain( - "setGrantAllowancesUnchecked", - ); - }); - // Only a release build writes the sidecars, so on a dev-profile `dist` there // is nothing for the exclusion to exclude and a green result would prove // nothing. Skipping says that; passing would not. diff --git a/js/packages/truapi-host/src/wallet-allowances.test.ts b/js/packages/truapi-host/src/wallet-allowances.test.ts new file mode 100644 index 000000000..a74802eab --- /dev/null +++ b/js/packages/truapi-host/src/wallet-allowances.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "bun:test"; +import { + validateWalletAllowanceSnapshot, + type WalletAllowanceSnapshot, +} from "./wallet-allowances.js"; + +const account = `0x${"11".repeat(32)}`; +const observation = { + genesisHash: `0x${"22".repeat(32)}`, + blockHash: `0x${"33".repeat(32)}`, + blockNumber: 42, + specVersion: 1, + chainTimestamp: 1_800_000_000, +}; +function snapshot(): WalletAllowanceSnapshot { + return { + schemaVersion: 1, + identityAccountId: account, + networkSuffix: "paseo", + productIds: ["chat.dot"], + statementStore: { + status: "available", + observation, + value: { + period: 4, + resetsAt: 1_800_100_000, + graceSeconds: 30, + replacementCooldownSeconds: 60, + pools: [ + { + collection: "LitePeople", + membership: "verified", + selected: true, + limit: 2, + used: 1, + remaining: 1, + slots: [{ index: 0, accountId: account }], + }, + ], + }, + }, + pgasClaims: { status: "unavailable", reason: "chain unavailable" }, + pgasBalances: { + status: "available", + observation, + value: { + assetId: "1984", + decimals: null, + symbol: null, + accounts: [ + { + productId: "chat.dot", + accountId: account, + derivationIndex: 0, + balance: "9007199254740993123456", + }, + ], + }, + }, + bulletinClaims: { status: "unavailable", reason: "chain unavailable" }, + bulletinQuotas: { status: "unavailable", reason: "chain unavailable" }, + }; +} + +describe("native wallet allowance snapshot validation", () => { + it("rejects malformed capacity instead of exposing a fabricated free slot", () => { + const value = snapshot(); + if (value.statementStore.status !== "available") throw new Error("fixture"); + value.statementStore.value.pools[0]!.remaining = 2; + expect(() => + validateWalletAllowanceSnapshot(value, account, "paseo", ["chat.dot"]), + ).toThrow(); + }); + + it("never treats a nonmember policy limit as usable capacity", () => { + const value = snapshot(); + if (value.statementStore.status !== "available") throw new Error("fixture"); + const pool = value.statementStore.value.pools[0]!; + pool.membership = "not-found"; + pool.selected = false; + pool.remaining = 0; + validateWalletAllowanceSnapshot(value, account, "paseo", ["chat.dot"]); + pool.remaining = pool.limit - pool.used; + expect(() => + validateWalletAllowanceSnapshot(value, account, "paseo", ["chat.dot"]), + ).toThrow(); + pool.remaining = 0; + pool.selected = true; + expect(() => + validateWalletAllowanceSnapshot(value, account, "paseo", ["chat.dot"]), + ).toThrow(); + }); + + it("rejects imprecise numbers and missing chain provenance", () => { + const value = snapshot(); + if (value.statementStore.status !== "available") throw new Error("fixture"); + value.statementStore.observation = { + ...observation, + blockNumber: Number.MAX_SAFE_INTEGER + 1, + }; + expect(() => + validateWalletAllowanceSnapshot(value, account, "paseo", ["chat.dot"]), + ).toThrow(); + value.statementStore.observation = { ...observation, blockHash: "" }; + expect(() => + validateWalletAllowanceSnapshot(value, account, "paseo", ["chat.dot"]), + ).toThrow(); + }); + + it("requires separate People provenance for Asset Hub claim eligibility", () => { + const value = snapshot(); + value.pgasClaims = { + status: "available", + observation, + value: { + period: 4, + resetsAt: 1_800_100_000, + pools: [], + assetId: "1984", + claimAmount: "100", + membershipObservation: { ...observation, genesisHash: account }, + }, + }; + validateWalletAllowanceSnapshot(value, account, "paseo", ["chat.dot"]); + value.pgasClaims.value.membershipObservation.blockHash = ""; + expect(() => + validateWalletAllowanceSnapshot(value, account, "paseo", ["chat.dot"]), + ).toThrow(); + }); + + it("rejects partial account batches instead of treating omissions as zero", () => { + const value = snapshot(); + if (value.pgasBalances.status !== "available") throw new Error("fixture"); + value.pgasBalances.value.accounts = []; + expect(() => + validateWalletAllowanceSnapshot(value, account, "paseo", ["chat.dot"]), + ).toThrow(); + }); + + it("rejects lossy numeric balances and unexplained null balances", () => { + const value = snapshot(); + validateWalletAllowanceSnapshot(value, account, "paseo", ["chat.dot"]); + if (value.pgasBalances.status !== "available") throw new Error("fixture"); + const numericBalance = { + ...value, + pgasBalances: { + ...value.pgasBalances, + value: { + ...value.pgasBalances.value, + accounts: [ + { + ...value.pgasBalances.value.accounts[0], + balance: 9007199254740992, + }, + ], + }, + }, + }; + expect(() => + validateWalletAllowanceSnapshot(numericBalance, account, "paseo", [ + "chat.dot", + ]), + ).toThrow(); + value.pgasBalances.value.accounts[0]!.balance = null; + expect(() => + validateWalletAllowanceSnapshot(value, account, "paseo", ["chat.dot"]), + ).toThrow(); + value.pgasBalances.value.accounts[0]!.error = "RPC unavailable"; + validateWalletAllowanceSnapshot(value, account, "paseo", ["chat.dot"]); + }); + + it("rejects snapshots from another wallet, network, schema or product scope", () => { + const value = snapshot(); + expect(() => + validateWalletAllowanceSnapshot(value, `0x${"44".repeat(32)}`, "paseo", [ + "chat.dot", + ]), + ).toThrow(); + expect(() => + validateWalletAllowanceSnapshot(value, account, "dot", ["chat.dot"]), + ).toThrow(); + expect(() => + validateWalletAllowanceSnapshot(value, account, "paseo", ["other.dot"]), + ).toThrow(); + expect(() => + validateWalletAllowanceSnapshot( + { ...value, schemaVersion: 2 }, + account, + "paseo", + ["chat.dot"], + ), + ).toThrow(); + }); +}); diff --git a/js/packages/truapi-host/src/wallet-allowances.ts b/js/packages/truapi-host/src/wallet-allowances.ts new file mode 100644 index 000000000..f29cb8294 --- /dev/null +++ b/js/packages/truapi-host/src/wallet-allowances.ts @@ -0,0 +1,285 @@ +export type AllowanceCollection = "People" | "LitePeople"; +export interface AllowanceObservation { + genesisHash: string; + blockHash: string; + blockNumber: number; + specVersion: number; + /** Unix seconds from the pinned block. */ + chainTimestamp: number; +} +export type AllowanceSection = + | { status: "available"; observation: AllowanceObservation; value: T } + | { status: "unavailable"; reason: string }; +export interface AllowanceSlot { + index: number; + accountId?: string; + productId?: string; + label?: string; + since?: number; +} +export interface AllowancePool { + collection: AllowanceCollection; + membership: "verified" | "not-found"; + selected: boolean; + limit: number; + used: number; + remaining: number; + slots: AllowanceSlot[]; +} +export interface AllowanceClaims { + period: number; + resetsAt: number; + pools: AllowancePool[]; +} +export interface StatementAllowanceSnapshot extends AllowanceClaims { + graceSeconds: number; + replacementCooldownSeconds: number; +} +export interface PgasClaimsSnapshot extends AllowanceClaims { + /** Membership is observed on People, separately from Asset Hub claim storage. */ + membershipObservation: AllowanceObservation; + assetId: string; + /** Integer base units, never a floating-point amount. */ + claimAmount: string; +} +export interface PgasBalancesSnapshot { + assetId: string; + decimals: number | null; + symbol: string | null; + accounts: Array<{ + productId: string; + accountId: string; + derivationIndex: 0; + /** Total balance, not a spendability promise. */ + balance: string | null; + error?: string; + }>; +} +export interface BulletinQuota { + productId: string; + accountId: string; + status: "active" | "expired" | "missing" | "unavailable"; + bytesUsed?: string; + bytesLimit?: string; + bytesRemaining?: string; + transactionsUsed?: number; + transactionsLimit?: number; + transactionsRemaining?: number; + expiresAtBlock?: number; + error?: string; +} +export interface WalletAllowanceSnapshot { + schemaVersion: 1; + identityAccountId: string; + networkSuffix: string; + productIds: string[]; + statementStore: AllowanceSection; + pgasClaims: AllowanceSection; + pgasBalances: AllowanceSection; + bulletinClaims: AllowanceSection; + bulletinQuotas: AllowanceSection<{ accounts: BulletinQuota[] }>; +} + +/** Bound trusted shell hints before crossing the worker boundary. Native code + * applies the canonical product-ID rules before deriving or querying accounts. */ +export function validateAllowanceProductIds(value: unknown): string[] { + if ( + !Array.isArray(value) || + value.length > 32 || + new Set(value).size !== value.length + ) { + throw new Error( + "allowance inspection requires at most 32 unique nonempty product IDs", + ); + } + for (const id of value) { + if (typeof id !== "string" || id.trim().length === 0) { + throw new Error("allowance inspection requires nonempty product IDs"); + } + } + return [...value]; +} + +function requireValid(condition: unknown): asserts condition { + if (!condition) throw new Error("invalid wallet allowance snapshot"); +} +function record(value: unknown): Record { + requireValid( + typeof value === "object" && value !== null && !Array.isArray(value), + ); + return value as Record; +} +function text(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} +function uint(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} +function units(value: unknown): value is string { + return typeof value === "string" && /^(0|[1-9][0-9]*)$/.test(value); +} +function hash(value: unknown): value is string { + return typeof value === "string" && /^0x[0-9a-f]{64}$/.test(value); +} +function array(value: unknown): unknown[] { + requireValid(Array.isArray(value)); + return value; +} +function claims(value: Record): void { + requireValid(uint(value.period) && uint(value.resetsAt)); + const collections = new Set(); + for (const item of array(value.pools)) { + const pool = record(item); + requireValid( + (pool.collection === "People" || pool.collection === "LitePeople") && + !collections.has(pool.collection), + ); + collections.add(pool.collection); + requireValid( + (pool.membership === "verified" || pool.membership === "not-found") && + typeof pool.selected === "boolean" && + uint(pool.limit) && + uint(pool.used) && + uint(pool.remaining) && + (pool.membership === "not-found" + ? pool.remaining === 0 && pool.selected === false + : pool.remaining === Math.max(0, pool.limit - pool.used)), + ); + const indices = new Set(); + for (const entry of array(pool.slots)) { + const slot = record(entry); + requireValid( + uint(slot.index) && + !indices.has(slot.index) && + (slot.accountId === undefined || hash(slot.accountId)) && + (slot.productId === undefined || text(slot.productId)) && + (slot.label === undefined || text(slot.label)) && + (slot.since === undefined || uint(slot.since)), + ); + indices.add(slot.index); + } + } +} +function observation(value: unknown): void { + const item = record(value); + requireValid( + hash(item.genesisHash) && + hash(item.blockHash) && + uint(item.blockNumber) && + uint(item.specVersion) && + uint(item.chainTimestamp), + ); +} +function section( + value: unknown, + validate: (value: Record) => void, +): void { + const item = record(value); + if (item.status === "unavailable") { + requireValid(text(item.reason)); + return; + } + requireValid(item.status === "available"); + observation(item.observation); + validate(record(item.value)); +} + +/** Validate once at the native JSON boundary, before treating an observation as + * current wallet data. Malformed data is rejected, never converted to capacity. */ +export function validateWalletAllowanceSnapshot( + value: unknown, + identityAccountId: string, + networkSuffix: string, + productIds: readonly string[], +): asserts value is WalletAllowanceSnapshot { + const snapshot = record(value); + requireValid( + snapshot.schemaVersion === 1 && + hash(snapshot.identityAccountId) && + snapshot.identityAccountId === identityAccountId && + snapshot.networkSuffix === networkSuffix, + ); + const requested = new Set(productIds); + const actual = validateAllowanceProductIds(snapshot.productIds); + requireValid( + actual.length === requested.size && actual.every((id) => requested.has(id)), + ); + const accounts = ( + value: unknown, + validate: (account: Record) => void, + ) => { + const seen = new Set(); + for (const entry of array(value)) { + const account = record(entry); + requireValid( + text(account.productId) && + requested.has(account.productId) && + !seen.has(account.productId) && + hash(account.accountId), + ); + seen.add(account.productId); + validate(account); + } + requireValid(seen.size === requested.size); + }; + section(snapshot.statementStore, (value) => { + claims(value); + requireValid( + uint(value.graceSeconds) && uint(value.replacementCooldownSeconds), + ); + }); + section(snapshot.pgasClaims, (value) => { + claims(value); + observation(value.membershipObservation); + requireValid(text(value.assetId) && units(value.claimAmount)); + }); + section(snapshot.pgasBalances, (value) => { + requireValid( + text(value.assetId) && + (value.decimals === null || uint(value.decimals)) && + (value.symbol === null || text(value.symbol)), + ); + accounts(value.accounts, (account) => { + requireValid( + account.derivationIndex === 0 && + (units(account.balance) || + (account.balance === null && text(account.error))) && + (account.error === undefined || text(account.error)), + ); + }); + }); + section(snapshot.bulletinClaims, claims); + section(snapshot.bulletinQuotas, (value) => + accounts(value.accounts, (account) => { + requireValid( + ["active", "expired", "missing", "unavailable"].includes( + String(account.status), + ), + ); + for (const key of ["bytesUsed", "bytesLimit", "bytesRemaining"]) { + requireValid(account[key] === undefined || units(account[key])); + } + for (const key of [ + "transactionsUsed", + "transactionsLimit", + "transactionsRemaining", + "expiresAtBlock", + ]) { + requireValid(account[key] === undefined || uint(account[key])); + } + requireValid(account.error === undefined || text(account.error)); + if (account.status === "unavailable") requireValid(text(account.error)); + if (account.status === "active" || account.status === "expired") { + requireValid( + units(account.bytesUsed) && + units(account.bytesLimit) && + units(account.bytesRemaining) && + uint(account.transactionsUsed) && + uint(account.transactionsLimit) && + uint(account.transactionsRemaining) && + uint(account.expiresAtBlock), + ); + } + }), + ); +} diff --git a/js/packages/truapi-host/src/wasm-module.ts b/js/packages/truapi-host/src/wasm-module.ts index 395a9f2e9..1267c53ca 100644 --- a/js/packages/truapi-host/src/wasm-module.ts +++ b/js/packages/truapi-host/src/wasm-module.ts @@ -4,6 +4,8 @@ // these interfaces so the worker can name it in a statically analysable import. import type { PermissionAuthorizationRuntime } from "./worker-permission-authorization.js"; +import type { LocalIdentity } from "./worker-protocol.js"; +import type { WalletAllowanceSnapshot } from "./wallet-allowances.js"; /** Cancellable handle on one live render stream inside the core. */ export interface WorkerRendererSubscription { @@ -45,15 +47,14 @@ export interface WorkerProductRuntime { /** What the host does with a product's worker after demand on it changed. */ export type WorkerTransition = "Start" | "Stop"; -/** The long-lived pairing-host runtime product cores are created from. */ -export interface WorkerPairingHostRuntime extends PermissionAuthorizationRuntime { +/** Runtime operations shared by paired and browser-local signing hosts. */ +export interface WorkerHostRuntime extends PermissionAuthorizationRuntime { productRuntime( product: unknown, coreCallbacks: unknown, + platformCallbacks?: unknown, ): WorkerProductRuntime; disconnectSession(): Promise; - cancelPairing(): void; - notifySessionStoreChanged(): void; notifyContactsChanged(): void; sessionChatIdentityKey(): Uint8Array | undefined; deviceStatementKey(): Uint8Array | undefined; @@ -62,9 +63,7 @@ export interface WorkerPairingHostRuntime extends PermissionAuthorizationRuntime productId: string, timeoutMs?: number, ): Promise; - activateStoredSession(): Promise; - activateExternalSession(blob: Uint8Array): Promise; - resetSessionState(): Promise; + clearProductState(productId: string): Promise; /** * Take one reference on the product's worker. The first one reports * `"Start"` through the runtime's `workerDemandChanged` callback. @@ -77,28 +76,52 @@ export interface WorkerPairingHostRuntime extends PermissionAuthorizationRuntime free(): void; } +/** The long-lived pairing-host runtime product cores are created from. */ +export interface WorkerPairingHostRuntime extends WorkerHostRuntime { + cancelPairing(): void; + notifySessionStoreChanged(): void; + activateStoredSession(): Promise; + activateExternalSession(blob: Uint8Array): Promise; + resetSessionState(): Promise; +} + /** - * The signing-host runtime, present only in the `testing` WASM bundle. + * A browser-local signing host activated from caller-owned entropy. * * A signing host owns the user's keys and establishes sessions from local - * entropy rather than by pairing with a wallet. The production `web` bundle is - * built without it on purpose, so this is optional on the module surface. + * entropy rather than by pairing with a wallet. It is present only in a core + * built with `wasm-signing-host`; the production `web` bundle is built without + * it, which is why the constructor is optional on {@link WasmModuleShape}. */ -export interface WorkerSigningHostRuntime extends WorkerPairingHostRuntime { +export interface WorkerSigningHostRuntime extends WorkerHostRuntime { activateLocalSession(secret: Uint8Array): Promise; /** * Activate and give the session a display name, which is what - * `account.get_user_id` answers with. Optional: a core built before this - * entry point existed exposes only {@link activateLocalSession}. + * `account.get_user_id` answers with. */ + activateLocalSessionWithIdentity( + secret: Uint8Array, + liteUsername?: string, + ): Promise; /** Only on a core built with `wasm-signing-host`. */ setGrantAllowancesUnchecked?(granted: boolean): void; + localIdentityContext(): { activationId: string; identityAccountId: string }; + localIdentityAuthProof( + activationId: string, + challenge: Uint8Array, + ): Uint8Array; + localLiteRegistrationBody( + activationId: string, + usernameBase: string, + verifier: Uint8Array, + ): Promise; + refreshLocalIdentity(activationId: string): Promise; + getWalletAllowanceSnapshot( + activationId: string, + productIds: string[], + ): Promise; /** Only on a core built with `wasm-signing-host`. */ setWithheldResources?(tags: string[]): void; - activateLocalSessionWithIdentity?( - secret: Uint8Array, - liteUsername?: string | null, - ): Promise; } /** Module surface the wasm-pack glue exports. */ diff --git a/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts b/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts index 3a55fad2e..249b2b6f9 100644 --- a/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts +++ b/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts @@ -11,6 +11,7 @@ declare const init: WasmModuleShape["default"]; export default init; export const WasmPairingHostRuntime: WasmModuleShape["WasmPairingHostRuntime"]; +export const WasmSigningHostRuntime: WasmModuleShape["WasmSigningHostRuntime"]; export const WasmProductRuntime: WasmModuleShape["WasmProductRuntime"]; export const setLogLevel: (level: string) => void; export const deriveProductAccountPublicKey: WasmModuleShape["deriveProductAccountPublicKey"]; diff --git a/js/packages/truapi-host/src/web/create-mock-host.ts b/js/packages/truapi-host/src/web/create-mock-host.ts index 2cbabed5a..0b245b219 100644 --- a/js/packages/truapi-host/src/web/create-mock-host.ts +++ b/js/packages/truapi-host/src/web/create-mock-host.ts @@ -48,6 +48,7 @@ import type { UserConfirmationReview, } from "../generated/host-callbacks.js"; import type { ProductRuntimeConfig } from "../runtime.js"; +import { localizeTimestamps } from "../locale.js"; /** How the mock answers a permission prompt for one capability. */ export type PermissionPolicy = "allow-all" | "deny-all"; @@ -397,6 +398,8 @@ export interface MockHostConfig { theme?: ThemeVariant; /** BCP 47 tag emitted by `subscribeLocale`. Default `"en"`. */ languageTag?: string; + /** IANA time zone emitted by `subscribeLocale`. Default is the host zone. */ + timeZone?: string; /** Whether `confirmUserAction` confirms reviewed actions. Default `true`. */ confirmUserActions?: boolean; /** @@ -844,6 +847,7 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { chainClosed = false, chainProxies = [], languageTag = "en", + timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone, faults = {}, supportedChains = { network: "mock", @@ -1352,13 +1356,14 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { }, locale: { - async *subscribeLocale(): AsyncGenerator< - Result - > { - yield ok({ languageTag }); - // A live subscription never ends, matching `subscribeTheme`. - await new Promise(() => {}); + subscribeLocale() { + return liveSubscription( + { languageTag, timeZone }, + subscriptionClosers, + () => () => {}, + ); }, + localizeTimestamps, }, preimage: { diff --git a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts index c6dd51acd..5f94e4916 100644 --- a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts +++ b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts @@ -33,6 +33,8 @@ import { isLoopbackWsUrl } from "../worker-protocol.js"; import type { CallbackName, HostRole, + LocalIdentity, + LocalIdentityProgress, MainToWorker, SubscriptionName, WorkerToMain, @@ -40,11 +42,19 @@ import type { import { bytesToHex } from "@parity/truapi/scale"; import { startRawSubscription } from "../generated/worker-callbacks.js"; import { errorMessage, toError } from "../error.js"; +import { + validateAllowanceProductIds, + type WalletAllowanceSnapshot, +} from "../wallet-allowances.js"; export type WebWorkerHostConfig = Omit< ProductRuntimeConfig, "productId" | "executionKind" >; +export type WebWorkerSigningHostConfig = WebWorkerHostConfig & { + /** Bare dotNS network suffix (`dot`, `paseo`, or `testnet`). */ + networkSuffix: string; +}; export interface WorkerPairingHostRuntime { /** @@ -57,10 +67,13 @@ export interface WorkerPairingHostRuntime { * should. Undefined for a core built before the export existed. */ readonly coreWireSchemaHash: string | undefined; - createProvider(product: { - productId: string; - executionKind?: ProductExecutionKind; - }): Promise; + createProvider( + product: { + productId: string; + executionKind?: ProductExecutionKind; + }, + callbacks?: WebWorkerHostCallbacks, + ): Promise; disconnectSession(): Promise; cancelPairing(): void; notifySessionStoreChanged(): void; @@ -88,7 +101,10 @@ export interface WorkerPairingHostRuntime { * Signing hosts only. A pairing host has no local secret and rejects this: * it waits for a wallet to answer over the statement-store channel instead. */ - activateLocalSession(secret: Uint8Array, liteUsername?: string): Promise; + activateLocalSession( + secret: Uint8Array, + liteUsername?: string, + ): Promise; setGrantAllowancesUnchecked(granted: boolean): Promise; /** * Answer these resource tags as refused, replacing any earlier set. @@ -146,6 +162,32 @@ export interface WorkerPairingHostRuntime { setLogLevel(level: LogLevel): void; dispose(): void; } +export interface WorkerSigningHostRuntime extends Omit< + WorkerPairingHostRuntime, + | "cancelPairing" + | "notifySessionStoreChanged" + | "activateStoredSession" + | "activateExternalSession" + | "resetSessionState" +> { + activateLocalSession(secret: Uint8Array): Promise; + activateLocalSessionWithIdentity( + secret: Uint8Array, + liteUsername?: string, + ): Promise; + /** Read dotNS ownership and install verified metadata into the native session. */ + refreshLocalIdentity(): Promise; + /** Read-only wallet-wide inspection bound to the native local activation. */ + getWalletAllowanceSnapshot( + productIds: string[], + ): Promise; + /** Complete native UID auth/proofs and wait for on-chain ownership confirmation. */ + registerLocalLiteUsername( + baseUsername: string, + identityBackendBaseUrl: string, + onProgress?: (progress: LocalIdentityProgress) => void, + ): Promise; +} interface CoreState { coreId: number; @@ -169,7 +211,13 @@ interface RenderEntry { interface RuntimeState { worker: Worker; + role: HostRole; + networkSuffix: string | undefined; + identityAccountId: string | null; + identityGeneration: number; + pendingAllowanceSnapshots: Map>; rawCallbacks: RawCallbacks; + coreCallbacks: Map; cores: Map; pendingCores: Map< number, @@ -182,9 +230,9 @@ interface RuntimeState { subscriptionDisposers: Map void>; /** * Open `worker.beginOperation` holds. A non-empty set defers `dispose()`. - * Worker-wide rather than per-core, since a `callbackRequest` carries no core - * id, so entries are product-scoped: `OperationId` is only unique per product - * and two products sharing this worker may be handed the same id. + * Worker-wide rather than per-core: operation holds can outlive a product + * connection. Entries are product-scoped because `OperationId` is only + * unique per product and products sharing this worker may use the same id. */ openOperations: Set; /** A dispose() arrived while operations were open; run it once they drain. */ @@ -207,6 +255,12 @@ interface RuntimeState { number, { resolve: () => void; reject: (error: Error) => void } >; + pendingLocalIdentities: Map< + number, + PendingEntry & { + onProgress?: (progress: LocalIdentityProgress) => void; + } + >; pendingPermissionAuthorizationStatuses: Map< number, { @@ -278,6 +332,8 @@ let nextDeviceStatementKeyRequestId = 0; let nextDeviceEncryptionKeyRequestId = 0; let nextProductSubtreePublicKeyRequestId = 0; let nextSessionActivationRequestId = 0; +let nextLocalIdentityRequestId = 0; +let nextAllowanceSnapshotRequestId = 0; let nextActionRequestId = 0; let nextRenderId = 0; @@ -514,6 +570,12 @@ function operationIdFrom(value: unknown): number | null { } } +/** + * Key one pending-operation hold. `OperationId` is unique per product, not per + * worker, so the product a `beginOperation`/`endOperation` arrived for has to be + * part of the key. Returns null if the encoded product will not decode, which + * drops the hold rather than letting it pin the worker forever. + */ function operationHold(encodedProduct: unknown, id: number): string | null { if (!(encodedProduct instanceof Uint8Array)) return null; try { @@ -527,18 +589,25 @@ function handleCallbackRequest( state: RuntimeState, msg: { requestId: number; + coreId?: number; name: CallbackName; args: readonly unknown[]; }, ): void { - const fn = Object.hasOwn(state.rawCallbacks, msg.name) - ? ( - state.rawCallbacks as unknown as Record< - string, - (...args: readonly unknown[]) => unknown - > - )[msg.name] - : undefined; + if (state.disposed) return; + const callbacks = + msg.coreId === undefined + ? state.rawCallbacks + : state.coreCallbacks.get(msg.coreId); + const fn = + callbacks && Object.hasOwn(callbacks, msg.name) + ? ( + callbacks as unknown as Record< + string, + (...args: readonly unknown[]) => unknown + > + )[msg.name] + : undefined; if (!fn) { state.worker.postMessage({ kind: "callbackResponse", @@ -549,7 +618,12 @@ function handleCallbackRequest( return; } Promise.resolve() - .then(() => fn(...msg.args)) + .then(() => { + if (state.disposed) throw new Error("Host runtime is unavailable"); + if (msg.coreId !== undefined && !state.coreCallbacks.has(msg.coreId)) + throw new Error("Product callbacks are unavailable"); + return fn(...msg.args); + }) .then( (value) => { // Tracked in the success arm only: a rejected begin must not leave a @@ -591,6 +665,7 @@ function handleSubscriptionStart( state: RuntimeState, msg: { subId: number; + coreId?: number; name: SubscriptionName; payload: Uint8Array | string | null; }, @@ -613,15 +688,20 @@ function handleSubscriptionStart( }; let dispose: (() => void) | void = undefined; try { + const callbacks = + msg.coreId === undefined + ? state.rawCallbacks + : state.coreCallbacks.get(msg.coreId); + if (!callbacks) throw new Error("Product callbacks are unavailable"); dispose = startRawSubscription( - state.rawCallbacks, + callbacks, msg.name, msg.payload, sendItem, sendError, ); } catch (err) { - console.error(`[truapi worker] ${msg.name} threw on start:`, err); + sendError({ reason: errorMessage(err) }); return; } if (typeof dispose === "function") { @@ -864,6 +944,8 @@ function handleDeviceEncryptionKeyResponse( function rejectPendingRuntimeRequests(state: RuntimeState, error: Error): void { rejectAll(state.pendingDisconnects, error); rejectAll(state.pendingSessionActivations, error); + rejectAll(state.pendingLocalIdentities, error); + rejectAll(state.pendingAllowanceSnapshots, error); rejectAll(state.pendingPermissionAuthorizationStatuses, error); rejectAll(state.pendingPermissionAuthorizationStatusBatches, error); rejectAll(state.pendingSetPermissionAuthorizationStatuses, error); @@ -911,10 +993,12 @@ function sendWorkerRequest( function sendSessionActivationRequest( state: RuntimeState, buildMessage: (requestId: number) => MainToWorker, + changesIdentity = true, ): Promise { if (state.disposed) { return Promise.reject(state.closedError ?? new Error("runtime disposed")); } + if (changesIdentity) invalidateAllowanceIdentity(state); return sendWorkerRequest( state, state.pendingSessionActivations, @@ -924,6 +1008,111 @@ function sendSessionActivationRequest( ); } +function sendLocalIdentityRequest( + state: RuntimeState, + buildMessage: (requestId: number) => MainToWorker, + onProgress?: (progress: LocalIdentityProgress) => void, +): Promise { + if (state.disposed) { + return Promise.reject(state.closedError ?? new Error("runtime disposed")); + } + const generation = state.identityGeneration; + const { promise, resolve, reject } = Promise.withResolvers(); + const requestId = ++nextLocalIdentityRequestId; + state.pendingLocalIdentities.set(requestId, { + resolve(identity) { + if (generation !== state.identityGeneration || state.disposePending) { + reject(new Error("local identity activation changed")); + return; + } + state.identityAccountId = identity.identityAccountId; + resolve(identity); + }, + reject, + onProgress, + }); + try { + state.worker.postMessage(buildMessage(requestId)); + } catch (error) { + state.pendingLocalIdentities.delete(requestId); + reject(error); + } + return promise; +} + +function invalidateAllowanceIdentity(state: RuntimeState): void { + state.identityGeneration++; + state.identityAccountId = null; + rejectAll( + state.pendingAllowanceSnapshots, + new Error("local identity activation changed"), + ); +} + +async function getWalletAllowanceSnapshot( + state: RuntimeState, + input: string[], +): Promise { + if (state.disposed || state.disposePending) { + throw state.closedError ?? new Error("runtime disposed"); + } + if ( + state.role !== "signing" || + state.pendingSessionActivations.size > 0 || + state.pendingDisconnects.size > 0 + ) { + throw new Error( + "allowance inspection requires a current local signing identity", + ); + } + const productIds = validateAllowanceProductIds(input); + const accountId = state.identityAccountId; + const generation = state.identityGeneration; + const requestId = ++nextAllowanceSnapshotRequestId; + const { promise, resolve, reject } = + Promise.withResolvers(); + const timeout = setTimeout(() => { + state.pendingAllowanceSnapshots.delete(requestId); + reject(new Error("wallet allowance inspection timed out after 30000ms")); + }, 30_000); + state.pendingAllowanceSnapshots.set(requestId, { + resolve(snapshot) { + clearTimeout(timeout); + if ( + generation !== state.identityGeneration || + snapshot?.schemaVersion !== 1 || + (accountId !== null && snapshot.identityAccountId !== accountId) || + snapshot.networkSuffix !== state.networkSuffix + ) { + reject( + new Error( + "wallet allowance snapshot does not match the current identity", + ), + ); + return; + } + resolve(snapshot); + }, + reject(error) { + clearTimeout(timeout); + reject(error); + }, + }); + try { + state.worker.postMessage({ + kind: "getWalletAllowanceSnapshot", + requestId, + productIds, + } satisfies MainToWorker); + } catch (error) { + settlePending(state.pendingAllowanceSnapshots, requestId, { + ok: false, + error: errorMessage(error), + }); + } + return promise; +} + function closeCoreState(core: CoreState, error: Error): void { if (core.disposed) return; core.disposed = true; @@ -950,6 +1139,7 @@ function teardown(state: RuntimeState, error: Error, fault: boolean): void { closeCoreState(core, error); } state.cores.clear(); + state.coreCallbacks.clear(); for (const fn of state.subscriptionDisposers.values()) { try { fn(); @@ -984,9 +1174,13 @@ function teardown(state: RuntimeState, error: Error, fault: boolean): void { } } -export interface CreateWebWorkerPairingHostRuntimeOptions { +interface CreateWebWorkerHostRuntimeOptions { logLevel?: LogLevel; - hostConfig: WebWorkerHostConfig; + hostConfig: WebWorkerHostConfig | WebWorkerSigningHostConfig; + /** + * Maximum inactivity during each worker startup phase. Loading the WASM and + * constructing the runtime each get a full interval. Defaults to 30s. + */ initTimeoutMs?: number; /** * Dev-only: a loopback `ws://` wire debugger to stream tapped frames to. @@ -1019,6 +1213,14 @@ export interface CreateWebWorkerPairingHostRuntimeOptions { operationGraceMs?: number; } +export interface CreateWebWorkerPairingHostRuntimeOptions extends CreateWebWorkerHostRuntimeOptions { + hostConfig: WebWorkerHostConfig; +} + +export interface CreateWebWorkerSigningHostRuntimeOptions extends CreateWebWorkerHostRuntimeOptions { + hostConfig: WebWorkerSigningHostConfig; +} + export type WebWorkerHostCallbacks = RequiredHostCallbacks; export function createWebWorkerPairingHostRuntime( @@ -1026,12 +1228,43 @@ export function createWebWorkerPairingHostRuntime( host: WebWorkerHostCallbacks, options: CreateWebWorkerPairingHostRuntimeOptions, ): Promise { + // No role default: a host that asks for none must put exactly what it put + // on the wire before the field existed, and the worker reads absent as + // "pairing". + return createWebWorkerHostRuntime(worker, host, options); +} + +export function createWebWorkerSigningHostRuntime( + worker: Worker, + host: WebWorkerHostCallbacks, + options: CreateWebWorkerSigningHostRuntimeOptions, +): Promise { + return createWebWorkerHostRuntime(worker, host, { + ...options, + role: options.role ?? "signing", + }); +} + +function createWebWorkerHostRuntime( + worker: Worker, + host: WebWorkerHostCallbacks, + options: CreateWebWorkerHostRuntimeOptions, +): Promise { const callbacks = createWasmRawCallbacks(host); return new Promise((resolve, reject) => { const state: RuntimeState = { worker, + role: options.role ?? "pairing", + networkSuffix: + "networkSuffix" in options.hostConfig + ? options.hostConfig.networkSuffix + : undefined, + identityAccountId: null, + identityGeneration: 0, + pendingAllowanceSnapshots: new Map(), rawCallbacks: callbacks, + coreCallbacks: new Map(), cores: new Map(), pendingCores: new Map(), subscriptionDisposers: new Map(), @@ -1042,6 +1275,7 @@ export function createWebWorkerPairingHostRuntime( chainConnections: new Map(), pendingDisconnects: new Map(), pendingSessionActivations: new Map(), + pendingLocalIdentities: new Map(), pendingPermissionAuthorizationStatuses: new Map(), pendingPermissionAuthorizationStatusBatches: new Map(), pendingSetPermissionAuthorizationStatuses: new Map(), @@ -1060,7 +1294,8 @@ export function createWebWorkerPairingHostRuntime( coreWireSchemaHash: undefined, }; - let runtime: WorkerPairingHostRuntime | null = null; + let runtime: (WorkerPairingHostRuntime & WorkerSigningHostRuntime) | null = + null; const notifyFault = (error: Error): void => { teardown(state, error, true); @@ -1103,6 +1338,34 @@ export function createWebWorkerPairingHostRuntime( case "sessionActivationResponse": handleSessionActivationResponse(state, msg); break; + case "localIdentityProgress": + if (state.disposed) break; + try { + state.pendingLocalIdentities + .get(msg.requestId) + ?.onProgress?.(msg.progress); + } catch { + // UI observers cannot fail or settle an identity operation. + } + break; + case "localIdentityResponse": + settlePending( + state.pendingLocalIdentities, + msg.requestId, + msg.ok + ? { ok: true, value: msg.identity } + : { ok: false, error: msg.error }, + ); + break; + case "walletAllowanceSnapshotResponse": + settlePending( + state.pendingAllowanceSnapshots, + msg.requestId, + msg.ok + ? { ok: true, value: msg.snapshot } + : { ok: false, error: msg.error }, + ); + break; case "permissionAuthorizationStatusResponse": handlePermissionAuthorizationStatusResponse(state, msg); break; @@ -1240,14 +1503,20 @@ export function createWebWorkerPairingHostRuntime( readDebuggerEnablement(options.debugger), options.debuggerIndicator, ); + const timeoutMs = options.initTimeoutMs ?? 30_000; + let initPhase = "loading WASM"; + let cancelInitTimeout = (): void => {}; const onInitMessage = (ev: MessageEvent): void => { const msg = ev.data; if (msg.kind === "loaded") { + initPhase = "initializing the runtime"; + scheduleInitTimeout(); worker.postMessage({ kind: "init", logLevel: devLogLevelOverride ?? options.logLevel ?? "off", hostConfig: options.hostConfig, + role: options.role, capabilities: { chat: host.chat !== undefined, permissionStatus: host.permissionStatus !== undefined, @@ -1255,7 +1524,6 @@ export function createWebWorkerPairingHostRuntime( contacts: host.contacts !== undefined, }, debuggerUrl: debuggerDial, - role: options.role, } satisfies MainToWorker); } else if (msg.kind === "ready") { state.coreWireSchemaHash = msg.schema; @@ -1272,16 +1540,25 @@ export function createWebWorkerPairingHostRuntime( }; const cleanupInit = (): void => { - clearTimeout(initTimeout); + cancelInitTimeout(); worker.removeEventListener("error", onError); worker.removeEventListener("messageerror", onInitMessageError); worker.removeEventListener("message", onInitMessage); }; - const timeoutMs = options.initTimeoutMs ?? 30_000; - const initTimeout = setTimeout(() => { - failInit(new Error(`worker init timed out after ${timeoutMs}ms`)); - }, timeoutMs); + const scheduleInitTimeout = (): void => { + cancelInitTimeout(); + const timeout = setTimeout(() => { + failInit( + new Error( + `worker init timed out after ${timeoutMs}ms while ${initPhase}`, + ), + ); + }, timeoutMs); + cancelInitTimeout = () => clearTimeout(timeout); + }; + + scheduleInitTimeout(); worker.addEventListener("error", onError); worker.addEventListener("messageerror", onInitMessageError); @@ -1317,6 +1594,7 @@ function handleCoreError( const pending = state.pendingCores.get(coreId); if (!pending) return; state.pendingCores.delete(coreId); + state.coreCallbacks.delete(coreId); pending.reject(new Error(error)); } @@ -1331,6 +1609,7 @@ function handleFrameError( const failure = new Error(`worker frame error: ${error}`); closeCoreState(core, failure); state.cores.delete(coreId); + state.coreCallbacks.delete(coreId); // Renders left registered would never settle: the worker cancels them with // the core, so nothing further arrives to complete the sink. failRendersForCore(state, coreId, failure); @@ -1344,10 +1623,12 @@ function handleFrameError( } } -function buildRuntime(state: RuntimeState): WorkerPairingHostRuntime { - const runtime: WorkerPairingHostRuntime = { +function buildRuntime( + state: RuntimeState, +): WorkerPairingHostRuntime & WorkerSigningHostRuntime { + const runtime: WorkerPairingHostRuntime & WorkerSigningHostRuntime = { coreWireSchemaHash: state.coreWireSchemaHash, - createProvider(product): Promise { + createProvider(product, callbacks): Promise { if (state.disposed) { return Promise.reject( state.closedError ?? new Error("runtime disposed"), @@ -1361,18 +1642,32 @@ function buildRuntime(state: RuntimeState): WorkerPairingHostRuntime { reject, }); try { + if (callbacks) + state.coreCallbacks.set(coreId, createWasmRawCallbacks(callbacks)); state.worker.postMessage({ kind: "createCore", coreId, product, + ...(callbacks === undefined + ? {} + : { + capabilities: { + chat: callbacks.chat !== undefined, + contacts: callbacks.contacts !== undefined, + permissionStatus: callbacks.permissionStatus !== undefined, + pocket: callbacks.pocket !== undefined, + }, + }), } satisfies MainToWorker); } catch (err) { state.pendingCores.delete(coreId); + state.coreCallbacks.delete(coreId); reject(err instanceof Error ? err : new Error(String(err))); } }); }, disconnectSession(): Promise { + invalidateAllowanceIdentity(state); return sendWorkerRequest( state, state.pendingDisconnects, @@ -1489,11 +1784,15 @@ function buildRuntime(state: RuntimeState): WorkerPairingHostRuntime { })); }, setGrantAllowancesUnchecked(granted: boolean): Promise { - return sendSessionActivationRequest(state, (requestId) => ({ - kind: "setGrantAllowancesUnchecked", - requestId, - granted, - })); + return sendSessionActivationRequest( + state, + (requestId) => ({ + kind: "setGrantAllowancesUnchecked", + requestId, + granted, + }), + false, + ); }, setWithheldResources(tags: string[]): Promise { return sendSessionActivationRequest(state, (requestId) => ({ @@ -1508,6 +1807,42 @@ function buildRuntime(state: RuntimeState): WorkerPairingHostRuntime { requestId, })); }, + activateLocalSessionWithIdentity( + secret: Uint8Array, + liteUsername?: string, + ): Promise { + return sendSessionActivationRequest(state, (requestId) => ({ + kind: "activateLocalSessionWithIdentity", + requestId, + secret, + liteUsername, + })); + }, + refreshLocalIdentity(): Promise { + return sendLocalIdentityRequest(state, (requestId) => ({ + kind: "refreshLocalIdentity", + requestId, + })); + }, + getWalletAllowanceSnapshot(productIds): Promise { + return getWalletAllowanceSnapshot(state, productIds); + }, + registerLocalLiteUsername( + baseUsername, + identityBackendBaseUrl, + onProgress, + ): Promise { + return sendLocalIdentityRequest( + state, + (requestId) => ({ + kind: "registerLocalLiteUsername", + requestId, + baseUsername, + identityBackendBaseUrl, + }), + onProgress, + ); + }, getPermissionAuthorizationStatus(productId, request) { return sendWorkerRequest( state, @@ -1560,6 +1895,7 @@ function buildRuntime(state: RuntimeState): WorkerPairingHostRuntime { } satisfies MainToWorker); }, dispose(): void { + invalidateAllowanceIdentity(state); devGlobalTargets.delete(runtime); // Let a background task (e.g. a funding transaction) finish; the last // endOperation runs the teardown. Fault teardown is never deferred. @@ -1831,6 +2167,7 @@ function buildProvider( if (core.disposed) return; closeCoreState(core, new Error("provider disposed")); state.cores.delete(core.coreId); + state.coreCallbacks.delete(core.coreId); // Renders left registered would never settle: the worker cancels them // with the core, so nothing further arrives to complete the sink. failRendersForCore(state, core.coreId, new Error("provider disposed")); diff --git a/js/packages/truapi-host/src/web/index.ts b/js/packages/truapi-host/src/web/index.ts index acba3a71a..7db4a1087 100644 --- a/js/packages/truapi-host/src/web/index.ts +++ b/js/packages/truapi-host/src/web/index.ts @@ -2,8 +2,31 @@ export type { IframeHost, IframeHostOptions } from "./create-iframe-host.js"; export { createIframeHost } from "./create-iframe-host.js"; export type { CreateWebWorkerPairingHostRuntimeOptions, + CreateWebWorkerSigningHostRuntimeOptions, WebWorkerHostConfig, + WebWorkerSigningHostConfig, WebWorkerHostCallbacks, WorkerPairingHostRuntime, + WorkerSigningHostRuntime, } from "./create-worker-host-runtime.js"; -export { createWebWorkerPairingHostRuntime } from "./create-worker-host-runtime.js"; +export { + createWebWorkerPairingHostRuntime, + createWebWorkerSigningHostRuntime, +} from "./create-worker-host-runtime.js"; +export type { + LocalIdentity, + LocalIdentityProgress, +} from "../worker-protocol.js"; +export type { + AllowanceCollection, + AllowanceObservation, + AllowanceSection, + AllowanceSlot, + AllowancePool, + AllowanceClaims, + StatementAllowanceSnapshot, + PgasClaimsSnapshot, + PgasBalancesSnapshot, + BulletinQuota, + WalletAllowanceSnapshot, +} from "../wallet-allowances.js"; diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts index bb69a8f1d..b008cd2c9 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, jest } from "bun:test"; import { err, ok } from "neverthrow"; import { @@ -39,8 +39,16 @@ import { runtimeConfig, } from "./worker-test-harness.js"; import type { WorkerMessage } from "./worker-test-harness.js"; -import { createWebWorkerPairingHostRuntime } from "./index.js"; -import type { CreateWebWorkerPairingHostRuntimeOptions } from "./index.js"; +import { + createWebWorkerPairingHostRuntime, + createWebWorkerSigningHostRuntime, +} from "./index.js"; +import type { + CreateWebWorkerPairingHostRuntimeOptions, + CreateWebWorkerSigningHostRuntimeOptions, + LocalIdentityProgress, + WorkerSigningHostRuntime, +} from "./index.js"; function renderRequest(): ProductRendererRenderRequest { return { @@ -104,6 +112,21 @@ async function createProviderFromRuntime( }; } +async function readySigningRuntime(worker: FakeWorker) { + const runtimePromise = createWebWorkerSigningHostRuntime( + asWorker(worker), + makeHostCallbacks(), + { + hostConfig: { + ...hostConfigFromRuntimeConfig(runtimeConfig()), + networkSuffix: "paseo", + }, + }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + return runtimePromise; +} async function readyProvider(worker: FakeWorker, options: ReadyOptions = {}) { const providerPromise = createProviderFromRuntime( asWorker(worker), @@ -125,6 +148,98 @@ const devGlobal = globalThis as typeof globalThis & { }; describe("createWebWorkerPairingHostRuntime", () => { + it("isolates interactive callbacks across replacement connections on one host", async () => { + const worker = new FakeWorker(); + const deliveries: string[] = []; + const callbacks = (owner: string) => + makeHostCallbacks({ + notifications: { + pushNotification: async (request) => { + deliveries.push(`${owner}:${request.text}`); + return { id: 42 }; + }, + }, + }); + const runtimePromise = createWebWorkerPairingHostRuntime( + asWorker(worker), + callbacks("host"), + { hostConfig: hostConfigFromRuntimeConfig(runtimeConfig()) }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + const old = await finishProviderReady( + worker, + runtime.createProvider({ productId: "test.dot" }, callbacks("old")), + ); + const oldId = lastMessageOfKind(worker, "createCore").coreId; + const replacement = await finishProviderReady( + worker, + runtime.createProvider( + { productId: "test.dot" }, + callbacks("replacement"), + ), + ); + const replacementId = lastMessageOfKind(worker, "createCore").coreId; + let requestId = 0; + const notify = async (coreId?: unknown) => { + worker.emit({ + kind: "callbackRequest", + requestId: ++requestId, + ...(coreId === undefined ? {} : { coreId }), + name: "pushNotification", + args: [ + HostPushNotificationRequest.enc({ + text: "Hello!", + deeplink: undefined, + scheduledAt: undefined, + }), + ], + }); + await settle(); + return lastMessageOfKind(worker, "callbackResponse"); + }; + expect((await notify(oldId)).ok).toBe(true); + expect((await notify(replacementId)).ok).toBe(true); + old.dispose(); + expect((await notify(oldId)).ok).toBe(false); + expect((await notify(replacementId)).ok).toBe(true); + expect((await notify()).ok).toBe(true); + expect(deliveries).toEqual([ + "old:Hello!", + "replacement:Hello!", + "replacement:Hello!", + "host:Hello!", + ]); + worker.emit({ + kind: "frameError", + coreId: replacementId, + error: "connection lost", + }); + expect((await notify(replacementId)).ok).toBe(false); + replacement.dispose(); + const failed = runtime.createProvider( + { productId: "test.dot" }, + callbacks("failed"), + ); + const failedId = lastMessageOfKind(worker, "createCore").coreId; + worker.emit({ + kind: "coreError", + coreId: failedId, + error: "creation failed", + }); + await expect(failed).rejects.toThrow("creation failed"); + expect((await notify(failedId)).ok).toBe(false); + runtime.dispose(); + await notify(replacementId); + expect(deliveries).toEqual([ + "old:Hello!", + "replacement:Hello!", + "replacement:Hello!", + "host:Hello!", + ]); + }); + it("initializes the worker without a callback manifest", async () => { const worker = new FakeWorker(); const config = runtimeConfig(); @@ -143,6 +258,9 @@ describe("createWebWorkerPairingHostRuntime", () => { kind: "init", logLevel: "debug", hostConfig: hostConfigFromRuntimeConfig(config), + // A host that asks for no role sends none: the worker reads absent as + // "pairing", so the message stays what it was before the field existed. + role: undefined, capabilities: { chat: false, permissionStatus: false, @@ -169,6 +287,143 @@ describe("createWebWorkerPairingHostRuntime", () => { provider.dispose(); }); + it("activates a browser-local signing session in the worker", async () => { + const worker = new FakeWorker(); + const hostConfig = { + ...hostConfigFromRuntimeConfig(runtimeConfig()), + networkSuffix: "paseo", + } satisfies CreateWebWorkerSigningHostRuntimeOptions["hostConfig"]; + const runtimePromise = createWebWorkerSigningHostRuntime( + asWorker(worker), + makeHostCallbacks(), + { hostConfig }, + ); + + worker.emit({ kind: "loaded" }); + expect(worker.messages[0]).toMatchObject({ + kind: "init", + hostConfig, + role: "signing", + }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + + const secret = new Uint8Array(32).fill(7); + const activation = runtime.activateLocalSession(secret); + const request = lastMessageOfKind(worker, "activateLocalSession"); + expect(request).toMatchObject({ + kind: "activateLocalSession", + secret, + }); + worker.emit({ + kind: "sessionActivationResponse", + requestId: request.requestId, + ok: true, + }); + await activation; + runtime.dispose(); + }); + + it("scopes identity progress to pending requests and isolates observer failures", async () => { + const worker = new FakeWorker(); + const runtime = await readySigningRuntime(worker); + const progress: LocalIdentityProgress[] = []; + const identity = { + identityAccountId: `0x${"11".repeat(32)}`, + liteUsername: "alice.paseo", + }; + const claim = runtime.registerLocalLiteUsername( + "alice", + "https://identity.invalid", + (event) => { + progress.push(event); + throw new Error("observer failed"); + }, + ); + const request = lastMessageOfKind(worker, "registerLocalLiteUsername"); + let settled = false; + void claim.then(() => { + settled = true; + }); + worker.emit({ + kind: "localIdentityProgress", + requestId: request.requestId, + progress: { stage: "confirming" }, + }); + await settle(); + expect(settled).toBe(false); + worker.emit({ + kind: "localIdentityResponse", + requestId: request.requestId, + ok: true, + identity, + }); + await expect(claim).resolves.toEqual(identity); + + const nextProgress: LocalIdentityProgress[] = []; + const nextClaim = runtime.registerLocalLiteUsername( + "bob", + "https://identity.invalid", + (event) => nextProgress.push(event), + ); + const next = lastMessageOfKind(worker, "registerLocalLiteUsername"); + worker.emit({ + kind: "localIdentityProgress", + requestId: request.requestId, + progress: { stage: "retrying", error: "late response" }, + }); + worker.emit({ + kind: "localIdentityProgress", + requestId: next.requestId, + progress: { stage: "checking" }, + }); + worker.emit({ + kind: "localIdentityResponse", + requestId: next.requestId, + ok: false, + error: "claim rejected", + }); + await expect(nextClaim).rejects.toThrow("claim rejected"); + worker.emit({ + kind: "localIdentityProgress", + requestId: next.requestId, + progress: { stage: "confirming" }, + }); + expect(progress).toEqual([{ stage: "confirming" }]); + expect(nextProgress).toEqual([{ stage: "checking" }]); + runtime.dispose(); + }); + + for (const close of ["dispose", "fault"] as const) { + it(`ignores late identity progress after runtime ${close}`, async () => { + const worker = new FakeWorker(); + const runtime = await readySigningRuntime(worker); + const progress: LocalIdentityProgress[] = []; + const claim = runtime.registerLocalLiteUsername( + "alice", + "https://identity.invalid", + (event) => progress.push(event), + ); + const request = lastMessageOfKind(worker, "registerLocalLiteUsername"); + worker.emit({ + kind: "localIdentityProgress", + requestId: request.requestId, + progress: { stage: "confirming" }, + }); + if (close === "dispose") runtime.dispose(); + else worker.emitError("worker stopped"); + await expect(claim).rejects.toThrow( + close === "dispose" ? "runtime disposed" : "worker stopped", + ); + worker.emit({ + kind: "localIdentityProgress", + requestId: request.requestId, + progress: { stage: "retrying", error: "late response" }, + }); + expect(progress).toEqual([{ stage: "confirming" }]); + }); + } + it("reports the chat capability to the worker when the host serves it", async () => { const worker = new FakeWorker(); void createWebWorkerPairingHostRuntime( @@ -1276,21 +1531,57 @@ describe("createWebWorkerPairingHostRuntime", () => { provider.dispose(); }); - it("rejects when init times out", async () => { - const worker = new FakeWorker(); - const providerPromise = createProviderFromRuntime( - asWorker(worker), - makeHostCallbacks(), - { - runtimeConfig: runtimeConfig(), - initTimeoutMs: 20, - }, - ); - worker.emit({ kind: "loaded" }); - await expect(providerPromise).rejects.toThrow( - /worker init timed out after 20ms/, - ); - expect(worker.terminated).toBe(true); + it("terminates the worker when WASM loading times out", async () => { + jest.useFakeTimers(); + try { + const worker = new FakeWorker(); + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks(), + { + runtimeConfig: runtimeConfig(), + initTimeoutMs: 20, + }, + ); + const initErrorPromise = providerPromise.catch((error: unknown) => error); + + jest.advanceTimersByTime(20); + const initError = await initErrorPromise; + expect(initError).toEqual( + new Error("worker init timed out after 20ms while loading WASM"), + ); + expect(worker.terminated).toBe(true); + } finally { + jest.useRealTimers(); + } + }); + + it("gives WASM loading and runtime initialization separate deadlines", async () => { + jest.useFakeTimers(); + try { + const worker = new FakeWorker(); + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks(), + { + runtimeConfig: runtimeConfig(), + initTimeoutMs: 200, + }, + ); + + jest.advanceTimersByTime(120); + worker.emit({ kind: "loaded" }); + jest.advanceTimersByTime(120); + worker.emit({ kind: "ready" }); + await Promise.resolve(); + const createCore = lastMessageOfKind(worker, "createCore"); + worker.emit({ kind: "coreReady", coreId: createCore.coreId }); + await providerPromise; + + expect(worker.terminated).toBe(false); + } finally { + jest.useRealTimers(); + } }); it("rejects on messageerror during init", async () => { @@ -1793,3 +2084,146 @@ describe("worker host role", () => { await finishProviderReady(worker, providerPromise).catch(() => {}); }); }); + +describe("wallet allowance inspection isolation", () => { + const account = `0x${"11".repeat(32)}`; + + async function resolvedRuntime(worker: FakeWorker) { + const runtime = await readySigningRuntime(worker); + const identity = runtime.refreshLocalIdentity(); + worker.emit({ + kind: "localIdentityResponse", + requestId: lastMessageOfKind(worker, "refreshLocalIdentity").requestId, + ok: true, + identity: { identityAccountId: account, liteUsername: "alice.paseo" }, + }); + await identity; + return runtime; + } + + it("denies wallet-wide inspection to pairing hosts", async () => { + const pairingWorker = new FakeWorker(); + const pairing = await readyRuntime(pairingWorker); + await expect( + ( + pairing as unknown as WorkerSigningHostRuntime + ).getWalletAllowanceSnapshot([]), + ).rejects.toThrow("local signing identity"); + expect(indexOfKind(pairingWorker, "getWalletAllowanceSnapshot")).toBe(-1); + pairing.dispose(); + }); + + it("rejects an oversized or ambiguous product scope before dispatch", async () => { + const worker = new FakeWorker(); + const runtime = await resolvedRuntime(worker); + for (const ids of [ + ["a.dot", "a.dot"], + [""], + Array.from({ length: 33 }, (_, i) => `app${i}.dot`), + ]) { + await expect(runtime.getWalletAllowanceSnapshot(ids)).rejects.toThrow( + "product IDs", + ); + } + expect(indexOfKind(worker, "getWalletAllowanceSnapshot")).toBe(-1); + runtime.dispose(); + }); + + it.each(["dispose", "error", "messageerror"] as const)( + "settles pending reads when the worker closes through %s", + async (close) => { + const worker = new FakeWorker(); + const runtime = await resolvedRuntime(worker); + const read = runtime.getWalletAllowanceSnapshot([]); + const rejected = read.catch((error: unknown) => error); + if (close === "dispose") runtime.dispose(); + else if (close === "error") worker.emitError("worker stopped"); + else worker.emitMessageError(); + expect(await rejected).toBeInstanceOf(Error); + await expect(runtime.getWalletAllowanceSnapshot([])).rejects.toThrow(); + runtime.dispose(); + }, + ); + + it("rejects an in-flight snapshot immediately when another wallet activates", async () => { + const worker = new FakeWorker(); + const runtime = await resolvedRuntime(worker); + const read = runtime.getWalletAllowanceSnapshot([]); + const requestId = lastMessageOfKind( + worker, + "getWalletAllowanceSnapshot", + ).requestId; + const rejected = read.catch((error: unknown) => error); + const activation = runtime.activateLocalSession(new Uint8Array(32)); + await expect(runtime.getWalletAllowanceSnapshot([])).rejects.toThrow( + "local signing identity", + ); + expect(await rejected).toBeInstanceOf(Error); + worker.emit({ + kind: "walletAllowanceSnapshotResponse", + requestId, + ok: true, + snapshot: { + schemaVersion: 1, + identityAccountId: account, + networkSuffix: "paseo", + }, + }); + worker.emit({ + kind: "sessionActivationResponse", + requestId: lastMessageOfKind(worker, "activateLocalSession").requestId, + ok: true, + }); + await activation; + runtime.dispose(); + }); + + it("does not accept a late identity refresh from a different activation", async () => { + const worker = new FakeWorker(); + const runtime = await readySigningRuntime(worker); + const identity = runtime.refreshLocalIdentity(); + const rejected = identity.catch((error: unknown) => error); + const activation = runtime.activateLocalSession(new Uint8Array(32)); + worker.emit({ + kind: "localIdentityResponse", + requestId: lastMessageOfKind(worker, "refreshLocalIdentity").requestId, + ok: true, + identity: { identityAccountId: account }, + }); + worker.emit({ + kind: "sessionActivationResponse", + requestId: lastMessageOfKind(worker, "activateLocalSession").requestId, + ok: true, + }); + expect(await rejected).toBeInstanceOf(Error); + await activation; + runtime.dispose(); + }); + + it("retains wallet binding when only the debug allocation policy changes", async () => { + const worker = new FakeWorker(); + const runtime = await resolvedRuntime(worker); + const toggle = runtime.setGrantAllowancesUnchecked(false); + worker.emit({ + kind: "sessionActivationResponse", + requestId: lastMessageOfKind(worker, "setGrantAllowancesUnchecked") + .requestId, + ok: true, + }); + await toggle; + const read = runtime.getWalletAllowanceSnapshot([]); + worker.emit({ + kind: "walletAllowanceSnapshotResponse", + requestId: lastMessageOfKind(worker, "getWalletAllowanceSnapshot") + .requestId, + ok: true, + snapshot: { + schemaVersion: 1, + identityAccountId: `0x${"22".repeat(32)}`, + networkSuffix: "paseo", + }, + }); + await expect(read).rejects.toThrow("current identity"); + runtime.dispose(); + }); +}); diff --git a/js/packages/truapi-host/src/worker-local-identity.test.ts b/js/packages/truapi-host/src/worker-local-identity.test.ts new file mode 100644 index 000000000..c5e2867a4 --- /dev/null +++ b/js/packages/truapi-host/src/worker-local-identity.test.ts @@ -0,0 +1,134 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import { resolveLocalIdentity } from "./worker-local-identity.js"; +import type { WorkerSigningHostRuntime } from "./wasm-module.js"; +import type { LocalIdentityProgress } from "./worker-protocol.js"; + +const account = `0x${"11".repeat(32)}`; +const registration = { + baseUsername: "lateconfirmation", + identityBackendBaseUrl: "https://identity.invalid/api/v1", +}; +const spies: { mockRestore(): void }[] = []; +afterEach(() => { + for (const spy of spies.splice(0)) spy.mockRestore(); +}); + +function setup( + refresh: () => Promise<{ identityAccountId: string; liteUsername?: string }>, +) { + let submissions = 0; + const realTimeout = globalThis.setTimeout; + spies.push( + spyOn(globalThis, "setTimeout").mockImplementation((( + callback: () => void, + delay?: number, + ) => + realTimeout(callback, delay === 4_000 ? 0 : delay)) as typeof setTimeout), + ); + spies.push( + spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const path = new URL(String(input)).pathname; + if (path.endsWith("/attester")) + return Response.json({ attester: account }); + if (path.endsWith("/auth/challenges")) + return Response.json({ challenge: "AA==" }); + if (path.endsWith("/auth/token")) + return Response.json({ token: "test-proof-token" }); + if (path.endsWith("/usernames")) { + submissions++; + return new Response("", { status: 202 }); + } + throw new Error(`Unexpected request: ${path}`); + }), + ); + const runtime = { + localIdentityContext: () => ({ + activationId: 1, + identityAccountId: account, + }), + refreshLocalIdentity: refresh, + localIdentityAuthProof: () => new Uint8Array(64), + localLiteRegistrationBody: async () => "{}", + } as unknown as WorkerSigningHostRuntime; + return { runtime, submissions: () => submissions }; +} + +test("an accepted claim confirms after the old polling cutoff without resubmission", async () => { + let reads = 0; + const confirmed = { + identityAccountId: account, + liteUsername: "lateconfirmation.paseo", + }; + const { runtime, submissions } = setup(async () => { + reads++; + if (reads === 32) throw new Error("temporary chain disconnect"); + return reads > 40 ? confirmed : { identityAccountId: account }; + }); + await expect( + resolveLocalIdentity(runtime, new AbortController().signal, registration), + ).resolves.toEqual(confirmed); + expect(submissions()).toBe(1); +}); + +test("accepted registration reports read retries and recovers without another submission", async () => { + const progress: LocalIdentityProgress[] = []; + const confirmed = { + identityAccountId: account, + liteUsername: "lateconfirmation.paseo", + }; + let reads = 0; + const { runtime, submissions } = setup(async () => { + reads++; + if (reads === 2) throw new Error("temporary chain disconnect"); + return reads === 4 ? confirmed : { identityAccountId: account }; + }); + await expect( + resolveLocalIdentity( + runtime, + new AbortController().signal, + registration, + (event) => { + progress.push(event); + throw new Error("observer failed"); + }, + ), + ).resolves.toEqual(confirmed); + expect(progress).toEqual([ + { stage: "checking" }, + { stage: "authenticating" }, + { stage: "submitting" }, + { stage: "confirming" }, + { stage: "retrying", error: "temporary chain disconnect" }, + { stage: "confirming" }, + { stage: "confirming" }, + ]); + expect(submissions()).toBe(1); +}); + +test("disposing the wallet stops confirmation monitoring without resubmission", async () => { + const controller = new AbortController(); + let reads = 0; + const { runtime, submissions } = setup(async () => { + if (++reads === 4) controller.abort(new Error("wallet disposed")); + return { identityAccountId: account }; + }); + await expect( + resolveLocalIdentity(runtime, controller.signal, registration), + ).rejects.toThrow("wallet disposed"); + expect(submissions()).toBe(1); +}); + +test("confirmation from a different identity is rejected", async () => { + let reads = 0; + const { runtime } = setup(async () => + ++reads === 1 + ? { identityAccountId: account } + : { + identityAccountId: `0x${"22".repeat(32)}`, + liteUsername: "wrong.paseo", + }, + ); + await expect( + resolveLocalIdentity(runtime, new AbortController().signal, registration), + ).rejects.toThrow("verified identity does not match"); +}); diff --git a/js/packages/truapi-host/src/worker-local-identity.ts b/js/packages/truapi-host/src/worker-local-identity.ts new file mode 100644 index 000000000..161261910 --- /dev/null +++ b/js/packages/truapi-host/src/worker-local-identity.ts @@ -0,0 +1,187 @@ +import { errorMessage } from "./error.js"; +import type { + LocalIdentity, + LocalIdentityProgress, +} from "./worker-protocol.js"; +import type { WorkerSigningHostRuntime } from "./wasm-module.js"; + +function stringField(value: unknown, field: string): string { + if (typeof value !== "object" || value === null || !(field in value)) { + throw new Error(`identity backend response missing ${field}`); + } + const result: unknown = Reflect.get(value, field); + if (typeof result !== "string" || !result.length) { + throw new Error(`identity backend response has invalid ${field}`); + } + return result; +} + +function delay(milliseconds: number, signal: AbortSignal): Promise { + signal.throwIfAborted(); + const { promise, resolve, reject } = Promise.withResolvers(); + const abort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", abort); + resolve(); + }, milliseconds); + signal.addEventListener("abort", abort, { once: true }); + return promise; +} + +/** HTTP stays in the worker; all secret material and proof construction stay native. */ +export async function resolveLocalIdentity( + runtime: WorkerSigningHostRuntime, + signal: AbortSignal, + registration?: { baseUsername: string; identityBackendBaseUrl: string }, + onProgress?: (progress: LocalIdentityProgress) => void, +): Promise { + const context = runtime.localIdentityContext(); + const check = () => { + signal.throwIfAborted(); + if (runtime.localIdentityContext().activationId !== context.activationId) { + throw new Error("local identity activation changed"); + } + }; + const refresh = async () => { + check(); + const identity = await runtime.refreshLocalIdentity(context.activationId); + check(); + if (identity.identityAccountId !== context.identityAccountId) { + throw new Error( + "verified identity does not match the active UID account", + ); + } + return identity; + }; + const reportProgress = (progress: LocalIdentityProgress) => { + try { + onProgress?.(progress); + } catch { + // Observers must not interrupt authentication, submission, or polling. + } + }; + reportProgress({ stage: "checking" }); + const existing = await refresh(); + if (!registration || existing.liteUsername) return existing; + + const base = registration.identityBackendBaseUrl.replace(/\/+$/, ""); + if (!base) throw new Error("identity backend base URL is empty"); + const request = async ( + path: string, + init: RequestInit, + ): Promise => { + check(); + const response = await fetch(`${base}${path}`, { + ...init, + credentials: "omit", + redirect: "error", + signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]), + }); + check(); + return response; + }; + const json = async (path: string, init: RequestInit): Promise => { + const response = await request(path, init); + const text = await response.text(); + check(); + if (!response.ok) { + throw new Error( + `identity backend ${path} failed (${response.status}): ${text}`, + ); + } + return JSON.parse(text) as unknown; + }; + const headers = { "Content-Type": "application/json" }; + reportProgress({ stage: "authenticating" }); + const attester = stringField( + await json("/attester", { method: "GET" }), + "attester", + ); + const verifierHex = attester.replace(/^0x/, ""); + if (!/^[0-9a-fA-F]{64}$/.test(verifierHex)) { + throw new Error("identity backend attester must be 32-byte hex"); + } + const verifier = Uint8Array.from(verifierHex.match(/../g)!, (byte) => + parseInt(byte, 16), + ); + const challenge = stringField( + await json("/auth/challenges", { method: "POST", headers, body: "{}" }), + "challenge", + ); + const challengeBytes = Uint8Array.from(atob(challenge), (char) => + char.charCodeAt(0), + ); + check(); + const proof = runtime.localIdentityAuthProof( + context.activationId, + challengeBytes, + ); + const token = stringField( + await json("/auth/token", { + method: "POST", + headers: { + ...headers, + "Auth-ClientId": btoa(String.fromCharCode(...proof.subarray(0, 32))), + "Auth-ClientProof": btoa(String.fromCharCode(...proof.subarray(32))), + "Auth-Challenge": challenge, + }, + body: "{}", + }), + "token", + ); + check(); + const body = await runtime.localLiteRegistrationBody( + context.activationId, + registration.baseUsername, + verifier, + ); + check(); + reportProgress({ stage: "submitting" }); + const response = await request("/usernames", { + method: "POST", + headers: { ...headers, Authorization: `Bearer ${token}` }, + body, + }); + const responseText = await response.text(); + check(); + if (!response.ok) { + throw new Error( + `username registration failed (${response.status}): ${responseText}`, + ); + } + reportProgress({ stage: "confirming" }); + + // Backend acceptance is not chain confirmation. Keep observing this activation + // until ownership is verified or the caller disposes it; never resubmit a claim + // just because indexing/finality takes longer than a fixed polling window. + for (;;) { + check(); + let identity: LocalIdentity; + try { + identity = await runtime.refreshLocalIdentity(context.activationId); + } catch (error) { + check(); + let message = "Chain read failed"; + try { + message = errorMessage(error); + } catch { + // An unprintable thrown value must not stop confirmation polling. + } + reportProgress({ stage: "retrying", error: message }); + await delay(4_000, signal); + continue; + } + check(); + if (identity.identityAccountId !== context.identityAccountId) { + throw new Error( + "verified identity does not match the active UID account", + ); + } + reportProgress({ stage: "confirming" }); + if (identity.liteUsername) return identity; + await delay(4_000, signal); + } +} diff --git a/js/packages/truapi-host/src/worker-protocol.ts b/js/packages/truapi-host/src/worker-protocol.ts index 1bc3bffc8..c78efb9ca 100644 --- a/js/packages/truapi-host/src/worker-protocol.ts +++ b/js/packages/truapi-host/src/worker-protocol.ts @@ -31,6 +31,7 @@ import type { OptionalCapabilities } from "./generated/worker-callbacks.js"; import type { LogLevel, PermissionAuthorizationStatus } from "./runtime.js"; +import type { WalletAllowanceSnapshot } from "./wallet-allowances.js"; import type { CallbackName, SubscriptionName, @@ -50,6 +51,18 @@ export type { */ export type CallbackArgs = readonly unknown[]; +/** Chain-verified identity metadata for the wallet's network-specific UID account. */ +export interface LocalIdentity { + /** Canonical lowercase 0x-prefixed 32-byte account identifier. */ + identityAccountId: string; + liteUsername?: string; +} + +/** Observable registration stages; acceptance is not verified ownership. */ +export type LocalIdentityProgress = + | { stage: "checking" | "authenticating" | "submitting" | "confirming" } + | { stage: "retrying"; error: string }; + /** * Messages posted by the main window to the WASM worker. These either control * worker/core lifecycle, forward encoded TrUAPI frames into the core, or return @@ -81,7 +94,12 @@ export type MainToWorker = */ role?: HostRole; } - | { kind: "createCore"; coreId: number; product: unknown } + | { + kind: "createCore"; + coreId: number; + product: unknown; + capabilities?: OptionalCapabilities; + } | { kind: "disposeCore"; coreId: number } | { kind: "setLogLevel"; level: LogLevel } | { kind: "frame"; coreId: number; bytes: Uint8Array } @@ -121,6 +139,24 @@ export type MainToWorker = tags: string[]; } | { kind: "resetSessionState"; requestId: number } + | { + kind: "activateLocalSessionWithIdentity"; + requestId: number; + secret: Uint8Array; + liteUsername?: string; + } + | { kind: "refreshLocalIdentity"; requestId: number } + | { + kind: "getWalletAllowanceSnapshot"; + requestId: number; + productIds: string[]; + } + | { + kind: "registerLocalLiteUsername"; + requestId: number; + baseUsername: string; + identityBackendBaseUrl: string; + } | { kind: "getPermissionAuthorizationStatus"; productId: string; @@ -221,6 +257,35 @@ export type WorkerToMain = ok: false; error: string; } + | { + kind: "localIdentityProgress"; + requestId: number; + progress: LocalIdentityProgress; + } + | { + kind: "localIdentityResponse"; + requestId: number; + ok: true; + identity: LocalIdentity; + } + | { + kind: "localIdentityResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "walletAllowanceSnapshotResponse"; + requestId: number; + ok: true; + snapshot: WalletAllowanceSnapshot; + } + | { + kind: "walletAllowanceSnapshotResponse"; + requestId: number; + ok: false; + error: string; + } | { kind: "permissionAuthorizationStatusResponse"; requestId: number; @@ -331,12 +396,14 @@ export type WorkerToMain = | { kind: "callbackRequest"; requestId: number; + coreId?: number; name: CallbackName; args: CallbackArgs; } | { kind: "subscriptionStart"; subId: number; + coreId?: number; name: SubscriptionName; payload: Uint8Array | string | null; } diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index b7cc25294..eaa862440 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -24,12 +24,18 @@ import { } from "./worker-permission-authorization.js"; import type { WasmModuleShape, + WorkerHostRuntime, WorkerPairingHostRuntime, - WorkerSigningHostRuntime, WorkerProductRuntime, + WorkerSigningHostRuntime, WorkerTransition, } from "./wasm-module.js"; import { errorMessage } from "./error.js"; +import { resolveLocalIdentity } from "./worker-local-identity.js"; +import { + validateAllowanceProductIds, + validateWalletAllowanceSnapshot, +} from "./wallet-allowances.js"; import { CHAT_ACTION_ENTRY_POINT, RENDERER_ACTION_ENTRY_POINT, @@ -82,6 +88,7 @@ const chainResponseListeners = new Map void>(); function callbackRequest( name: CallbackName, args: readonly unknown[], + coreId?: number, ): Promise { return new Promise((resolve, reject) => { const requestId = ++nextRequestId; @@ -89,7 +96,13 @@ function callbackRequest( if (r.ok) resolve(r.value); else reject(new Error(r.error)); }); - postToMain({ kind: "callbackRequest", requestId, name, args }); + postToMain({ + kind: "callbackRequest", + requestId, + name, + args, + ...(coreId === undefined ? {} : { coreId }), + }); }); } @@ -98,13 +111,20 @@ function startSubscription( payload: Uint8Array | string | null, sendItem: (value: T) => void, sendError: (error: GenericError) => void, + coreId?: number, ): () => void { const subId = ++nextSubId; subscriptionListeners.set(subId, { sendItem: sendItem as (value: unknown) => void, sendError: (error) => sendError({ reason: error }), }); - postToMain({ kind: "subscriptionStart", subId, name, payload }); + postToMain({ + kind: "subscriptionStart", + subId, + name, + payload, + ...(coreId === undefined ? {} : { coreId }), + }); return () => { subscriptionListeners.delete(subId); postToMain({ kind: "subscriptionStop", subId }); @@ -171,12 +191,16 @@ function chainConnect( } /** Build the host-level callback object passed to the WASM runtime. */ -function buildRawCallbacks(capabilities: OptionalCapabilities) { +function buildRawCallbacks( + capabilities: OptionalCapabilities, + coreId?: number, +) { return { ...createWorkerRawCallbacks( { - callbackRequest, - startSubscription, + callbackRequest: (name, args) => callbackRequest(name, args, coreId), + startSubscription: (name, payload, sendItem, sendError) => + startSubscription(name, payload, sendItem, sendError, coreId), chainConnect, }, capabilities, @@ -590,7 +614,7 @@ function buildCoreCallbacks(coreId: number) { }; } -let runtime: WorkerPairingHostRuntime | null = null; +let runtime: WorkerHostRuntime | null = null; const cores = new Map(); // Outstanding receiveFrame calls per core. wasm-bindgen holds a borrow of the // core for the whole duration of an async method, so `free()` throws while one @@ -599,6 +623,127 @@ const inFlightFrames = new Map>>(); /** Live render subscriptions, keyed by main-thread render id. */ const renders: RenderSubscriptions = new Map(); let wasm: WasmModuleShape | null = null; +let identityAbort: AbortController | null = null; +const identityOperations = new Set>(); +let allowanceNetworkSuffix: string | null = null; +let allowanceGeneration = 0; +const allowanceOperations = new Set>(); + +function handleWalletAllowanceSnapshot( + requestId: number, + input: string[], +): void { + const rt = runtime; + const generation = allowanceGeneration; + const operation = (async () => { + try { + if (!rt || !isSigningRuntime(rt) || allowanceNetworkSuffix === null) { + throw new Error( + "wallet allowance inspection requires a signing runtime", + ); + } + const productIds = validateAllowanceProductIds(input); + const context = rt.localIdentityContext(); + const snapshot = await rt.getWalletAllowanceSnapshot( + context.activationId, + productIds, + ); + if ( + runtime !== rt || + generation !== allowanceGeneration || + rt.localIdentityContext().activationId !== context.activationId + ) { + throw new Error( + "local identity activation changed during allowance inspection", + ); + } + validateWalletAllowanceSnapshot( + snapshot, + context.identityAccountId, + allowanceNetworkSuffix, + productIds, + ); + postToMain({ + kind: "walletAllowanceSnapshotResponse", + requestId, + ok: true, + snapshot, + }); + } catch (error) { + postToMain({ + kind: "walletAllowanceSnapshotResponse", + requestId, + ok: false, + error: errorMessage(error), + }); + } + })(); + allowanceOperations.add(operation); + void operation.finally(() => allowanceOperations.delete(operation)); +} + +function handleLocalIdentity( + requestId: number, + registration?: { baseUsername: string; identityBackendBaseUrl: string }, +): void { + const rt = runtime; + if (!rt || !isSigningRuntime(rt) || identityAbort) { + postToMain({ + kind: "localIdentityResponse", + requestId, + ok: false, + error: identityAbort + ? "local identity operation already in progress" + : "signing runtime is not active", + }); + return; + } + const controller = new AbortController(); + identityAbort = controller; + const operation = (async () => { + try { + const identity = await resolveLocalIdentity( + rt, + controller.signal, + registration, + (progress) => { + if (controller.signal.aborted || identityAbort !== controller) return; + postToMain({ kind: "localIdentityProgress", requestId, progress }); + }, + ); + controller.signal.throwIfAborted(); + postToMain({ + kind: "localIdentityResponse", + requestId, + ok: true, + identity, + }); + } catch (error) { + postToMain({ + kind: "localIdentityResponse", + requestId, + ok: false, + error: errorMessage(error), + }); + } finally { + if (identityAbort === controller) identityAbort = null; + } + })(); + identityOperations.add(operation); + void operation.finally(() => identityOperations.delete(operation)); +} + +function isPairingRuntime( + candidate: WorkerHostRuntime, +): candidate is WorkerPairingHostRuntime { + return "cancelPairing" in candidate; +} + +function isSigningRuntime( + candidate: WorkerHostRuntime, +): candidate is WorkerSigningHostRuntime { + return "activateLocalSession" in candidate; +} (async () => { try { @@ -652,6 +797,10 @@ ctx.addEventListener("message", (ev: MessageEvent) => { }); break; } + const hostConfig = msg.hostConfig as { networkSuffix?: unknown }; + if (typeof hostConfig?.networkSuffix === "string") { + allowanceNetworkSuffix = hostConfig.networkSuffix; + } runtime = new SigningRuntime( buildRawCallbacks(msg.capabilities), msg.hostConfig, @@ -680,6 +829,9 @@ ctx.addEventListener("message", (ev: MessageEvent) => { const core = runtime.productRuntime( msg.product, buildCoreCallbacks(msg.coreId), + msg.capabilities === undefined + ? undefined + : buildRawCallbacks(msg.capabilities, msg.coreId), ); cores.set(msg.coreId, core); postToMain({ kind: "coreReady", coreId: msg.coreId }); @@ -698,10 +850,13 @@ ctx.addEventListener("message", (ev: MessageEvent) => { void handleFrame(msg.coreId, msg.bytes); break; case "disconnectSession": + identityAbort?.abort(new Error("local identity session disconnected")); void handleDisconnectSession(msg.requestId); break; case "cancelPairing": - runtime?.cancelPairing(); + if (runtime && isPairingRuntime(runtime)) { + runtime.cancelPairing(); + } break; case "getSessionChatIdentityKey": handleGetSessionChatIdentityKey(msg.requestId); @@ -720,7 +875,9 @@ ctx.addEventListener("message", (ev: MessageEvent) => { ); break; case "notifySessionStoreChanged": - runtime?.notifySessionStoreChanged(); + if (runtime && isPairingRuntime(runtime)) { + runtime.notifySessionStoreChanged(); + } break; case "notifyContactsChanged": runtime?.notifyContactsChanged(); @@ -735,7 +892,10 @@ ctx.addEventListener("message", (ev: MessageEvent) => { void handleSessionActivation( msg.requestId, "activateStoredSession", - (rt) => rt.activateStoredSession(), + (rt) => + isPairingRuntime(rt) + ? rt.activateStoredSession() + : Promise.reject(new Error("pairing runtime is not active")), ); break; case "activateExternalSession": { @@ -743,7 +903,10 @@ ctx.addEventListener("message", (ev: MessageEvent) => { void handleSessionActivation( msg.requestId, "activateExternalSession", - (rt) => rt.activateExternalSession(blob), + (rt) => + isPairingRuntime(rt) + ? rt.activateExternalSession(blob) + : Promise.reject(new Error("pairing runtime is not active")), ); break; } @@ -798,6 +961,7 @@ ctx.addEventListener("message", (ev: MessageEvent) => { signing.setGrantAllowancesUnchecked(granted); return Promise.resolve(); }, + false, ); break; } @@ -820,9 +984,33 @@ ctx.addEventListener("message", (ev: MessageEvent) => { } case "resetSessionState": void handleSessionActivation(msg.requestId, "resetSessionState", (rt) => - rt.resetSessionState(), + isPairingRuntime(rt) + ? rt.resetSessionState() + : Promise.reject(new Error("pairing runtime is not active")), + ); + break; + case "activateLocalSessionWithIdentity": { + identityAbort?.abort(new Error("local identity activation changed")); + const { secret, liteUsername } = msg; + void handleSessionActivation( + msg.requestId, + "activateLocalSessionWithIdentity", + (rt) => + isSigningRuntime(rt) + ? rt.activateLocalSessionWithIdentity(secret, liteUsername) + : Promise.reject(new Error("signing runtime is not active")), ); break; + } + case "refreshLocalIdentity": + handleLocalIdentity(msg.requestId); + break; + case "registerLocalLiteUsername": + handleLocalIdentity(msg.requestId, msg); + break; + case "getWalletAllowanceSnapshot": + handleWalletAllowanceSnapshot(msg.requestId, msg.productIds); + break; case "getPermissionAuthorizationStatus": void handleGetPermissionAuthorizationStatus( runtime, @@ -940,8 +1128,14 @@ ctx.addEventListener("message", (ev: MessageEvent) => { // down; free the captured handle after the cores finish disposing. const disposing = runtime; runtime = null; + allowanceGeneration++; + identityAbort?.abort(new Error("runtime disposed")); void (async () => { try { + if (disposing && isSigningRuntime(disposing)) + await disposing.disconnectSession(); + await Promise.allSettled(identityOperations); + await Promise.allSettled(allowanceOperations); await Promise.all( [...cores.keys()].map((coreId) => disposeCore(coreId)), ); @@ -977,7 +1171,8 @@ async function disposeCore(coreId: number): Promise { async function handleSessionActivation( requestId: number, label: string, - activate: (runtime: WorkerPairingHostRuntime) => Promise, + activate: (runtime: WorkerHostRuntime) => Promise, + changesIdentity = true, ): Promise { if (!runtime) { postToMain({ @@ -988,6 +1183,10 @@ async function handleSessionActivation( }); return; } + if (changesIdentity) { + allowanceGeneration++; + identityAbort?.abort(new Error("local identity activation changed")); + } try { await activate(runtime); postToMain({ kind: "sessionActivationResponse", requestId, ok: true }); @@ -1011,6 +1210,8 @@ async function handleDisconnectSession(requestId: number): Promise { }); return; } + allowanceGeneration++; + identityAbort?.abort(new Error("local identity disconnected")); try { await runtime.disconnectSession(); postToMain({ kind: "disconnectSessionResponse", requestId, ok: true }); diff --git a/js/packages/truapi-host/src/worker-wasm-import.test.ts b/js/packages/truapi-host/src/worker-wasm-import.test.ts deleted file mode 100644 index 9c762b798..000000000 --- a/js/packages/truapi-host/src/worker-wasm-import.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { existsSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -// The worker names the wasm glue in a literal import so bundlers resolve it -// statically and emit it alongside `truapi_server_bg.wasm`. Hidden behind a -// variable or `@vite-ignore` the import leaves the module graph, every bundler -// silently omits both files, and the failure only surfaces when a host's worker -// tries to instantiate the core. These assertions run against the compiled -// output because that is what a consuming bundler reads. - -const packageRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); -const workerEntry = join(packageRoot, "dist/worker-runtime.js"); -const GLUE_SPECIFIER = "./wasm/web/truapi_server.js"; -const gluePath = join(packageRoot, "dist", GLUE_SPECIFIER); - -// Every binding the worker reaches for through `WasmModuleShape`, and that -// `src/wasm/web/truapi_server.d.ts` declares. `default` is the wasm-pack init. -const GLUE_EXPORTS = [ - "default", - "WasmPairingHostRuntime", - "WasmProductRuntime", - "setLogLevel", -] as const; - -describe("worker wasm import", () => { - it("names the glue in a statically analysable import", () => { - expect( - existsSync(workerEntry), - `${workerEntry} is missing; run \`npm run build\` first`, - ).toBe(true); - const compiled = readFileSync(workerEntry, "utf8"); - - expect(compiled).toContain(`import("${GLUE_SPECIFIER}")`); - expect(compiled).not.toContain("@vite-ignore"); - }); - - // `make wasm` is a local step, so CI reaches here without the artifact. Skip - // visibly rather than returning early: a silent `return` reports as a pass - // and hides that the assertions never ran. - it.skipIf(!existsSync(gluePath))( - "resolves that specifier against a glue exporting the surface the worker uses", - async () => { - expect(readFileSync(gluePath, "utf8")).toContain("truapi_server_bg.wasm"); - - // The ambient declaration in `src/wasm/web/truapi_server.d.ts` is - // hand-written, so nothing else checks it against what `make wasm` - // actually emits. Importing the real glue catches a rename or removal - // that would otherwise only fail inside a host's worker at runtime. - const glue: Record = await import( - pathToFileURL(gluePath).href - ); - - for (const name of GLUE_EXPORTS) { - expect(typeof glue[name], `glue must export \`${name}\``).toBe( - "function", - ); - } - }, - ); -}); diff --git a/playground/public/fonts/Geist-OFL.txt b/playground/public/fonts/Geist-OFL.txt new file mode 100644 index 000000000..61f6f4b78 --- /dev/null +++ b/playground/public/fonts/Geist-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/playground/public/fonts/InstrumentSerif-OFL.txt b/playground/public/fonts/InstrumentSerif-OFL.txt new file mode 100644 index 000000000..669ce7917 --- /dev/null +++ b/playground/public/fonts/InstrumentSerif-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/playground/public/fonts/JetBrainsMono-OFL.txt b/playground/public/fonts/JetBrainsMono-OFL.txt new file mode 100644 index 000000000..821a3dac2 --- /dev/null +++ b/playground/public/fonts/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/playground/src/app/fonts/Geist.ttf b/playground/src/app/fonts/Geist.ttf new file mode 100644 index 000000000..f63f0afc6 Binary files /dev/null and b/playground/src/app/fonts/Geist.ttf differ diff --git a/playground/src/app/fonts/InstrumentSerif-Italic.ttf b/playground/src/app/fonts/InstrumentSerif-Italic.ttf new file mode 100644 index 000000000..192c3eda9 Binary files /dev/null and b/playground/src/app/fonts/InstrumentSerif-Italic.ttf differ diff --git a/playground/src/app/fonts/InstrumentSerif-Regular.ttf b/playground/src/app/fonts/InstrumentSerif-Regular.ttf new file mode 100644 index 000000000..36fac00ef Binary files /dev/null and b/playground/src/app/fonts/InstrumentSerif-Regular.ttf differ diff --git a/playground/src/app/fonts/JetBrainsMono.ttf b/playground/src/app/fonts/JetBrainsMono.ttf new file mode 100644 index 000000000..aa310be8b Binary files /dev/null and b/playground/src/app/fonts/JetBrainsMono.ttf differ diff --git a/playground/src/app/layout.tsx b/playground/src/app/layout.tsx index 52556d4f4..b5dd4aa5f 100644 --- a/playground/src/app/layout.tsx +++ b/playground/src/app/layout.tsx @@ -1,26 +1,38 @@ import type { Metadata, Viewport } from "next"; -import { Instrument_Serif, Geist, JetBrains_Mono } from "next/font/google"; +import localFont from "next/font/local"; import "./globals.css"; import packageJson from "../../package.json"; -const serif = Instrument_Serif({ - subsets: ["latin"], - weight: "400", - style: ["normal", "italic"], +const serif = localFont({ + src: [ + { + path: "./fonts/InstrumentSerif-Regular.ttf", + weight: "400", + style: "normal", + }, + { + path: "./fonts/InstrumentSerif-Italic.ttf", + weight: "400", + style: "italic", + }, + ], + adjustFontFallback: "Times New Roman", variable: "--font-serif", display: "swap", }); -const sans = Geist({ - subsets: ["latin"], - weight: ["400", "500", "600", "700"], +const sans = localFont({ + src: "./fonts/Geist.ttf", + weight: "400 700", + style: "normal", variable: "--font-sans", display: "swap", }); -const mono = JetBrains_Mono({ - subsets: ["latin"], - weight: ["400", "500", "600"], +const mono = localFont({ + src: "./fonts/JetBrainsMono.ttf", + weight: "400 600", + style: "normal", variable: "--font-mono", display: "swap", }); diff --git a/rust/crates/truapi-client/Cargo.toml b/rust/crates/truapi-client/Cargo.toml new file mode 100644 index 000000000..681cb3e45 --- /dev/null +++ b/rust/crates/truapi-client/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "truapi-client" +version = "0.16.0" +edition.workspace = true +license.workspace = true +description = "Transport-neutral no-std TrUAPI client codecs and generated method catalog" +publish = false + +[dependencies] +parity-scale-codec = { workspace = true } +truapi = { path = "../truapi", default-features = false } + +[lints] +workspace = true diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs new file mode 100644 index 000000000..20c47381b --- /dev/null +++ b/rust/crates/truapi-client/src/generated.rs @@ -0,0 +1,2541 @@ +//! Canonical TrUAPI client method catalog. +//! +//! Auto-generated by truapi-codegen. Do not edit. + +use super::*; + +/// Fingerprint of the generated wire contract. +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "7c073d1a8c5db314"; + +/// `account_connection_status_subscribe` method marker. +pub struct AccountConnectionStatusSubscribe; +impl AccountConnectionStatusSubscribe { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Account", + method: "connection_status_subscribe", + wire_name: "account_connection_status_subscribe", + request_type: "truapi::versioned::account::HostAccountConnectionStatusSubscribeRequest", + response_type: "truapi::versioned::account::HostAccountConnectionStatusSubscribeItem", + error_type: Some("truapi::versioned::account::HostAccountConnectionStatusSubscribeError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Subscription(MethodIds { + trait_id: 2, + method_id: 0, + }), + }; +} +impl SubscriptionMethod for AccountConnectionStatusSubscribe { + type Request = truapi::versioned::account::HostAccountConnectionStatusSubscribeRequest; + type Error = truapi::versioned::account::HostAccountConnectionStatusSubscribeError; + type Item = truapi::versioned::account::HostAccountConnectionStatusSubscribeItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `account_get_account` method marker. +pub struct AccountGetAccount; +impl AccountGetAccount { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Account", + method: "get_account", + wire_name: "account_get_account", + request_type: "truapi::versioned::account::HostAccountGetRequest", + response_type: "truapi::versioned::account::HostAccountGetResponse", + error_type: Some("truapi::versioned::account::HostAccountGetError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 1, + }), + }; +} +impl RequestMethod for AccountGetAccount { + type Request = truapi::versioned::account::HostAccountGetRequest; + type Response = truapi::versioned::account::HostAccountGetResponse; + type Error = truapi::versioned::account::HostAccountGetError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `account_get_account_alias` method marker. +pub struct AccountGetAccountAlias; +impl AccountGetAccountAlias { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Account", + method: "get_account_alias", + wire_name: "account_get_account_alias", + request_type: "truapi::versioned::account::HostAccountGetAliasRequest", + response_type: "truapi::versioned::account::HostAccountGetAliasResponse", + error_type: Some("truapi::versioned::account::HostAccountGetAliasError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 2, + }), + }; +} +impl RequestMethod for AccountGetAccountAlias { + type Request = truapi::versioned::account::HostAccountGetAliasRequest; + type Response = truapi::versioned::account::HostAccountGetAliasResponse; + type Error = truapi::versioned::account::HostAccountGetAliasError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `account_create_account_proof` method marker. +pub struct AccountCreateAccountProof; +impl AccountCreateAccountProof { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Account", + method: "create_account_proof", + wire_name: "account_create_account_proof", + request_type: "truapi::versioned::account::HostAccountCreateProofRequest", + response_type: "truapi::versioned::account::HostAccountCreateProofResponse", + error_type: Some("truapi::versioned::account::HostAccountCreateProofError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 3, + }), + }; +} +impl RequestMethod for AccountCreateAccountProof { + type Request = truapi::versioned::account::HostAccountCreateProofRequest; + type Response = truapi::versioned::account::HostAccountCreateProofResponse; + type Error = truapi::versioned::account::HostAccountCreateProofError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `account_sign_vrf` method marker. +pub struct AccountSignVrf; +impl AccountSignVrf { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Account", + method: "sign_vrf", + wire_name: "account_sign_vrf", + request_type: "truapi::versioned::account::HostAccountSignVrfRequest", + response_type: "truapi::versioned::account::HostAccountSignVrfResponse", + error_type: Some("truapi::versioned::account::HostAccountSignVrfError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 7, + }), + }; +} +impl RequestMethod for AccountSignVrf { + type Request = truapi::versioned::account::HostAccountSignVrfRequest; + type Response = truapi::versioned::account::HostAccountSignVrfResponse; + type Error = truapi::versioned::account::HostAccountSignVrfError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `account_register_ring_vrf_key` method marker. +pub struct AccountRegisterRingVrfKey; +impl AccountRegisterRingVrfKey { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Account", + method: "register_ring_vrf_key", + wire_name: "account_register_ring_vrf_key", + request_type: "truapi::versioned::account::HostAccountRegisterRingVrfKeyRequest", + response_type: "truapi::versioned::account::HostAccountRegisterRingVrfKeyResponse", + error_type: Some("truapi::versioned::account::HostAccountRegisterRingVrfKeyError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 8, + }), + }; +} +impl RequestMethod for AccountRegisterRingVrfKey { + type Request = truapi::versioned::account::HostAccountRegisterRingVrfKeyRequest; + type Response = truapi::versioned::account::HostAccountRegisterRingVrfKeyResponse; + type Error = truapi::versioned::account::HostAccountRegisterRingVrfKeyError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `account_list_ring_vrf_keys` method marker. +pub struct AccountListRingVrfKeys; +impl AccountListRingVrfKeys { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Account", + method: "list_ring_vrf_keys", + wire_name: "account_list_ring_vrf_keys", + request_type: "truapi::versioned::account::HostAccountListRingVrfKeysRequest", + response_type: "truapi::versioned::account::HostAccountListRingVrfKeysResponse", + error_type: Some("truapi::versioned::account::HostAccountListRingVrfKeysError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 9, + }), + }; +} +impl RequestMethod for AccountListRingVrfKeys { + type Request = truapi::versioned::account::HostAccountListRingVrfKeysRequest; + type Response = truapi::versioned::account::HostAccountListRingVrfKeysResponse; + type Error = truapi::versioned::account::HostAccountListRingVrfKeysError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `account_ring_vrf_sign` method marker. +pub struct AccountRingVrfSign; +impl AccountRingVrfSign { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Account", + method: "ring_vrf_sign", + wire_name: "account_ring_vrf_sign", + request_type: "truapi::versioned::account::HostAccountRingVrfSignRequest", + response_type: "truapi::versioned::account::HostAccountRingVrfSignResponse", + error_type: Some("truapi::versioned::account::HostAccountRingVrfSignError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 10, + }), + }; +} +impl RequestMethod for AccountRingVrfSign { + type Request = truapi::versioned::account::HostAccountRingVrfSignRequest; + type Response = truapi::versioned::account::HostAccountRingVrfSignResponse; + type Error = truapi::versioned::account::HostAccountRingVrfSignError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `account_get_legacy_accounts` method marker. +pub struct AccountGetLegacyAccounts; +impl AccountGetLegacyAccounts { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Account", + method: "get_legacy_accounts", + wire_name: "account_get_legacy_accounts", + request_type: "truapi::versioned::account::HostGetLegacyAccountsRequest", + response_type: "truapi::versioned::account::HostGetLegacyAccountsResponse", + error_type: Some("truapi::versioned::account::HostGetLegacyAccountsError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 4, + }), + }; +} +impl RequestMethod for AccountGetLegacyAccounts { + type Request = truapi::versioned::account::HostGetLegacyAccountsRequest; + type Response = truapi::versioned::account::HostGetLegacyAccountsResponse; + type Error = truapi::versioned::account::HostGetLegacyAccountsError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `account_get_user_id` method marker. +pub struct AccountGetUserId; +impl AccountGetUserId { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Account", + method: "get_user_id", + wire_name: "account_get_user_id", + request_type: "truapi::versioned::account::HostGetUserIdRequest", + response_type: "truapi::versioned::account::HostGetUserIdResponse", + error_type: Some("truapi::versioned::account::HostGetUserIdError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 5, + }), + }; +} +impl RequestMethod for AccountGetUserId { + type Request = truapi::versioned::account::HostGetUserIdRequest; + type Response = truapi::versioned::account::HostGetUserIdResponse; + type Error = truapi::versioned::account::HostGetUserIdError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `account_request_login` method marker. +pub struct AccountRequestLogin; +impl AccountRequestLogin { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Account", + method: "request_login", + wire_name: "account_request_login", + request_type: "truapi::versioned::account::HostRequestLoginRequest", + response_type: "truapi::versioned::account::HostRequestLoginResponse", + error_type: Some("truapi::versioned::account::HostRequestLoginError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 2, + method_id: 6, + }), + }; +} +impl RequestMethod for AccountRequestLogin { + type Request = truapi::versioned::account::HostRequestLoginRequest; + type Response = truapi::versioned::account::HostRequestLoginResponse; + type Error = truapi::versioned::account::HostRequestLoginError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chain_follow_head_subscribe` method marker. +pub struct ChainFollowHeadSubscribe; +impl ChainFollowHeadSubscribe { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chain", + method: "follow_head_subscribe", + wire_name: "chain_follow_head_subscribe", + request_type: "truapi::versioned::chain::RemoteChainHeadFollowRequest", + response_type: "truapi::versioned::chain::RemoteChainHeadFollowItem", + error_type: Some("truapi::versioned::chain::RemoteChainHeadFollowError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Subscription(MethodIds { + trait_id: 3, + method_id: 0, + }), + }; +} +impl SubscriptionMethod for ChainFollowHeadSubscribe { + type Request = truapi::versioned::chain::RemoteChainHeadFollowRequest; + type Error = truapi::versioned::chain::RemoteChainHeadFollowError; + type Item = truapi::versioned::chain::RemoteChainHeadFollowItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chain_get_head_header` method marker. +pub struct ChainGetHeadHeader; +impl ChainGetHeadHeader { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chain", + method: "get_head_header", + wire_name: "chain_get_head_header", + request_type: "truapi::versioned::chain::RemoteChainHeadHeaderRequest", + response_type: "truapi::versioned::chain::RemoteChainHeadHeaderResponse", + error_type: Some("truapi::versioned::chain::RemoteChainHeadHeaderError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 1, + }), + }; +} +impl RequestMethod for ChainGetHeadHeader { + type Request = truapi::versioned::chain::RemoteChainHeadHeaderRequest; + type Response = truapi::versioned::chain::RemoteChainHeadHeaderResponse; + type Error = truapi::versioned::chain::RemoteChainHeadHeaderError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chain_get_head_body` method marker. +pub struct ChainGetHeadBody; +impl ChainGetHeadBody { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chain", + method: "get_head_body", + wire_name: "chain_get_head_body", + request_type: "truapi::versioned::chain::RemoteChainHeadBodyRequest", + response_type: "truapi::versioned::chain::RemoteChainHeadBodyResponse", + error_type: Some("truapi::versioned::chain::RemoteChainHeadBodyError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 2, + }), + }; +} +impl RequestMethod for ChainGetHeadBody { + type Request = truapi::versioned::chain::RemoteChainHeadBodyRequest; + type Response = truapi::versioned::chain::RemoteChainHeadBodyResponse; + type Error = truapi::versioned::chain::RemoteChainHeadBodyError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chain_get_head_storage` method marker. +pub struct ChainGetHeadStorage; +impl ChainGetHeadStorage { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chain", + method: "get_head_storage", + wire_name: "chain_get_head_storage", + request_type: "truapi::versioned::chain::RemoteChainHeadStorageRequest", + response_type: "truapi::versioned::chain::RemoteChainHeadStorageResponse", + error_type: Some("truapi::versioned::chain::RemoteChainHeadStorageError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 3, + }), + }; +} +impl RequestMethod for ChainGetHeadStorage { + type Request = truapi::versioned::chain::RemoteChainHeadStorageRequest; + type Response = truapi::versioned::chain::RemoteChainHeadStorageResponse; + type Error = truapi::versioned::chain::RemoteChainHeadStorageError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chain_call_head` method marker. +pub struct ChainCallHead; +impl ChainCallHead { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chain", + method: "call_head", + wire_name: "chain_call_head", + request_type: "truapi::versioned::chain::RemoteChainHeadCallRequest", + response_type: "truapi::versioned::chain::RemoteChainHeadCallResponse", + error_type: Some("truapi::versioned::chain::RemoteChainHeadCallError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 4, + }), + }; +} +impl RequestMethod for ChainCallHead { + type Request = truapi::versioned::chain::RemoteChainHeadCallRequest; + type Response = truapi::versioned::chain::RemoteChainHeadCallResponse; + type Error = truapi::versioned::chain::RemoteChainHeadCallError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chain_unpin_head` method marker. +pub struct ChainUnpinHead; +impl ChainUnpinHead { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chain", + method: "unpin_head", + wire_name: "chain_unpin_head", + request_type: "truapi::versioned::chain::RemoteChainHeadUnpinRequest", + response_type: "truapi::versioned::chain::RemoteChainHeadUnpinResponse", + error_type: Some("truapi::versioned::chain::RemoteChainHeadUnpinError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 5, + }), + }; +} +impl RequestMethod for ChainUnpinHead { + type Request = truapi::versioned::chain::RemoteChainHeadUnpinRequest; + type Response = truapi::versioned::chain::RemoteChainHeadUnpinResponse; + type Error = truapi::versioned::chain::RemoteChainHeadUnpinError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chain_continue_head` method marker. +pub struct ChainContinueHead; +impl ChainContinueHead { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chain", + method: "continue_head", + wire_name: "chain_continue_head", + request_type: "truapi::versioned::chain::RemoteChainHeadContinueRequest", + response_type: "truapi::versioned::chain::RemoteChainHeadContinueResponse", + error_type: Some("truapi::versioned::chain::RemoteChainHeadContinueError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 6, + }), + }; +} +impl RequestMethod for ChainContinueHead { + type Request = truapi::versioned::chain::RemoteChainHeadContinueRequest; + type Response = truapi::versioned::chain::RemoteChainHeadContinueResponse; + type Error = truapi::versioned::chain::RemoteChainHeadContinueError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chain_stop_head_operation` method marker. +pub struct ChainStopHeadOperation; +impl ChainStopHeadOperation { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chain", + method: "stop_head_operation", + wire_name: "chain_stop_head_operation", + request_type: "truapi::versioned::chain::RemoteChainHeadStopOperationRequest", + response_type: "truapi::versioned::chain::RemoteChainHeadStopOperationResponse", + error_type: Some("truapi::versioned::chain::RemoteChainHeadStopOperationError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 7, + }), + }; +} +impl RequestMethod for ChainStopHeadOperation { + type Request = truapi::versioned::chain::RemoteChainHeadStopOperationRequest; + type Response = truapi::versioned::chain::RemoteChainHeadStopOperationResponse; + type Error = truapi::versioned::chain::RemoteChainHeadStopOperationError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chain_get_spec_genesis_hash` method marker. +pub struct ChainGetSpecGenesisHash; +impl ChainGetSpecGenesisHash { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chain", + method: "get_spec_genesis_hash", + wire_name: "chain_get_spec_genesis_hash", + request_type: "truapi::versioned::chain::RemoteChainSpecGenesisHashRequest", + response_type: "truapi::versioned::chain::RemoteChainSpecGenesisHashResponse", + error_type: Some("truapi::versioned::chain::RemoteChainSpecGenesisHashError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 8, + }), + }; +} +impl RequestMethod for ChainGetSpecGenesisHash { + type Request = truapi::versioned::chain::RemoteChainSpecGenesisHashRequest; + type Response = truapi::versioned::chain::RemoteChainSpecGenesisHashResponse; + type Error = truapi::versioned::chain::RemoteChainSpecGenesisHashError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chain_get_spec_chain_name` method marker. +pub struct ChainGetSpecChainName; +impl ChainGetSpecChainName { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chain", + method: "get_spec_chain_name", + wire_name: "chain_get_spec_chain_name", + request_type: "truapi::versioned::chain::RemoteChainSpecChainNameRequest", + response_type: "truapi::versioned::chain::RemoteChainSpecChainNameResponse", + error_type: Some("truapi::versioned::chain::RemoteChainSpecChainNameError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 9, + }), + }; +} +impl RequestMethod for ChainGetSpecChainName { + type Request = truapi::versioned::chain::RemoteChainSpecChainNameRequest; + type Response = truapi::versioned::chain::RemoteChainSpecChainNameResponse; + type Error = truapi::versioned::chain::RemoteChainSpecChainNameError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chain_get_spec_properties` method marker. +pub struct ChainGetSpecProperties; +impl ChainGetSpecProperties { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chain", + method: "get_spec_properties", + wire_name: "chain_get_spec_properties", + request_type: "truapi::versioned::chain::RemoteChainSpecPropertiesRequest", + response_type: "truapi::versioned::chain::RemoteChainSpecPropertiesResponse", + error_type: Some("truapi::versioned::chain::RemoteChainSpecPropertiesError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 10, + }), + }; +} +impl RequestMethod for ChainGetSpecProperties { + type Request = truapi::versioned::chain::RemoteChainSpecPropertiesRequest; + type Response = truapi::versioned::chain::RemoteChainSpecPropertiesResponse; + type Error = truapi::versioned::chain::RemoteChainSpecPropertiesError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chain_broadcast_transaction` method marker. +pub struct ChainBroadcastTransaction; +impl ChainBroadcastTransaction { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chain", + method: "broadcast_transaction", + wire_name: "chain_broadcast_transaction", + request_type: "truapi::versioned::chain::RemoteChainTransactionBroadcastRequest", + response_type: "truapi::versioned::chain::RemoteChainTransactionBroadcastResponse", + error_type: Some("truapi::versioned::chain::RemoteChainTransactionBroadcastError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 11, + }), + }; +} +impl RequestMethod for ChainBroadcastTransaction { + type Request = truapi::versioned::chain::RemoteChainTransactionBroadcastRequest; + type Response = truapi::versioned::chain::RemoteChainTransactionBroadcastResponse; + type Error = truapi::versioned::chain::RemoteChainTransactionBroadcastError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chain_stop_transaction` method marker. +pub struct ChainStopTransaction; +impl ChainStopTransaction { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chain", + method: "stop_transaction", + wire_name: "chain_stop_transaction", + request_type: "truapi::versioned::chain::RemoteChainTransactionStopRequest", + response_type: "truapi::versioned::chain::RemoteChainTransactionStopResponse", + error_type: Some("truapi::versioned::chain::RemoteChainTransactionStopError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 12, + }), + }; +} +impl RequestMethod for ChainStopTransaction { + type Request = truapi::versioned::chain::RemoteChainTransactionStopRequest; + type Response = truapi::versioned::chain::RemoteChainTransactionStopResponse; + type Error = truapi::versioned::chain::RemoteChainTransactionStopError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chain_get_chain_info` method marker. +pub struct ChainGetChainInfo; +impl ChainGetChainInfo { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chain", + method: "get_chain_info", + wire_name: "chain_get_chain_info", + request_type: "truapi::versioned::chain::RemoteChainInfoRequest", + response_type: "truapi::versioned::chain::RemoteChainInfoResponse", + error_type: Some("truapi::versioned::chain::RemoteChainInfoError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 3, + method_id: 13, + }), + }; +} +impl RequestMethod for ChainGetChainInfo { + type Request = truapi::versioned::chain::RemoteChainInfoRequest; + type Response = truapi::versioned::chain::RemoteChainInfoResponse; + type Error = truapi::versioned::chain::RemoteChainInfoError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chat_create_room` method marker. +pub struct ChatCreateRoom; +impl ChatCreateRoom { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chat", + method: "create_room", + wire_name: "chat_create_room", + request_type: "truapi::versioned::chat::HostChatCreateRoomRequest", + response_type: "truapi::versioned::chat::HostChatCreateRoomResponse", + error_type: Some("truapi::versioned::chat::HostChatCreateRoomError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: Some(ExecutionKind::Worker), + wire: MethodWire::Request(MethodIds { + trait_id: 4, + method_id: 0, + }), + }; +} +impl RequestMethod for ChatCreateRoom { + type Request = truapi::versioned::chat::HostChatCreateRoomRequest; + type Response = truapi::versioned::chat::HostChatCreateRoomResponse; + type Error = truapi::versioned::chat::HostChatCreateRoomError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chat_register_bot` method marker. +pub struct ChatRegisterBot; +impl ChatRegisterBot { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chat", + method: "register_bot", + wire_name: "chat_register_bot", + request_type: "truapi::versioned::chat::HostChatRegisterBotRequest", + response_type: "truapi::versioned::chat::HostChatRegisterBotResponse", + error_type: Some("truapi::versioned::chat::HostChatRegisterBotError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: Some(ExecutionKind::Worker), + wire: MethodWire::Request(MethodIds { + trait_id: 4, + method_id: 1, + }), + }; +} +impl RequestMethod for ChatRegisterBot { + type Request = truapi::versioned::chat::HostChatRegisterBotRequest; + type Response = truapi::versioned::chat::HostChatRegisterBotResponse; + type Error = truapi::versioned::chat::HostChatRegisterBotError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chat_list_subscribe` method marker. +pub struct ChatListSubscribe; +impl ChatListSubscribe { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chat", + method: "list_subscribe", + wire_name: "chat_list_subscribe", + request_type: "truapi::versioned::chat::HostChatListSubscribeRequest", + response_type: "truapi::versioned::chat::HostChatListSubscribeItem", + error_type: Some("truapi::versioned::chat::HostChatListSubscribeError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: Some(ExecutionKind::Worker), + wire: MethodWire::Subscription(MethodIds { + trait_id: 4, + method_id: 2, + }), + }; +} +impl SubscriptionMethod for ChatListSubscribe { + type Request = truapi::versioned::chat::HostChatListSubscribeRequest; + type Error = truapi::versioned::chat::HostChatListSubscribeError; + type Item = truapi::versioned::chat::HostChatListSubscribeItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chat_post_message` method marker. +pub struct ChatPostMessage; +impl ChatPostMessage { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chat", + method: "post_message", + wire_name: "chat_post_message", + request_type: "truapi::versioned::chat::HostChatPostMessageRequest", + response_type: "truapi::versioned::chat::HostChatPostMessageResponse", + error_type: Some("truapi::versioned::chat::HostChatPostMessageError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: Some(ExecutionKind::Worker), + wire: MethodWire::Request(MethodIds { + trait_id: 4, + method_id: 3, + }), + }; +} +impl RequestMethod for ChatPostMessage { + type Request = truapi::versioned::chat::HostChatPostMessageRequest; + type Response = truapi::versioned::chat::HostChatPostMessageResponse; + type Error = truapi::versioned::chat::HostChatPostMessageError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `chat_action_subscribe` method marker. +pub struct ChatActionSubscribe; +impl ChatActionSubscribe { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Chat", + method: "action_subscribe", + wire_name: "chat_action_subscribe", + request_type: "truapi::versioned::chat::HostChatActionSubscribeRequest", + response_type: "truapi::versioned::chat::HostChatActionSubscribeItem", + error_type: Some("truapi::versioned::chat::HostChatActionSubscribeError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: Some(ExecutionKind::Worker), + wire: MethodWire::Subscription(MethodIds { + trait_id: 4, + method_id: 4, + }), + }; +} +impl SubscriptionMethod for ChatActionSubscribe { + type Request = truapi::versioned::chat::HostChatActionSubscribeRequest; + type Error = truapi::versioned::chat::HostChatActionSubscribeError; + type Item = truapi::versioned::chat::HostChatActionSubscribeItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `coin_payment_create_purse` method marker. +pub struct CoinPaymentCreatePurse; +impl CoinPaymentCreatePurse { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "CoinPayment", + method: "create_purse", + wire_name: "coin_payment_create_purse", + request_type: "truapi::versioned::coin_payment::HostCoinPaymentCreatePurseRequest", + response_type: "truapi::versioned::coin_payment::HostCoinPaymentCreatePurseResponse", + error_type: Some("truapi::versioned::coin_payment::HostCoinPaymentCreatePurseError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 5, + method_id: 0, + }), + }; +} +impl RequestMethod for CoinPaymentCreatePurse { + type Request = truapi::versioned::coin_payment::HostCoinPaymentCreatePurseRequest; + type Response = truapi::versioned::coin_payment::HostCoinPaymentCreatePurseResponse; + type Error = truapi::versioned::coin_payment::HostCoinPaymentCreatePurseError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `coin_payment_query_purse` method marker. +pub struct CoinPaymentQueryPurse; +impl CoinPaymentQueryPurse { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "CoinPayment", + method: "query_purse", + wire_name: "coin_payment_query_purse", + request_type: "truapi::versioned::coin_payment::HostCoinPaymentQueryPurseRequest", + response_type: "truapi::versioned::coin_payment::HostCoinPaymentQueryPurseResponse", + error_type: Some("truapi::versioned::coin_payment::HostCoinPaymentQueryPurseError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 5, + method_id: 1, + }), + }; +} +impl RequestMethod for CoinPaymentQueryPurse { + type Request = truapi::versioned::coin_payment::HostCoinPaymentQueryPurseRequest; + type Response = truapi::versioned::coin_payment::HostCoinPaymentQueryPurseResponse; + type Error = truapi::versioned::coin_payment::HostCoinPaymentQueryPurseError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `coin_payment_rebalance_purse` method marker. +pub struct CoinPaymentRebalancePurse; +impl CoinPaymentRebalancePurse { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "CoinPayment", + method: "rebalance_purse", + wire_name: "coin_payment_rebalance_purse", + request_type: "truapi::versioned::coin_payment::HostCoinPaymentRebalancePurseRequest", + response_type: "truapi::versioned::coin_payment::HostCoinPaymentRebalancePurseItem", + error_type: Some("truapi::versioned::coin_payment::HostCoinPaymentRebalancePurseError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Subscription(MethodIds { + trait_id: 5, + method_id: 2, + }), + }; +} +impl SubscriptionMethod for CoinPaymentRebalancePurse { + type Request = truapi::versioned::coin_payment::HostCoinPaymentRebalancePurseRequest; + type Error = truapi::versioned::coin_payment::HostCoinPaymentRebalancePurseError; + type Item = truapi::versioned::coin_payment::HostCoinPaymentRebalancePurseItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `coin_payment_delete_purse` method marker. +pub struct CoinPaymentDeletePurse; +impl CoinPaymentDeletePurse { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "CoinPayment", + method: "delete_purse", + wire_name: "coin_payment_delete_purse", + request_type: "truapi::versioned::coin_payment::HostCoinPaymentDeletePurseRequest", + response_type: "truapi::versioned::coin_payment::HostCoinPaymentDeletePurseItem", + error_type: Some("truapi::versioned::coin_payment::HostCoinPaymentDeletePurseError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Subscription(MethodIds { + trait_id: 5, + method_id: 3, + }), + }; +} +impl SubscriptionMethod for CoinPaymentDeletePurse { + type Request = truapi::versioned::coin_payment::HostCoinPaymentDeletePurseRequest; + type Error = truapi::versioned::coin_payment::HostCoinPaymentDeletePurseError; + type Item = truapi::versioned::coin_payment::HostCoinPaymentDeletePurseItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `coin_payment_create_receivable` method marker. +pub struct CoinPaymentCreateReceivable; +impl CoinPaymentCreateReceivable { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "CoinPayment", + method: "create_receivable", + wire_name: "coin_payment_create_receivable", + request_type: "truapi::versioned::coin_payment::HostCoinPaymentCreateReceivableRequest", + response_type: "truapi::versioned::coin_payment::HostCoinPaymentCreateReceivableResponse", + error_type: Some("truapi::versioned::coin_payment::HostCoinPaymentCreateReceivableError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 5, + method_id: 4, + }), + }; +} +impl RequestMethod for CoinPaymentCreateReceivable { + type Request = truapi::versioned::coin_payment::HostCoinPaymentCreateReceivableRequest; + type Response = truapi::versioned::coin_payment::HostCoinPaymentCreateReceivableResponse; + type Error = truapi::versioned::coin_payment::HostCoinPaymentCreateReceivableError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `coin_payment_create_cheque` method marker. +pub struct CoinPaymentCreateCheque; +impl CoinPaymentCreateCheque { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "CoinPayment", + method: "create_cheque", + wire_name: "coin_payment_create_cheque", + request_type: "truapi::versioned::coin_payment::HostCoinPaymentCreateChequeRequest", + response_type: "truapi::versioned::coin_payment::HostCoinPaymentCreateChequeResponse", + error_type: Some("truapi::versioned::coin_payment::HostCoinPaymentCreateChequeError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 5, + method_id: 5, + }), + }; +} +impl RequestMethod for CoinPaymentCreateCheque { + type Request = truapi::versioned::coin_payment::HostCoinPaymentCreateChequeRequest; + type Response = truapi::versioned::coin_payment::HostCoinPaymentCreateChequeResponse; + type Error = truapi::versioned::coin_payment::HostCoinPaymentCreateChequeError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `coin_payment_deposit` method marker. +pub struct CoinPaymentDeposit; +impl CoinPaymentDeposit { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "CoinPayment", + method: "deposit", + wire_name: "coin_payment_deposit", + request_type: "truapi::versioned::coin_payment::HostCoinPaymentDepositRequest", + response_type: "truapi::versioned::coin_payment::HostCoinPaymentDepositItem", + error_type: Some("truapi::versioned::coin_payment::HostCoinPaymentDepositError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Subscription(MethodIds { + trait_id: 5, + method_id: 6, + }), + }; +} +impl SubscriptionMethod for CoinPaymentDeposit { + type Request = truapi::versioned::coin_payment::HostCoinPaymentDepositRequest; + type Error = truapi::versioned::coin_payment::HostCoinPaymentDepositError; + type Item = truapi::versioned::coin_payment::HostCoinPaymentDepositItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `coin_payment_refund` method marker. +pub struct CoinPaymentRefund; +impl CoinPaymentRefund { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "CoinPayment", + method: "refund", + wire_name: "coin_payment_refund", + request_type: "truapi::versioned::coin_payment::HostCoinPaymentRefundRequest", + response_type: "truapi::versioned::coin_payment::HostCoinPaymentRefundItem", + error_type: Some("truapi::versioned::coin_payment::HostCoinPaymentRefundError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Subscription(MethodIds { + trait_id: 5, + method_id: 7, + }), + }; +} +impl SubscriptionMethod for CoinPaymentRefund { + type Request = truapi::versioned::coin_payment::HostCoinPaymentRefundRequest; + type Error = truapi::versioned::coin_payment::HostCoinPaymentRefundError; + type Item = truapi::versioned::coin_payment::HostCoinPaymentRefundItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `coin_payment_listen_for_payment` method marker. +pub struct CoinPaymentListenForPayment; +impl CoinPaymentListenForPayment { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "CoinPayment", + method: "listen_for_payment", + wire_name: "coin_payment_listen_for_payment", + request_type: "truapi::versioned::coin_payment::HostCoinPaymentListenForRequest", + response_type: "truapi::versioned::coin_payment::HostCoinPaymentListenForItem", + error_type: Some("truapi::versioned::coin_payment::HostCoinPaymentListenForError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Subscription(MethodIds { + trait_id: 5, + method_id: 8, + }), + }; +} +impl SubscriptionMethod for CoinPaymentListenForPayment { + type Request = truapi::versioned::coin_payment::HostCoinPaymentListenForRequest; + type Error = truapi::versioned::coin_payment::HostCoinPaymentListenForError; + type Item = truapi::versioned::coin_payment::HostCoinPaymentListenForItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `contacts_pick` method marker. +pub struct ContactsPick; +impl ContactsPick { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Contacts", + method: "pick", + wire_name: "contacts_pick", + request_type: "truapi::versioned::contacts::HostContactsPickRequest", + response_type: "truapi::versioned::contacts::HostContactsPickResponse", + error_type: Some("truapi::versioned::contacts::HostContactsPickError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 20, + method_id: 0, + }), + }; +} +impl RequestMethod for ContactsPick { + type Request = truapi::versioned::contacts::HostContactsPickRequest; + type Response = truapi::versioned::contacts::HostContactsPickResponse; + type Error = truapi::versioned::contacts::HostContactsPickError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `entropy_derive` method marker. +pub struct EntropyDerive; +impl EntropyDerive { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Entropy", + method: "derive", + wire_name: "entropy_derive", + request_type: "truapi::versioned::entropy::HostDeriveEntropyRequest", + response_type: "truapi::versioned::entropy::HostDeriveEntropyResponse", + error_type: Some("truapi::versioned::entropy::HostDeriveEntropyError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 6, + method_id: 0, + }), + }; +} +impl RequestMethod for EntropyDerive { + type Request = truapi::versioned::entropy::HostDeriveEntropyRequest; + type Response = truapi::versioned::entropy::HostDeriveEntropyResponse; + type Error = truapi::versioned::entropy::HostDeriveEntropyError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `local_storage_read` method marker. +pub struct LocalStorageRead; +impl LocalStorageRead { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "LocalStorage", + method: "read", + wire_name: "local_storage_read", + request_type: "truapi::versioned::local_storage::HostLocalStorageReadRequest", + response_type: "truapi::versioned::local_storage::HostLocalStorageReadResponse", + error_type: Some("truapi::versioned::local_storage::HostLocalStorageReadError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 7, + method_id: 0, + }), + }; +} +impl RequestMethod for LocalStorageRead { + type Request = truapi::versioned::local_storage::HostLocalStorageReadRequest; + type Response = truapi::versioned::local_storage::HostLocalStorageReadResponse; + type Error = truapi::versioned::local_storage::HostLocalStorageReadError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `local_storage_write` method marker. +pub struct LocalStorageWrite; +impl LocalStorageWrite { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "LocalStorage", + method: "write", + wire_name: "local_storage_write", + request_type: "truapi::versioned::local_storage::HostLocalStorageWriteRequest", + response_type: "truapi::versioned::local_storage::HostLocalStorageWriteResponse", + error_type: Some("truapi::versioned::local_storage::HostLocalStorageWriteError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 7, + method_id: 1, + }), + }; +} +impl RequestMethod for LocalStorageWrite { + type Request = truapi::versioned::local_storage::HostLocalStorageWriteRequest; + type Response = truapi::versioned::local_storage::HostLocalStorageWriteResponse; + type Error = truapi::versioned::local_storage::HostLocalStorageWriteError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `local_storage_clear` method marker. +pub struct LocalStorageClear; +impl LocalStorageClear { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "LocalStorage", + method: "clear", + wire_name: "local_storage_clear", + request_type: "truapi::versioned::local_storage::HostLocalStorageClearRequest", + response_type: "truapi::versioned::local_storage::HostLocalStorageClearResponse", + error_type: Some("truapi::versioned::local_storage::HostLocalStorageClearError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 7, + method_id: 2, + }), + }; +} +impl RequestMethod for LocalStorageClear { + type Request = truapi::versioned::local_storage::HostLocalStorageClearRequest; + type Response = truapi::versioned::local_storage::HostLocalStorageClearResponse; + type Error = truapi::versioned::local_storage::HostLocalStorageClearError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `local_storage_subscribe` method marker. +pub struct LocalStorageSubscribe; +impl LocalStorageSubscribe { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "LocalStorage", + method: "subscribe", + wire_name: "local_storage_subscribe", + request_type: "truapi::versioned::local_storage::HostLocalStorageSubscribeRequest", + response_type: "truapi::versioned::local_storage::HostLocalStorageChangeItem", + error_type: Some("truapi::versioned::local_storage::HostLocalStorageSubscribeError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Subscription(MethodIds { + trait_id: 7, + method_id: 3, + }), + }; +} +impl SubscriptionMethod for LocalStorageSubscribe { + type Request = truapi::versioned::local_storage::HostLocalStorageSubscribeRequest; + type Error = truapi::versioned::local_storage::HostLocalStorageSubscribeError; + type Item = truapi::versioned::local_storage::HostLocalStorageChangeItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `locale_subscribe` method marker. +pub struct LocaleSubscribe; +impl LocaleSubscribe { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Locale", + method: "subscribe", + wire_name: "locale_subscribe", + request_type: "truapi::versioned::locale::HostLocaleSubscribeRequest", + response_type: "truapi::versioned::locale::HostLocaleSubscribeItem", + error_type: Some("truapi::versioned::locale::HostLocaleSubscribeError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Subscription(MethodIds { + trait_id: 16, + method_id: 0, + }), + }; +} +impl SubscriptionMethod for LocaleSubscribe { + type Request = truapi::versioned::locale::HostLocaleSubscribeRequest; + type Error = truapi::versioned::locale::HostLocaleSubscribeError; + type Item = truapi::versioned::locale::HostLocaleSubscribeItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `locale_localize_timestamps` method marker. +pub struct LocaleLocalizeTimestamps; +impl LocaleLocalizeTimestamps { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Locale", + method: "localize_timestamps", + wire_name: "locale_localize_timestamps", + request_type: "truapi::versioned::locale::HostLocaleLocalizeTimestampsRequest", + response_type: "truapi::versioned::locale::HostLocaleLocalizeTimestampsResponse", + error_type: Some("truapi::versioned::locale::HostLocaleLocalizeTimestampsError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 16, + method_id: 1, + }), + }; +} +impl RequestMethod for LocaleLocalizeTimestamps { + type Request = truapi::versioned::locale::HostLocaleLocalizeTimestampsRequest; + type Response = truapi::versioned::locale::HostLocaleLocalizeTimestampsResponse; + type Error = truapi::versioned::locale::HostLocaleLocalizeTimestampsError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `notifications_send_push_notification` method marker. +pub struct NotificationsSendPushNotification; +impl NotificationsSendPushNotification { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Notifications", + method: "send_push_notification", + wire_name: "notifications_send_push_notification", + request_type: "truapi::versioned::notifications::HostPushNotificationRequest", + response_type: "truapi::versioned::notifications::HostPushNotificationResponse", + error_type: Some("truapi::versioned::notifications::HostPushNotificationError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 8, + method_id: 0, + }), + }; +} +impl RequestMethod for NotificationsSendPushNotification { + type Request = truapi::versioned::notifications::HostPushNotificationRequest; + type Response = truapi::versioned::notifications::HostPushNotificationResponse; + type Error = truapi::versioned::notifications::HostPushNotificationError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `notifications_cancel_push_notification` method marker. +pub struct NotificationsCancelPushNotification; +impl NotificationsCancelPushNotification { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Notifications", + method: "cancel_push_notification", + wire_name: "notifications_cancel_push_notification", + request_type: "truapi::versioned::notifications::HostPushNotificationCancelRequest", + response_type: "truapi::versioned::notifications::HostPushNotificationCancelResponse", + error_type: Some("truapi::versioned::notifications::HostPushNotificationCancelError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 8, + method_id: 1, + }), + }; +} +impl RequestMethod for NotificationsCancelPushNotification { + type Request = truapi::versioned::notifications::HostPushNotificationCancelRequest; + type Response = truapi::versioned::notifications::HostPushNotificationCancelResponse; + type Error = truapi::versioned::notifications::HostPushNotificationCancelError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `payment_balance_subscribe` method marker. +pub struct PaymentBalanceSubscribe; +impl PaymentBalanceSubscribe { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Payment", + method: "balance_subscribe", + wire_name: "payment_balance_subscribe", + request_type: "truapi::versioned::payment::HostPaymentBalanceSubscribeRequest", + response_type: "truapi::versioned::payment::HostPaymentBalanceSubscribeItem", + error_type: Some("truapi::versioned::payment::HostPaymentBalanceSubscribeError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Subscription(MethodIds { + trait_id: 9, + method_id: 0, + }), + }; +} +impl SubscriptionMethod for PaymentBalanceSubscribe { + type Request = truapi::versioned::payment::HostPaymentBalanceSubscribeRequest; + type Error = truapi::versioned::payment::HostPaymentBalanceSubscribeError; + type Item = truapi::versioned::payment::HostPaymentBalanceSubscribeItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `payment_request` method marker. +pub struct PaymentRequest; +impl PaymentRequest { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Payment", + method: "request", + wire_name: "payment_request", + request_type: "truapi::versioned::payment::HostPaymentRequest", + response_type: "truapi::versioned::payment::HostPaymentResponse", + error_type: Some("truapi::versioned::payment::HostPaymentError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 9, + method_id: 2, + }), + }; +} +impl RequestMethod for PaymentRequest { + type Request = truapi::versioned::payment::HostPaymentRequest; + type Response = truapi::versioned::payment::HostPaymentResponse; + type Error = truapi::versioned::payment::HostPaymentError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `payment_status_subscribe` method marker. +pub struct PaymentStatusSubscribe; +impl PaymentStatusSubscribe { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Payment", + method: "status_subscribe", + wire_name: "payment_status_subscribe", + request_type: "truapi::versioned::payment::HostPaymentStatusSubscribeRequest", + response_type: "truapi::versioned::payment::HostPaymentStatusSubscribeItem", + error_type: Some("truapi::versioned::payment::HostPaymentStatusSubscribeError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Subscription(MethodIds { + trait_id: 9, + method_id: 3, + }), + }; +} +impl SubscriptionMethod for PaymentStatusSubscribe { + type Request = truapi::versioned::payment::HostPaymentStatusSubscribeRequest; + type Error = truapi::versioned::payment::HostPaymentStatusSubscribeError; + type Item = truapi::versioned::payment::HostPaymentStatusSubscribeItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `payment_top_up` method marker. +pub struct PaymentTopUp; +impl PaymentTopUp { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Payment", + method: "top_up", + wire_name: "payment_top_up", + request_type: "truapi::versioned::payment::HostPaymentTopUpRequest", + response_type: "truapi::versioned::payment::HostPaymentTopUpResponse", + error_type: Some("truapi::versioned::payment::HostPaymentTopUpError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 9, + method_id: 1, + }), + }; +} +impl RequestMethod for PaymentTopUp { + type Request = truapi::versioned::payment::HostPaymentTopUpRequest; + type Response = truapi::versioned::payment::HostPaymentTopUpResponse; + type Error = truapi::versioned::payment::HostPaymentTopUpError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `permissions_request_device_permission` method marker. +pub struct PermissionsRequestDevicePermission; +impl PermissionsRequestDevicePermission { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Permissions", + method: "request_device_permission", + wire_name: "permissions_request_device_permission", + request_type: "truapi::versioned::permissions::HostDevicePermissionRequest", + response_type: "truapi::versioned::permissions::HostDevicePermissionResponse", + error_type: Some("truapi::versioned::permissions::HostDevicePermissionError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 10, + method_id: 0, + }), + }; +} +impl RequestMethod for PermissionsRequestDevicePermission { + type Request = truapi::versioned::permissions::HostDevicePermissionRequest; + type Response = truapi::versioned::permissions::HostDevicePermissionResponse; + type Error = truapi::versioned::permissions::HostDevicePermissionError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `permissions_request_remote_permission` method marker. +pub struct PermissionsRequestRemotePermission; +impl PermissionsRequestRemotePermission { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Permissions", + method: "request_remote_permission", + wire_name: "permissions_request_remote_permission", + request_type: "truapi::versioned::permissions::RemotePermissionRequest", + response_type: "truapi::versioned::permissions::RemotePermissionResponse", + error_type: Some("truapi::versioned::permissions::RemotePermissionError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 10, + method_id: 1, + }), + }; +} +impl RequestMethod for PermissionsRequestRemotePermission { + type Request = truapi::versioned::permissions::RemotePermissionRequest; + type Response = truapi::versioned::permissions::RemotePermissionResponse; + type Error = truapi::versioned::permissions::RemotePermissionError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `permissions_authorize_remote_permission` method marker. +pub struct PermissionsAuthorizeRemotePermission; +impl PermissionsAuthorizeRemotePermission { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Permissions", + method: "authorize_remote_permission", + wire_name: "permissions_authorize_remote_permission", + request_type: "truapi::versioned::permissions::RemotePermissionRequest", + response_type: "truapi::versioned::permissions::RemotePermissionResponse", + error_type: Some("truapi::versioned::permissions::RemotePermissionError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 10, + method_id: 2, + }), + }; +} +impl RequestMethod for PermissionsAuthorizeRemotePermission { + type Request = truapi::versioned::permissions::RemotePermissionRequest; + type Response = truapi::versioned::permissions::RemotePermissionResponse; + type Error = truapi::versioned::permissions::RemotePermissionError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `permissions_authorize_device_permission` method marker. +pub struct PermissionsAuthorizeDevicePermission; +impl PermissionsAuthorizeDevicePermission { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Permissions", + method: "authorize_device_permission", + wire_name: "permissions_authorize_device_permission", + request_type: "truapi::versioned::permissions::HostDevicePermissionRequest", + response_type: "truapi::versioned::permissions::HostDevicePermissionResponse", + error_type: Some("truapi::versioned::permissions::HostDevicePermissionError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 10, + method_id: 3, + }), + }; +} +impl RequestMethod for PermissionsAuthorizeDevicePermission { + type Request = truapi::versioned::permissions::HostDevicePermissionRequest; + type Response = truapi::versioned::permissions::HostDevicePermissionResponse; + type Error = truapi::versioned::permissions::HostDevicePermissionError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `pocket_list_subscribe` method marker. +pub struct PocketListSubscribe; +impl PocketListSubscribe { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Pocket", + method: "list_subscribe", + wire_name: "pocket_list_subscribe", + request_type: "truapi::versioned::pocket::HostPocketListSubscribeRequest", + response_type: "truapi::versioned::pocket::HostPocketListSubscribeItem", + error_type: Some("truapi::versioned::pocket::HostPocketListSubscribeError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: Some(ExecutionKind::Worker), + wire: MethodWire::Subscription(MethodIds { + trait_id: 18, + method_id: 0, + }), + }; +} +impl SubscriptionMethod for PocketListSubscribe { + type Request = truapi::versioned::pocket::HostPocketListSubscribeRequest; + type Error = truapi::versioned::pocket::HostPocketListSubscribeError; + type Item = truapi::versioned::pocket::HostPocketListSubscribeItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `pocket_remove_card` method marker. +pub struct PocketRemoveCard; +impl PocketRemoveCard { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Pocket", + method: "remove_card", + wire_name: "pocket_remove_card", + request_type: "truapi::versioned::pocket::HostPocketRemoveCardRequest", + response_type: "truapi::versioned::pocket::HostPocketRemoveCardResponse", + error_type: Some("truapi::versioned::pocket::HostPocketRemoveCardError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: Some(ExecutionKind::Worker), + wire: MethodWire::Request(MethodIds { + trait_id: 18, + method_id: 1, + }), + }; +} +impl RequestMethod for PocketRemoveCard { + type Request = truapi::versioned::pocket::HostPocketRemoveCardRequest; + type Response = truapi::versioned::pocket::HostPocketRemoveCardResponse; + type Error = truapi::versioned::pocket::HostPocketRemoveCardError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `preimage_lookup_subscribe` method marker. +pub struct PreimageLookupSubscribe; +impl PreimageLookupSubscribe { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Preimage", + method: "lookup_subscribe", + wire_name: "preimage_lookup_subscribe", + request_type: "truapi::versioned::preimage::RemotePreimageLookupSubscribeRequest", + response_type: "truapi::versioned::preimage::RemotePreimageLookupSubscribeItem", + error_type: Some("truapi::versioned::preimage::RemotePreimageLookupSubscribeError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Subscription(MethodIds { + trait_id: 11, + method_id: 0, + }), + }; +} +impl SubscriptionMethod for PreimageLookupSubscribe { + type Request = truapi::versioned::preimage::RemotePreimageLookupSubscribeRequest; + type Error = truapi::versioned::preimage::RemotePreimageLookupSubscribeError; + type Item = truapi::versioned::preimage::RemotePreimageLookupSubscribeItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `preimage_submit` method marker. +pub struct PreimageSubmit; +impl PreimageSubmit { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Preimage", + method: "submit", + wire_name: "preimage_submit", + request_type: "truapi::versioned::preimage::RemotePreimageSubmitRequest", + response_type: "truapi::versioned::preimage::RemotePreimageSubmitResponse", + error_type: Some("truapi::versioned::preimage::RemotePreimageSubmitError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 11, + method_id: 1, + }), + }; +} +impl RequestMethod for PreimageSubmit { + type Request = truapi::versioned::preimage::RemotePreimageSubmitRequest; + type Response = truapi::versioned::preimage::RemotePreimageSubmitResponse; + type Error = truapi::versioned::preimage::RemotePreimageSubmitError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `renderer_render` method marker. +pub struct RendererRender; +impl RendererRender { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Renderer", + method: "render", + wire_name: "renderer_render", + request_type: "truapi::versioned::renderer::ProductRendererRenderRequest", + response_type: "truapi::versioned::renderer::ProductRendererRenderItem", + error_type: Some("truapi::versioned::renderer::ProductRendererRenderError"), + kind: MethodKind::Subscription, + direction: Direction::HostToProduct, + required_execution: Some(ExecutionKind::Worker), + wire: MethodWire::Subscription(MethodIds { + trait_id: 17, + method_id: 0, + }), + }; +} +impl HostSubscriptionMethod for RendererRender { + type Request = truapi::versioned::renderer::ProductRendererRenderRequest; + type Error = truapi::versioned::renderer::ProductRendererRenderError; + type Item = truapi::versioned::renderer::ProductRendererRenderItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `renderer_action_subscribe` method marker. +pub struct RendererActionSubscribe; +impl RendererActionSubscribe { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Renderer", + method: "action_subscribe", + wire_name: "renderer_action_subscribe", + request_type: "truapi::versioned::renderer::HostRendererActionSubscribeRequest", + response_type: "truapi::versioned::renderer::HostRendererActionSubscribeItem", + error_type: Some("truapi::versioned::renderer::HostRendererActionSubscribeError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: Some(ExecutionKind::Worker), + wire: MethodWire::Subscription(MethodIds { + trait_id: 17, + method_id: 1, + }), + }; +} +impl SubscriptionMethod for RendererActionSubscribe { + type Request = truapi::versioned::renderer::HostRendererActionSubscribeRequest; + type Error = truapi::versioned::renderer::HostRendererActionSubscribeError; + type Item = truapi::versioned::renderer::HostRendererActionSubscribeItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `resource_allocation_request` method marker. +pub struct ResourceAllocationRequest; +impl ResourceAllocationRequest { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "ResourceAllocation", + method: "request", + wire_name: "resource_allocation_request", + request_type: "truapi::versioned::resource_allocation::HostRequestResourceAllocationRequest", + response_type: "truapi::versioned::resource_allocation::HostRequestResourceAllocationResponse", + error_type: Some( + "truapi::versioned::resource_allocation::HostRequestResourceAllocationError", + ), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 12, + method_id: 0, + }), + }; +} +impl RequestMethod for ResourceAllocationRequest { + type Request = truapi::versioned::resource_allocation::HostRequestResourceAllocationRequest; + type Response = truapi::versioned::resource_allocation::HostRequestResourceAllocationResponse; + type Error = truapi::versioned::resource_allocation::HostRequestResourceAllocationError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `signing_create_transaction` method marker. +pub struct SigningCreateTransaction; +impl SigningCreateTransaction { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Signing", + method: "create_transaction", + wire_name: "signing_create_transaction", + request_type: "truapi::versioned::signing::HostCreateTransactionRequest", + response_type: "truapi::versioned::signing::HostCreateTransactionResponse", + error_type: Some("truapi::versioned::signing::HostCreateTransactionError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 13, + method_id: 0, + }), + }; +} +impl RequestMethod for SigningCreateTransaction { + type Request = truapi::versioned::signing::HostCreateTransactionRequest; + type Response = truapi::versioned::signing::HostCreateTransactionResponse; + type Error = truapi::versioned::signing::HostCreateTransactionError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `signing_create_transaction_with_legacy_account` method marker. +pub struct SigningCreateTransactionWithLegacyAccount; +impl SigningCreateTransactionWithLegacyAccount { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Signing", + method: "create_transaction_with_legacy_account", + wire_name: "signing_create_transaction_with_legacy_account", + request_type: "truapi::versioned::signing::HostCreateTransactionWithLegacyAccountRequest", + response_type: "truapi::versioned::signing::HostCreateTransactionWithLegacyAccountResponse", + error_type: Some("truapi::versioned::signing::HostCreateTransactionWithLegacyAccountError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 13, + method_id: 1, + }), + }; +} +impl RequestMethod for SigningCreateTransactionWithLegacyAccount { + type Request = truapi::versioned::signing::HostCreateTransactionWithLegacyAccountRequest; + type Response = truapi::versioned::signing::HostCreateTransactionWithLegacyAccountResponse; + type Error = truapi::versioned::signing::HostCreateTransactionWithLegacyAccountError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `signing_sign_raw_with_legacy_account` method marker. +pub struct SigningSignRawWithLegacyAccount; +impl SigningSignRawWithLegacyAccount { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Signing", + method: "sign_raw_with_legacy_account", + wire_name: "signing_sign_raw_with_legacy_account", + request_type: "truapi::versioned::signing::HostSignRawWithLegacyAccountRequest", + response_type: "truapi::versioned::signing::HostSignRawWithLegacyAccountResponse", + error_type: Some("truapi::versioned::signing::HostSignRawWithLegacyAccountError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 13, + method_id: 2, + }), + }; +} +impl RequestMethod for SigningSignRawWithLegacyAccount { + type Request = truapi::versioned::signing::HostSignRawWithLegacyAccountRequest; + type Response = truapi::versioned::signing::HostSignRawWithLegacyAccountResponse; + type Error = truapi::versioned::signing::HostSignRawWithLegacyAccountError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `signing_sign_payload_with_legacy_account` method marker. +pub struct SigningSignPayloadWithLegacyAccount; +impl SigningSignPayloadWithLegacyAccount { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Signing", + method: "sign_payload_with_legacy_account", + wire_name: "signing_sign_payload_with_legacy_account", + request_type: "truapi::versioned::signing::HostSignPayloadWithLegacyAccountRequest", + response_type: "truapi::versioned::signing::HostSignPayloadWithLegacyAccountResponse", + error_type: Some("truapi::versioned::signing::HostSignPayloadWithLegacyAccountError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 13, + method_id: 3, + }), + }; +} +impl RequestMethod for SigningSignPayloadWithLegacyAccount { + type Request = truapi::versioned::signing::HostSignPayloadWithLegacyAccountRequest; + type Response = truapi::versioned::signing::HostSignPayloadWithLegacyAccountResponse; + type Error = truapi::versioned::signing::HostSignPayloadWithLegacyAccountError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `signing_sign_raw` method marker. +pub struct SigningSignRaw; +impl SigningSignRaw { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Signing", + method: "sign_raw", + wire_name: "signing_sign_raw", + request_type: "truapi::versioned::signing::HostSignRawRequest", + response_type: "truapi::versioned::signing::HostSignRawResponse", + error_type: Some("truapi::versioned::signing::HostSignRawError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 13, + method_id: 4, + }), + }; +} +impl RequestMethod for SigningSignRaw { + type Request = truapi::versioned::signing::HostSignRawRequest; + type Response = truapi::versioned::signing::HostSignRawResponse; + type Error = truapi::versioned::signing::HostSignRawError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `signing_sign_payload` method marker. +pub struct SigningSignPayload; +impl SigningSignPayload { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Signing", + method: "sign_payload", + wire_name: "signing_sign_payload", + request_type: "truapi::versioned::signing::HostSignPayloadRequest", + response_type: "truapi::versioned::signing::HostSignPayloadResponse", + error_type: Some("truapi::versioned::signing::HostSignPayloadError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 13, + method_id: 5, + }), + }; +} +impl RequestMethod for SigningSignPayload { + type Request = truapi::versioned::signing::HostSignPayloadRequest; + type Response = truapi::versioned::signing::HostSignPayloadResponse; + type Error = truapi::versioned::signing::HostSignPayloadError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `signing_sign_raw_unwatermarked_deprecated` method marker. +pub struct SigningSignRawUnwatermarkedDeprecated; +impl SigningSignRawUnwatermarkedDeprecated { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Signing", + method: "sign_raw_unwatermarked_deprecated", + wire_name: "signing_sign_raw_unwatermarked_deprecated", + request_type: "truapi::versioned::signing::HostSignRawRequest", + response_type: "truapi::versioned::signing::HostSignRawResponse", + error_type: Some("truapi::versioned::signing::HostSignRawError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 13, + method_id: 6, + }), + }; +} +impl RequestMethod for SigningSignRawUnwatermarkedDeprecated { + type Request = truapi::versioned::signing::HostSignRawRequest; + type Response = truapi::versioned::signing::HostSignRawResponse; + type Error = truapi::versioned::signing::HostSignRawError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `signing_sign_raw_unwatermarked_deprecated_with_legacy_account` method marker. +pub struct SigningSignRawUnwatermarkedDeprecatedWithLegacyAccount; +impl SigningSignRawUnwatermarkedDeprecatedWithLegacyAccount { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Signing", + method: "sign_raw_unwatermarked_deprecated_with_legacy_account", + wire_name: "signing_sign_raw_unwatermarked_deprecated_with_legacy_account", + request_type: "truapi::versioned::signing::HostSignRawWithLegacyAccountRequest", + response_type: "truapi::versioned::signing::HostSignRawWithLegacyAccountResponse", + error_type: Some("truapi::versioned::signing::HostSignRawWithLegacyAccountError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 13, + method_id: 7, + }), + }; +} +impl RequestMethod for SigningSignRawUnwatermarkedDeprecatedWithLegacyAccount { + type Request = truapi::versioned::signing::HostSignRawWithLegacyAccountRequest; + type Response = truapi::versioned::signing::HostSignRawWithLegacyAccountResponse; + type Error = truapi::versioned::signing::HostSignRawWithLegacyAccountError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `statement_store_subscribe` method marker. +pub struct StatementStoreSubscribe; +impl StatementStoreSubscribe { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "StatementStore", + method: "subscribe", + wire_name: "statement_store_subscribe", + request_type: "truapi::versioned::statement_store::RemoteStatementStoreSubscribeRequest", + response_type: "truapi::versioned::statement_store::RemoteStatementStoreSubscribeItem", + error_type: Some("truapi::versioned::statement_store::RemoteStatementStoreSubscribeError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Subscription(MethodIds { + trait_id: 14, + method_id: 0, + }), + }; +} +impl SubscriptionMethod for StatementStoreSubscribe { + type Request = truapi::versioned::statement_store::RemoteStatementStoreSubscribeRequest; + type Error = truapi::versioned::statement_store::RemoteStatementStoreSubscribeError; + type Item = truapi::versioned::statement_store::RemoteStatementStoreSubscribeItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `statement_store_create_proof` method marker. +pub struct StatementStoreCreateProof; +impl StatementStoreCreateProof { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "StatementStore", + method: "create_proof", + wire_name: "statement_store_create_proof", + request_type: "truapi::versioned::statement_store::RemoteStatementStoreCreateProofRequest", + response_type: "truapi::versioned::statement_store::RemoteStatementStoreCreateProofResponse", + error_type: Some( + "truapi::versioned::statement_store::RemoteStatementStoreCreateProofError", + ), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 14, + method_id: 1, + }), + }; +} +impl RequestMethod for StatementStoreCreateProof { + type Request = truapi::versioned::statement_store::RemoteStatementStoreCreateProofRequest; + type Response = truapi::versioned::statement_store::RemoteStatementStoreCreateProofResponse; + type Error = truapi::versioned::statement_store::RemoteStatementStoreCreateProofError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `statement_store_create_proof_authorized` method marker. +pub struct StatementStoreCreateProofAuthorized; +impl StatementStoreCreateProofAuthorized { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "StatementStore", + method: "create_proof_authorized", + wire_name: "statement_store_create_proof_authorized", + request_type: "truapi::versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedRequest", + response_type: "truapi::versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedResponse", + error_type: Some( + "truapi::versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedError", + ), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 14, + method_id: 3, + }), + }; +} +impl RequestMethod for StatementStoreCreateProofAuthorized { + type Request = + truapi::versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedRequest; + type Response = + truapi::versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedResponse; + type Error = truapi::versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `statement_store_submit` method marker. +pub struct StatementStoreSubmit; +impl StatementStoreSubmit { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "StatementStore", + method: "submit", + wire_name: "statement_store_submit", + request_type: "truapi::versioned::statement_store::RemoteStatementStoreSubmitRequest", + response_type: "truapi::versioned::statement_store::RemoteStatementStoreSubmitResponse", + error_type: Some("truapi::versioned::statement_store::RemoteStatementStoreSubmitError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 14, + method_id: 2, + }), + }; +} +impl RequestMethod for StatementStoreSubmit { + type Request = truapi::versioned::statement_store::RemoteStatementStoreSubmitRequest; + type Response = truapi::versioned::statement_store::RemoteStatementStoreSubmitResponse; + type Error = truapi::versioned::statement_store::RemoteStatementStoreSubmitError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `system_handshake` method marker. +pub struct SystemHandshake; +impl SystemHandshake { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "System", + method: "handshake", + wire_name: "system_handshake", + request_type: "truapi::versioned::system::HostHandshakeRequest", + response_type: "truapi::versioned::system::HostHandshakeResponse", + error_type: Some("truapi::versioned::system::HostHandshakeError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 1, + method_id: 0, + }), + }; +} +impl RequestMethod for SystemHandshake { + type Request = truapi::versioned::system::HostHandshakeRequest; + type Response = truapi::versioned::system::HostHandshakeResponse; + type Error = truapi::versioned::system::HostHandshakeError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `system_feature_supported` method marker. +pub struct SystemFeatureSupported; +impl SystemFeatureSupported { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "System", + method: "feature_supported", + wire_name: "system_feature_supported", + request_type: "truapi::versioned::system::HostFeatureSupportedRequest", + response_type: "truapi::versioned::system::HostFeatureSupportedResponse", + error_type: Some("truapi::versioned::system::HostFeatureSupportedError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 1, + method_id: 1, + }), + }; +} +impl RequestMethod for SystemFeatureSupported { + type Request = truapi::versioned::system::HostFeatureSupportedRequest; + type Response = truapi::versioned::system::HostFeatureSupportedResponse; + type Error = truapi::versioned::system::HostFeatureSupportedError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `system_navigate_to` method marker. +pub struct SystemNavigateTo; +impl SystemNavigateTo { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "System", + method: "navigate_to", + wire_name: "system_navigate_to", + request_type: "truapi::versioned::system::HostNavigateToRequest", + response_type: "truapi::versioned::system::HostNavigateToResponse", + error_type: Some("truapi::versioned::system::HostNavigateToError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 1, + method_id: 2, + }), + }; +} +impl RequestMethod for SystemNavigateTo { + type Request = truapi::versioned::system::HostNavigateToRequest; + type Response = truapi::versioned::system::HostNavigateToResponse; + type Error = truapi::versioned::system::HostNavigateToError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `system_host_info` method marker. +pub struct SystemHostInfo; +impl SystemHostInfo { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "System", + method: "host_info", + wire_name: "system_host_info", + request_type: "truapi::versioned::system::HostInfoRequest", + response_type: "truapi::versioned::system::HostInfoResponse", + error_type: Some("truapi::versioned::system::HostInfoError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 1, + method_id: 3, + }), + }; +} +impl RequestMethod for SystemHostInfo { + type Request = truapi::versioned::system::HostInfoRequest; + type Response = truapi::versioned::system::HostInfoResponse; + type Error = truapi::versioned::system::HostInfoError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `system_get_product_context` method marker. +pub struct SystemGetProductContext; +impl SystemGetProductContext { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "System", + method: "get_product_context", + wire_name: "system_get_product_context", + request_type: "truapi::versioned::system::HostGetProductContextRequest", + response_type: "truapi::versioned::system::HostGetProductContextResponse", + error_type: Some("truapi::versioned::system::HostGetProductContextError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 1, + method_id: 4, + }), + }; +} +impl RequestMethod for SystemGetProductContext { + type Request = truapi::versioned::system::HostGetProductContextRequest; + type Response = truapi::versioned::system::HostGetProductContextResponse; + type Error = truapi::versioned::system::HostGetProductContextError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `theme_subscribe` method marker. +pub struct ThemeSubscribe; +impl ThemeSubscribe { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Theme", + method: "subscribe", + wire_name: "theme_subscribe", + request_type: "truapi::versioned::theme::HostThemeSubscribeRequest", + response_type: "truapi::versioned::theme::HostThemeSubscribeItem", + error_type: Some("truapi::versioned::theme::HostThemeSubscribeError"), + kind: MethodKind::Subscription, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Subscription(MethodIds { + trait_id: 15, + method_id: 0, + }), + }; +} +impl SubscriptionMethod for ThemeSubscribe { + type Request = truapi::versioned::theme::HostThemeSubscribeRequest; + type Error = truapi::versioned::theme::HostThemeSubscribeError; + type Item = truapi::versioned::theme::HostThemeSubscribeItem; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `worker_begin_operation` method marker. +pub struct WorkerBeginOperation; +impl WorkerBeginOperation { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Worker", + method: "begin_operation", + wire_name: "worker_begin_operation", + request_type: "truapi::versioned::worker::HostWorkerBeginOperationRequest", + response_type: "truapi::versioned::worker::HostWorkerBeginOperationResponse", + error_type: Some("truapi::versioned::worker::HostWorkerBeginOperationError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: Some(ExecutionKind::Worker), + wire: MethodWire::Request(MethodIds { + trait_id: 19, + method_id: 0, + }), + }; +} +impl RequestMethod for WorkerBeginOperation { + type Request = truapi::versioned::worker::HostWorkerBeginOperationRequest; + type Response = truapi::versioned::worker::HostWorkerBeginOperationResponse; + type Error = truapi::versioned::worker::HostWorkerBeginOperationError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `worker_end_operation` method marker. +pub struct WorkerEndOperation; +impl WorkerEndOperation { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Worker", + method: "end_operation", + wire_name: "worker_end_operation", + request_type: "truapi::versioned::worker::HostWorkerEndOperationRequest", + response_type: "truapi::versioned::worker::HostWorkerEndOperationResponse", + error_type: Some("truapi::versioned::worker::HostWorkerEndOperationError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: Some(ExecutionKind::Worker), + wire: MethodWire::Request(MethodIds { + trait_id: 19, + method_id: 1, + }), + }; +} +impl RequestMethod for WorkerEndOperation { + type Request = truapi::versioned::worker::HostWorkerEndOperationRequest; + type Response = truapi::versioned::worker::HostWorkerEndOperationResponse; + type Error = truapi::versioned::worker::HostWorkerEndOperationError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// Generated `APP_METHODS` execution catalog. +pub const APP_METHODS: &[MethodDescriptor] = &[ + AccountConnectionStatusSubscribe::DESCRIPTOR, + AccountGetAccount::DESCRIPTOR, + AccountGetAccountAlias::DESCRIPTOR, + AccountCreateAccountProof::DESCRIPTOR, + AccountSignVrf::DESCRIPTOR, + AccountRegisterRingVrfKey::DESCRIPTOR, + AccountListRingVrfKeys::DESCRIPTOR, + AccountRingVrfSign::DESCRIPTOR, + AccountGetLegacyAccounts::DESCRIPTOR, + AccountGetUserId::DESCRIPTOR, + AccountRequestLogin::DESCRIPTOR, + ChainFollowHeadSubscribe::DESCRIPTOR, + ChainGetHeadHeader::DESCRIPTOR, + ChainGetHeadBody::DESCRIPTOR, + ChainGetHeadStorage::DESCRIPTOR, + ChainCallHead::DESCRIPTOR, + ChainUnpinHead::DESCRIPTOR, + ChainContinueHead::DESCRIPTOR, + ChainStopHeadOperation::DESCRIPTOR, + ChainGetSpecGenesisHash::DESCRIPTOR, + ChainGetSpecChainName::DESCRIPTOR, + ChainGetSpecProperties::DESCRIPTOR, + ChainBroadcastTransaction::DESCRIPTOR, + ChainStopTransaction::DESCRIPTOR, + ChainGetChainInfo::DESCRIPTOR, + CoinPaymentCreatePurse::DESCRIPTOR, + CoinPaymentQueryPurse::DESCRIPTOR, + CoinPaymentRebalancePurse::DESCRIPTOR, + CoinPaymentDeletePurse::DESCRIPTOR, + CoinPaymentCreateReceivable::DESCRIPTOR, + CoinPaymentCreateCheque::DESCRIPTOR, + CoinPaymentDeposit::DESCRIPTOR, + CoinPaymentRefund::DESCRIPTOR, + CoinPaymentListenForPayment::DESCRIPTOR, + ContactsPick::DESCRIPTOR, + EntropyDerive::DESCRIPTOR, + LocalStorageRead::DESCRIPTOR, + LocalStorageWrite::DESCRIPTOR, + LocalStorageClear::DESCRIPTOR, + LocalStorageSubscribe::DESCRIPTOR, + LocaleSubscribe::DESCRIPTOR, + LocaleLocalizeTimestamps::DESCRIPTOR, + NotificationsSendPushNotification::DESCRIPTOR, + NotificationsCancelPushNotification::DESCRIPTOR, + PaymentBalanceSubscribe::DESCRIPTOR, + PaymentRequest::DESCRIPTOR, + PaymentStatusSubscribe::DESCRIPTOR, + PaymentTopUp::DESCRIPTOR, + PermissionsRequestDevicePermission::DESCRIPTOR, + PermissionsRequestRemotePermission::DESCRIPTOR, + PermissionsAuthorizeRemotePermission::DESCRIPTOR, + PermissionsAuthorizeDevicePermission::DESCRIPTOR, + PreimageLookupSubscribe::DESCRIPTOR, + PreimageSubmit::DESCRIPTOR, + ResourceAllocationRequest::DESCRIPTOR, + SigningCreateTransaction::DESCRIPTOR, + SigningCreateTransactionWithLegacyAccount::DESCRIPTOR, + SigningSignRawWithLegacyAccount::DESCRIPTOR, + SigningSignPayloadWithLegacyAccount::DESCRIPTOR, + SigningSignRaw::DESCRIPTOR, + SigningSignPayload::DESCRIPTOR, + SigningSignRawUnwatermarkedDeprecated::DESCRIPTOR, + SigningSignRawUnwatermarkedDeprecatedWithLegacyAccount::DESCRIPTOR, + StatementStoreSubscribe::DESCRIPTOR, + StatementStoreCreateProof::DESCRIPTOR, + StatementStoreCreateProofAuthorized::DESCRIPTOR, + StatementStoreSubmit::DESCRIPTOR, + SystemHandshake::DESCRIPTOR, + SystemFeatureSupported::DESCRIPTOR, + SystemNavigateTo::DESCRIPTOR, + SystemHostInfo::DESCRIPTOR, + SystemGetProductContext::DESCRIPTOR, + ThemeSubscribe::DESCRIPTOR, +]; + +/// Generated `WIDGET_METHODS` execution catalog. +pub const WIDGET_METHODS: &[MethodDescriptor] = &[ + AccountConnectionStatusSubscribe::DESCRIPTOR, + AccountGetAccount::DESCRIPTOR, + AccountGetAccountAlias::DESCRIPTOR, + AccountCreateAccountProof::DESCRIPTOR, + AccountSignVrf::DESCRIPTOR, + AccountRegisterRingVrfKey::DESCRIPTOR, + AccountListRingVrfKeys::DESCRIPTOR, + AccountRingVrfSign::DESCRIPTOR, + AccountGetLegacyAccounts::DESCRIPTOR, + AccountGetUserId::DESCRIPTOR, + AccountRequestLogin::DESCRIPTOR, + ChainFollowHeadSubscribe::DESCRIPTOR, + ChainGetHeadHeader::DESCRIPTOR, + ChainGetHeadBody::DESCRIPTOR, + ChainGetHeadStorage::DESCRIPTOR, + ChainCallHead::DESCRIPTOR, + ChainUnpinHead::DESCRIPTOR, + ChainContinueHead::DESCRIPTOR, + ChainStopHeadOperation::DESCRIPTOR, + ChainGetSpecGenesisHash::DESCRIPTOR, + ChainGetSpecChainName::DESCRIPTOR, + ChainGetSpecProperties::DESCRIPTOR, + ChainBroadcastTransaction::DESCRIPTOR, + ChainStopTransaction::DESCRIPTOR, + ChainGetChainInfo::DESCRIPTOR, + CoinPaymentCreatePurse::DESCRIPTOR, + CoinPaymentQueryPurse::DESCRIPTOR, + CoinPaymentRebalancePurse::DESCRIPTOR, + CoinPaymentDeletePurse::DESCRIPTOR, + CoinPaymentCreateReceivable::DESCRIPTOR, + CoinPaymentCreateCheque::DESCRIPTOR, + CoinPaymentDeposit::DESCRIPTOR, + CoinPaymentRefund::DESCRIPTOR, + CoinPaymentListenForPayment::DESCRIPTOR, + ContactsPick::DESCRIPTOR, + EntropyDerive::DESCRIPTOR, + LocalStorageRead::DESCRIPTOR, + LocalStorageWrite::DESCRIPTOR, + LocalStorageClear::DESCRIPTOR, + LocalStorageSubscribe::DESCRIPTOR, + LocaleSubscribe::DESCRIPTOR, + LocaleLocalizeTimestamps::DESCRIPTOR, + NotificationsSendPushNotification::DESCRIPTOR, + NotificationsCancelPushNotification::DESCRIPTOR, + PaymentBalanceSubscribe::DESCRIPTOR, + PaymentRequest::DESCRIPTOR, + PaymentStatusSubscribe::DESCRIPTOR, + PaymentTopUp::DESCRIPTOR, + PermissionsRequestDevicePermission::DESCRIPTOR, + PermissionsRequestRemotePermission::DESCRIPTOR, + PermissionsAuthorizeRemotePermission::DESCRIPTOR, + PermissionsAuthorizeDevicePermission::DESCRIPTOR, + PreimageLookupSubscribe::DESCRIPTOR, + PreimageSubmit::DESCRIPTOR, + ResourceAllocationRequest::DESCRIPTOR, + SigningCreateTransaction::DESCRIPTOR, + SigningCreateTransactionWithLegacyAccount::DESCRIPTOR, + SigningSignRawWithLegacyAccount::DESCRIPTOR, + SigningSignPayloadWithLegacyAccount::DESCRIPTOR, + SigningSignRaw::DESCRIPTOR, + SigningSignPayload::DESCRIPTOR, + SigningSignRawUnwatermarkedDeprecated::DESCRIPTOR, + SigningSignRawUnwatermarkedDeprecatedWithLegacyAccount::DESCRIPTOR, + StatementStoreSubscribe::DESCRIPTOR, + StatementStoreCreateProof::DESCRIPTOR, + StatementStoreCreateProofAuthorized::DESCRIPTOR, + StatementStoreSubmit::DESCRIPTOR, + SystemHandshake::DESCRIPTOR, + SystemFeatureSupported::DESCRIPTOR, + SystemNavigateTo::DESCRIPTOR, + SystemHostInfo::DESCRIPTOR, + SystemGetProductContext::DESCRIPTOR, + ThemeSubscribe::DESCRIPTOR, +]; + +/// Generated `WORKER_METHODS` execution catalog. +pub const WORKER_METHODS: &[MethodDescriptor] = &[ + AccountConnectionStatusSubscribe::DESCRIPTOR, + AccountGetAccount::DESCRIPTOR, + AccountGetAccountAlias::DESCRIPTOR, + AccountCreateAccountProof::DESCRIPTOR, + AccountSignVrf::DESCRIPTOR, + AccountRegisterRingVrfKey::DESCRIPTOR, + AccountListRingVrfKeys::DESCRIPTOR, + AccountRingVrfSign::DESCRIPTOR, + AccountGetLegacyAccounts::DESCRIPTOR, + AccountGetUserId::DESCRIPTOR, + AccountRequestLogin::DESCRIPTOR, + ChainFollowHeadSubscribe::DESCRIPTOR, + ChainGetHeadHeader::DESCRIPTOR, + ChainGetHeadBody::DESCRIPTOR, + ChainGetHeadStorage::DESCRIPTOR, + ChainCallHead::DESCRIPTOR, + ChainUnpinHead::DESCRIPTOR, + ChainContinueHead::DESCRIPTOR, + ChainStopHeadOperation::DESCRIPTOR, + ChainGetSpecGenesisHash::DESCRIPTOR, + ChainGetSpecChainName::DESCRIPTOR, + ChainGetSpecProperties::DESCRIPTOR, + ChainBroadcastTransaction::DESCRIPTOR, + ChainStopTransaction::DESCRIPTOR, + ChainGetChainInfo::DESCRIPTOR, + ChatCreateRoom::DESCRIPTOR, + ChatRegisterBot::DESCRIPTOR, + ChatListSubscribe::DESCRIPTOR, + ChatPostMessage::DESCRIPTOR, + ChatActionSubscribe::DESCRIPTOR, + CoinPaymentCreatePurse::DESCRIPTOR, + CoinPaymentQueryPurse::DESCRIPTOR, + CoinPaymentRebalancePurse::DESCRIPTOR, + CoinPaymentDeletePurse::DESCRIPTOR, + CoinPaymentCreateReceivable::DESCRIPTOR, + CoinPaymentCreateCheque::DESCRIPTOR, + CoinPaymentDeposit::DESCRIPTOR, + CoinPaymentRefund::DESCRIPTOR, + CoinPaymentListenForPayment::DESCRIPTOR, + ContactsPick::DESCRIPTOR, + EntropyDerive::DESCRIPTOR, + LocalStorageRead::DESCRIPTOR, + LocalStorageWrite::DESCRIPTOR, + LocalStorageClear::DESCRIPTOR, + LocalStorageSubscribe::DESCRIPTOR, + LocaleSubscribe::DESCRIPTOR, + LocaleLocalizeTimestamps::DESCRIPTOR, + NotificationsSendPushNotification::DESCRIPTOR, + NotificationsCancelPushNotification::DESCRIPTOR, + PaymentBalanceSubscribe::DESCRIPTOR, + PaymentRequest::DESCRIPTOR, + PaymentStatusSubscribe::DESCRIPTOR, + PaymentTopUp::DESCRIPTOR, + PermissionsRequestDevicePermission::DESCRIPTOR, + PermissionsRequestRemotePermission::DESCRIPTOR, + PermissionsAuthorizeRemotePermission::DESCRIPTOR, + PermissionsAuthorizeDevicePermission::DESCRIPTOR, + PocketListSubscribe::DESCRIPTOR, + PocketRemoveCard::DESCRIPTOR, + PreimageLookupSubscribe::DESCRIPTOR, + PreimageSubmit::DESCRIPTOR, + RendererRender::DESCRIPTOR, + RendererActionSubscribe::DESCRIPTOR, + ResourceAllocationRequest::DESCRIPTOR, + SigningCreateTransaction::DESCRIPTOR, + SigningCreateTransactionWithLegacyAccount::DESCRIPTOR, + SigningSignRawWithLegacyAccount::DESCRIPTOR, + SigningSignPayloadWithLegacyAccount::DESCRIPTOR, + SigningSignRaw::DESCRIPTOR, + SigningSignPayload::DESCRIPTOR, + SigningSignRawUnwatermarkedDeprecated::DESCRIPTOR, + SigningSignRawUnwatermarkedDeprecatedWithLegacyAccount::DESCRIPTOR, + StatementStoreSubscribe::DESCRIPTOR, + StatementStoreCreateProof::DESCRIPTOR, + StatementStoreCreateProofAuthorized::DESCRIPTOR, + StatementStoreSubmit::DESCRIPTOR, + SystemHandshake::DESCRIPTOR, + SystemFeatureSupported::DESCRIPTOR, + SystemNavigateTo::DESCRIPTOR, + SystemHostInfo::DESCRIPTOR, + SystemGetProductContext::DESCRIPTOR, + ThemeSubscribe::DESCRIPTOR, + WorkerBeginOperation::DESCRIPTOR, + WorkerEndOperation::DESCRIPTOR, +]; + +/// Generated `WORKER_ONLY_METHODS` execution catalog. +pub const WORKER_ONLY_METHODS: &[MethodDescriptor] = &[ + ChatCreateRoom::DESCRIPTOR, + ChatRegisterBot::DESCRIPTOR, + ChatListSubscribe::DESCRIPTOR, + ChatPostMessage::DESCRIPTOR, + ChatActionSubscribe::DESCRIPTOR, + PocketListSubscribe::DESCRIPTOR, + PocketRemoveCard::DESCRIPTOR, + RendererRender::DESCRIPTOR, + RendererActionSubscribe::DESCRIPTOR, + WorkerBeginOperation::DESCRIPTOR, + WorkerEndOperation::DESCRIPTOR, +]; diff --git a/rust/crates/truapi-client/src/lib.rs b/rust/crates/truapi-client/src/lib.rs new file mode 100644 index 000000000..44080579d --- /dev/null +++ b/rust/crates/truapi-client/src/lib.rs @@ -0,0 +1,626 @@ +#![no_std] + +//! Transport-neutral TrUAPI client codecs and generated method catalog. +//! +//! Frames contain a SCALE request id, a `(trait, method)` address, a message +//! type byte, and the leg's inline SCALE payload. This crate owns no transport. + +extern crate alloc; + +use alloc::string::String; +use alloc::vec::Vec; +use parity_scale_codec::{Decode, Encode}; +use truapi::CallError; + +mod generated; +pub use generated::*; + +/// Product executable kinds used by TrUAPI authorization. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExecutionKind { + /// Full application surface. + App, + /// Embedded widget surface. + Widget, + /// Background product worker. + Worker, +} + +/// Direction in which a method's initial frame travels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + /// Product starts the call and the host serves it. + ProductToHost, + /// Host starts the call and the product serves it. + HostToProduct, +} + +/// Wire interaction shape. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MethodKind { + /// One request followed by one response. + Request, + /// Items followed by a normal or failed interruption. + Subscription, +} + +/// The address shared by every leg of one method. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MethodIds { + /// Trait discriminant. + pub trait_id: u8, + /// Method discriminant within the trait. + pub method_id: u8, +} + +/// Which leg of an exchange a frame carries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum MessageType { + /// Request, or subscription start. + Request = 0, + /// Response, or subscription item. + Response = 1, + /// Subscription completion carrying `Result<(), CallError>`. + Interrupt = 2, + /// Subscription cancellation with no payload. + Stop = 3, +} + +/// Wire ids and interaction shape for one method. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MethodWire { + /// Request/response method. + Request(MethodIds), + /// Subscription method. + Subscription(MethodIds), +} + +/// Generated metadata for one canonical method. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MethodDescriptor { + /// Canonical service trait name. + pub service: &'static str, + /// Rust method name. + pub method: &'static str, + /// Globally unique wire method name. + pub wire_name: &'static str, + /// Rust type of the request envelope. + pub request_type: &'static str, + /// Rust type of the success or stream-item envelope. + pub response_type: &'static str, + /// Rust type of the domain error. + pub error_type: Option<&'static str>, + /// Interaction shape. + pub kind: MethodKind, + /// Initial-frame direction. + pub direction: Direction, + /// Required executable kind, or `None` for every kind. + pub required_execution: Option, + /// Canonical address and shape. + pub wire: MethodWire, +} + +/// Generated marker for a product-initiated request method. +pub trait RequestMethod { + /// Request envelope. + type Request: Encode; + /// Success envelope. + type Response: Decode; + /// Domain-error envelope. + type Error: Decode; + /// Canonical method metadata. + const DESCRIPTOR: MethodDescriptor; +} + +/// Generated marker for a product-initiated subscription. +pub trait SubscriptionMethod { + /// Start request, or unit for an empty payload. + type Request: Encode; + /// Stream item. + type Item: Decode; + /// Domain error carried by the interrupt leg. + type Error: Decode; + /// Canonical method metadata. + const DESCRIPTOR: MethodDescriptor; +} + +/// Generated marker for a host-initiated subscription served by a product. +pub trait HostSubscriptionMethod { + /// Host request. + type Request: Decode; + /// Product stream item. + type Item: Encode; + /// Domain error carried by the interrupt leg. + type Error: Encode; + /// Canonical method metadata. + const DESCRIPTOR: MethodDescriptor; +} + +/// Decoded value paired with its transport request id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Decoded { + /// Request id from the envelope. + pub request_id: String, + /// Typed payload. + pub value: T, +} + +/// Typed outcome carried by a request response. +pub type RequestOutcome = + Result<::Response, CallError<::Error>>; +/// Result of decoding a request response. +pub type DecodedResponse = Result>, DecodeError>; +/// Typed completion carried by a subscription interruption. +pub type SubscriptionOutcome = Result<(), CallError<::Error>>; + +/// Reserved method-independent protocol error address. +pub const PROTOCOL_ERROR_IDS: MethodIds = MethodIds { + trait_id: 255, + method_id: 255, +}; + +/// A recognized protocol failure from a peer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtocolError { + /// The peer cannot route this method address. + UnsupportedMessage(MethodIds), +} + +/// Structural or protocol failure while decoding a frame. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DecodeError { + /// Invalid SCALE or a truncated envelope. + Malformed, + /// Bytes remain after the expected payload. + TrailingBytes, + /// The frame belongs to another method. + UnexpectedMethod { + /// Expected method address. + expected: MethodIds, + /// Actual method address. + actual: MethodIds, + }, + /// The frame belongs to another leg of the exchange. + UnexpectedMessageType { + /// Expected leg. + expected: MessageType, + /// Actual message-type byte. + actual: u8, + }, + /// A descriptor has a shape incompatible with the operation. + WrongMethodKind, + /// A correlated protocol failure. An unknown future version or variant is + /// retained as `None` rather than mistaken for malformed known data. + Protocol { + /// Request id of the call that failed. + request_id: String, + /// Recognized failure, if supported by this build. + error: Option, + }, +} + +/// Encodes a request without an intermediate payload allocation. +pub fn encode_request(request_id: &str, request: &M::Request) -> Vec { + encode_value_frame( + request_id, + generated_request_ids(M::DESCRIPTOR), + MessageType::Request, + request, + ) +} + +/// Decodes a response's `Result>` payload. +pub fn decode_response(frame: &[u8]) -> DecodedResponse { + decode_value_frame( + frame, + generated_request_ids(M::DESCRIPTOR), + MessageType::Response, + ) +} + +/// Encodes a subscription start. +pub fn encode_subscription_start( + request_id: &str, + request: &M::Request, +) -> Vec { + encode_value_frame( + request_id, + generated_subscription_ids(M::DESCRIPTOR), + MessageType::Request, + request, + ) +} + +/// Encodes subscription cancellation; rejects request descriptors. +pub fn encode_subscription_stop( + request_id: &str, + descriptor: MethodDescriptor, +) -> Result, DecodeError> { + Ok(encode_value_frame( + request_id, + subscription_ids(descriptor)?, + MessageType::Stop, + &(), + )) +} + +/// Decodes a subscription item. +pub fn decode_subscription_item( + frame: &[u8], +) -> Result, DecodeError> { + decode_value_frame( + frame, + generated_subscription_ids(M::DESCRIPTOR), + MessageType::Response, + ) +} + +/// Decodes normal completion (`Ok(())`) or a typed subscription failure. +pub fn decode_subscription_interrupt( + frame: &[u8], +) -> Result>, DecodeError> { + decode_value_frame( + frame, + generated_subscription_ids(M::DESCRIPTOR), + MessageType::Interrupt, + ) +} + +/// Whether a frame interrupts this subscription; rejects request descriptors. +pub fn is_subscription_interrupt( + frame: &[u8], + descriptor: MethodDescriptor, +) -> Result { + let ids = subscription_ids(descriptor)?; + let frame = decode_frame(frame)?; + Ok(frame.ids == ids && frame.message_type == MessageType::Interrupt as u8) +} + +/// Decodes a host-initiated start for a product worker. +pub fn decode_host_subscription_start( + frame: &[u8], +) -> Result, DecodeError> { + decode_value_frame( + frame, + generated_subscription_ids(M::DESCRIPTOR), + MessageType::Request, + ) +} + +/// Encodes a product-served subscription item. +pub fn encode_host_subscription_item( + request_id: &str, + item: &M::Item, +) -> Vec { + encode_value_frame( + request_id, + generated_subscription_ids(M::DESCRIPTOR), + MessageType::Response, + item, + ) +} + +/// Encodes normal or failed termination of a product-served subscription. +pub fn encode_host_subscription_interrupt( + request_id: &str, + outcome: &Result<(), CallError>, +) -> Vec { + encode_value_frame( + request_id, + generated_subscription_ids(M::DESCRIPTOR), + MessageType::Interrupt, + outcome, + ) +} + +/// Whether a host frame cancels this product-served subscription. +pub fn is_host_subscription_stop( + frame: &[u8], +) -> Result { + let frame = decode_frame(frame)?; + let matches = frame.ids == generated_subscription_ids(M::DESCRIPTOR) + && frame.message_type == MessageType::Stop as u8; + if matches { + decode_exact::<()>(frame.payload)?; + } + Ok(matches) +} + +fn generated_request_ids(descriptor: MethodDescriptor) -> MethodIds { + match descriptor.wire { + MethodWire::Request(ids) => ids, + MethodWire::Subscription(_) => { + panic!("generated request descriptor must use request wire ids") + } + } +} + +fn subscription_ids(descriptor: MethodDescriptor) -> Result { + match descriptor.wire { + MethodWire::Subscription(ids) => Ok(ids), + MethodWire::Request(_) => Err(DecodeError::WrongMethodKind), + } +} + +fn generated_subscription_ids(descriptor: MethodDescriptor) -> MethodIds { + subscription_ids(descriptor) + .expect("generated subscription descriptor must use subscription wire ids") +} + +fn encode_value_frame( + request_id: &str, + ids: MethodIds, + message_type: MessageType, + value: &T, +) -> Vec { + let mut frame = Vec::new(); + request_id.encode_to(&mut frame); + frame.extend_from_slice(&[ids.trait_id, ids.method_id, message_type as u8]); + value.encode_to(&mut frame); + frame +} + +struct BorrowedFrame<'a> { + request_id: String, + ids: MethodIds, + message_type: u8, + payload: &'a [u8], +} + +fn decode_frame(mut frame: &[u8]) -> Result, DecodeError> { + let request_id = String::decode(&mut frame).map_err(|_| DecodeError::Malformed)?; + let [trait_id, method_id, message_type, payload @ ..] = frame else { + return Err(DecodeError::Malformed); + }; + let ids = MethodIds { + trait_id: *trait_id, + method_id: *method_id, + }; + if ids == PROTOCOL_ERROR_IDS { + if *message_type != MessageType::Response as u8 { + return Err(DecodeError::UnexpectedMessageType { + expected: MessageType::Response, + actual: *message_type, + }); + } + let error = match (payload.first(), payload.get(1)) { + (None, _) => return Err(DecodeError::Malformed), + (Some(version), _) if *version != 0 => None, + (Some(_), Some(variant)) if *variant != 0 => None, + _ => { + let (version, variant, trait_id, method_id): (u8, u8, u8, u8) = + decode_exact(payload)?; + debug_assert_eq!((version, variant), (0, 0)); + Some(ProtocolError::UnsupportedMessage(MethodIds { + trait_id, + method_id, + })) + } + }; + return Err(DecodeError::Protocol { request_id, error }); + } + Ok(BorrowedFrame { + request_id, + ids, + message_type: *message_type, + payload, + }) +} + +fn decode_value_frame( + frame: &[u8], + ids: MethodIds, + message_type: MessageType, +) -> Result, DecodeError> { + let frame = decode_frame(frame)?; + if frame.ids != ids { + return Err(DecodeError::UnexpectedMethod { + expected: ids, + actual: frame.ids, + }); + } + if frame.message_type != message_type as u8 { + return Err(DecodeError::UnexpectedMessageType { + expected: message_type, + actual: frame.message_type, + }); + } + Ok(Decoded { + request_id: frame.request_id, + value: decode_exact(frame.payload)?, + }) +} + +fn decode_exact(mut payload: &[u8]) -> Result { + let value = T::decode(&mut payload).map_err(|_| DecodeError::Malformed)?; + if payload.is_empty() { + Ok(value) + } else { + Err(DecodeError::TrailingBytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + use truapi::v01; + use truapi::versioned::system::{HostHandshakeRequest, HostHandshakeResponse}; + + #[test] + fn request_and_response_use_canonical_envelopes() { + let request = HostHandshakeRequest::V1(v01::HostHandshakeRequest { codec_version: 2 }); + assert_eq!( + encode_request::("p:1", &request), + [12, b'p', b':', b'1', 1, 0, 0, 0, 2] + ); + let decoded = decode_response::(&[12, b'p', b':', b'1', 1, 0, 1, 0, 0]) + .expect("response frame"); + assert_eq!(decoded.request_id, "p:1"); + assert_eq!(decoded.value, Ok(HostHandshakeResponse::V1)); + } + + #[test] + fn response_domain_error_preserves_versioned_payload() { + let error = CallError::Domain(truapi::versioned::system::HostHandshakeError::V1( + v01::HostHandshakeError::UnsupportedProtocolVersion, + )); + let mut frame = vec![12, b'p', b':', b'1', 1, 0, 1, 1]; + error.encode_to(&mut frame); + assert_eq!( + decode_response::(&frame) + .expect("error frame") + .value, + Err(error) + ); + } + + #[test] + fn worker_serves_unified_renderer() { + use truapi::versioned::renderer::{ + ProductRendererRenderItem, ProductRendererRenderRequest, + }; + let request = ProductRendererRenderRequest::V1(v01::ProductRendererRenderRequest { + context: v01::RenderContext::PocketCard { + card_id: "loyalty".into(), + }, + payload: vec![1, 2, 3], + }); + let ids = generated_subscription_ids(RendererRender::DESCRIPTOR); + let start = encode_value_frame("host:4", ids, MessageType::Request, &request); + let decoded = + decode_host_subscription_start::(&start).expect("start frame"); + assert_eq!(decoded.request_id, "host:4"); + assert_eq!(decoded.value, request); + let item = ProductRendererRenderItem::V1(v01::RendererNode::Nil); + let rendered = encode_host_subscription_item::("host:4", &item); + assert_eq!( + decode_value_frame::(&rendered, ids, MessageType::Response) + .expect("item frame") + .value, + item + ); + let stop = encode_value_frame("host:4", ids, MessageType::Stop, &()); + assert_eq!(is_host_subscription_stop::(&stop), Ok(true)); + let interrupt = encode_host_subscription_interrupt::("host:4", &Ok(())); + assert_eq!( + decode_value_frame::>>( + &interrupt, + ids, + MessageType::Interrupt + ) + .expect("interrupt") + .value, + Ok(()) + ); + } + + #[test] + fn subscription_completion_and_failure_are_typed_and_strict() { + type Method = AccountConnectionStatusSubscribe; + let ids = generated_subscription_ids(Method::DESCRIPTOR); + let complete = encode_value_frame( + "p:1", + ids, + MessageType::Interrupt, + &Ok::<(), CallError>(()), + ); + assert_eq!( + decode_subscription_interrupt::(&complete) + .expect("completion") + .value, + Ok(()) + ); + let failed = encode_value_frame( + "p:1", + ids, + MessageType::Interrupt, + &Err::<(), _>(CallError::::Denied), + ); + assert_eq!( + decode_subscription_interrupt::(&failed) + .expect("failure") + .value, + Err(CallError::Denied) + ); + let mut trailing = complete; + trailing.push(0); + assert_eq!( + decode_subscription_interrupt::(&trailing), + Err(DecodeError::TrailingBytes) + ); + let empty = encode_value_frame("p:1", ids, MessageType::Interrupt, &()); + assert_eq!( + decode_subscription_interrupt::(&empty), + Err(DecodeError::Malformed) + ); + } + + #[test] + fn frame_address_and_leg_both_must_match() { + let ids = generated_request_ids(SystemHandshake::DESCRIPTOR); + let other = MethodIds { + trait_id: ids.trait_id + 1, + method_id: ids.method_id, + }; + let wrong_method = encode_value_frame( + "p:1", + other, + MessageType::Response, + &Ok::<_, CallError<()>>(HostHandshakeResponse::V1), + ); + assert_eq!( + decode_response::(&wrong_method), + Err(DecodeError::UnexpectedMethod { + expected: ids, + actual: other + }) + ); + let wrong_leg = encode_value_frame("p:1", ids, MessageType::Request, &()); + assert_eq!( + decode_response::(&wrong_leg), + Err(DecodeError::UnexpectedMessageType { + expected: MessageType::Response, + actual: 0 + }) + ); + assert_eq!( + encode_subscription_stop("p:1", SystemHandshake::DESCRIPTOR), + Err(DecodeError::WrongMethodKind) + ); + } + + #[test] + fn protocol_failures_keep_correlation_and_accept_future_variants() { + let known = [12, b'p', b':', b'1', 255, 255, 1, 0, 0, 4, 7]; + assert_eq!( + decode_response::(&known), + Err(DecodeError::Protocol { + request_id: "p:1".into(), + error: Some(ProtocolError::UnsupportedMessage(MethodIds { + trait_id: 4, + method_id: 7 + })) + }) + ); + let future = [12, b'p', b':', b'1', 255, 255, 1, 0, 1, 42]; + assert_eq!( + decode_response::(&future), + Err(DecodeError::Protocol { + request_id: "p:1".into(), + error: None + }) + ); + assert_eq!( + decode_response::(&known[..known.len() - 1]), + Err(DecodeError::Malformed) + ); + let mut trailing = known.to_vec(); + trailing.push(0); + assert_eq!( + decode_response::(&trailing), + Err(DecodeError::TrailingBytes) + ); + } +} diff --git a/rust/crates/truapi-codegen/.gitignore b/rust/crates/truapi-codegen/.gitignore deleted file mode 100644 index 7a4783894..000000000 --- a/rust/crates/truapi-codegen/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Mismatch dumps written by tests/golden_rust_emit.rs for local inspection. -tests/golden/*.actual diff --git a/rust/crates/truapi-codegen/README.md b/rust/crates/truapi-codegen/README.md index f3d2a356b..d47e87a5b 100644 --- a/rust/crates/truapi-codegen/README.md +++ b/rust/crates/truapi-codegen/README.md @@ -1,6 +1,6 @@ # truapi-codegen -_Reads rustdoc JSON for the `truapi` crate and generates the TypeScript client._ +_Reads rustdoc JSON for the `truapi` crate and generates TypeScript clients and Rust codecs._ [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](../../../LICENSE) @@ -9,57 +9,33 @@ _Reads rustdoc JSON for the `truapi` crate and generates the TypeScript client._ - TypeScript types for every protocol type in `truapi`. - TypeScript domain client classes for every unified trait. - The TypeScript wire dispatch table. +- The Rust server dispatcher and transport-neutral wire table. +- The no-std Rust client method catalog when `--rust-client-output` is supplied. ## Generated output -Generated client methods keep API codecs local, encode payload bytes, and hand only wire frames to the transport: - -```ts -export interface TrUApiTransport { - request(params: { - method: string; - payload: Uint8Array; - decodeResponse: (payload: Uint8Array) => Response; - }): Promise; -} - -export class AccountManagementClient { - constructor(private readonly transport: TrUApiTransport) {} - - async accountGet( - request: T.ProductAccountId, - ): Promise> { - const result = await this.transport.request< - S.ResultPayload - >({ - method: "host_account_get", - payload: T.HostAccountGetRequest.enc({ tag: "V2", value: request }), - decodeResponse: (payload) => - S.indexedTaggedUnion({ - V2: [1, S.Result(T.Account, T.RequestCredentialsError)] as const, - }).dec(payload).value, - }); - return result.success ? ok(result.value) : err(result.value); - } -} -``` +Generated methods encode their payloads and pass complete wire frames to the transport. Every frame carries a SCALE request id, a `(trait, method)` byte pair, a `message_type` byte, and the leg's inline payload. Request responses encode `Result>`; subscriptions deliver versioned items and terminate with `Result<(), CallError>`. + +The Rust client catalog targets `truapi-client`: request markers implement `RequestMethod`, product-initiated streams implement `SubscriptionMethod`, and product-served streams such as `Renderer::render` implement `HostSubscriptionMethod`. Both subscription traits declare their domain error through `Error`. All legs share `MethodIds`; the message type distinguishes the leg. ## Architecture The generator runs in three stages: 1. **Parse**: read JSON emitted by nightly rustdoc. -2. **Normalize**: extract the API model, including each method's `#[wire(id = N)]`. -3. **Emit**: generators write TypeScript output. +2. **Normalize**: extract the API model, including `#[wire_trait(id = N)]` and each method's `#[wire(id = N)]`. +3. **Emit**: generators write TypeScript clients and optional Rust dispatcher, wire-table, and client-catalog outputs. -Missing or duplicate wire ids fail generation. One id addresses a method whatever its shape: which leg a frame carries is the envelope's own `message_type` byte. +Missing or duplicate wire ids fail generation. Trait id 255 is reserved for correlated protocol errors. One `(trait, method)` pair addresses a method whatever its shape: which leg a frame carries is the envelope's own `message_type` byte. ## CLI ```bash cargo run -p truapi-codegen -- \ --input target/doc/truapi.json \ - --output js/packages/truapi/src/generated + --output js/packages/truapi/src/generated \ + --rust-output rust/crates/truapi/src/generated \ + --rust-client-output rust/crates/truapi-client/src/generated.rs ``` ## Typical workflow @@ -68,11 +44,15 @@ cargo run -p truapi-codegen -- \ cargo +$(cat nightly-toolchain) rustdoc -p truapi -- -Z unstable-options --output-format json cargo run -p truapi-codegen -- \ --input target/doc/truapi.json \ - --output js/packages/truapi/src/generated + --output js/packages/truapi/src/generated \ + --rust-output rust/crates/truapi/src/generated \ + --rust-client-output rust/crates/truapi-client/src/generated.rs ``` The repo wraps both steps in [`scripts/codegen.sh`](../../../scripts/codegen.sh), which is what you should run from the repo root. +CI passes the generated TypeScript and Rust output to the Rust job. `TRUAPI_REQUIRE_GENERATED_TS=1 cargo test -p truapi --test wire_table_ts_parity` enforces actual Rust/TypeScript wire-table parity; `cargo test -p truapi-client` exercises client frame codecs after regeneration. Generated Rust source is not compared against checked-in dispatcher or wire-table text snapshots. + ## When to run it Run after any trait or type change in [`truapi`](../truapi/). If you only change runtime behavior without changing the protocol shape, regeneration is not needed. diff --git a/rust/crates/truapi-codegen/src/main.rs b/rust/crates/truapi-codegen/src/main.rs index 7c8bcce7e..b0b28fe6f 100644 --- a/rust/crates/truapi-codegen/src/main.rs +++ b/rust/crates/truapi-codegen/src/main.rs @@ -63,6 +63,10 @@ struct Cli { #[arg(long)] rust_output: Option, + /// Output file for the generated no-std Rust client catalog (optional). + #[arg(long)] + rust_client_output: Option, + /// Path to rustdoc JSON for a crate with a `platform` module; repeat for /// each such crate (`truapi`, `truapi-provider`). /// @@ -170,6 +174,20 @@ fn main() -> Result<()> { .with_context(|| format!("writing Rust dispatcher to {}", path.display()))?; println!("Wrote Rust dispatcher to {}", path.display()); } + if let Some(path) = &cli.rust_client_output { + let schema_hash = + ts::wire_schema_hash(&api, ts::latest_wire_version(&api), cli.codec_version) + .context("computing Rust client wire schema hash")?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } + let client = rust::generate_client(&api, &schema_hash) + .context("generating no-std Rust client catalog")?; + std::fs::write(path, client) + .with_context(|| format!("writing Rust client catalog to {}", path.display()))?; + println!("Wrote Rust client catalog to {}", path.display()); + } if !cli.platform_input.is_empty() { if cli.platform_wasm_adapter_output.is_some() && cli.platform_ts_output.is_none() { anyhow::bail!("--platform-wasm-adapter-output requires --platform-ts-output"); diff --git a/rust/crates/truapi-codegen/src/platform.rs b/rust/crates/truapi-codegen/src/platform.rs index 729ff964a..c7962d523 100644 --- a/rust/crates/truapi-codegen/src/platform.rs +++ b/rust/crates/truapi-codegen/src/platform.rs @@ -26,6 +26,8 @@ pub struct PlatformDefinition { /// sorted alphabetically by name. Emitted alongside the trait interfaces /// so the generated TS does not have to import them from the API client. pub types: Vec, + /// Public API paths actually used by platform signatures, including version pins. + pub api_type_paths: BTreeMap, /// Composite super-trait (`Platform: Storage + Navigation + ...`), if any. pub super_trait: Option, /// Composite super-trait of capabilities a host may omit @@ -117,6 +119,7 @@ pub fn extract_all(krates: &[Crate]) -> Result { let mut merged = PlatformDefinition { traits: Vec::new(), types: Vec::new(), + api_type_paths: BTreeMap::new(), super_trait: None, optional_super_trait: None, }; @@ -124,6 +127,15 @@ pub fn extract_all(krates: &[Crate]) -> Result { let definition = extract(krate)?; merged.traits.extend(definition.traits); merged.types.extend(definition.types); + for (name, path) in definition.api_type_paths { + if merged + .api_type_paths + .insert(name.clone(), path.clone()) + .is_some_and(|old| old != path) + { + bail!("platform API type `{name}` has conflicting Rust paths"); + } + } for (slot, found) in [ (&mut merged.super_trait, definition.super_trait), ( @@ -169,6 +181,7 @@ fn extract(krate: &Crate) -> Result { let mut traits = Vec::new(); let mut super_trait = None; let mut optional_super_trait = None; + let mut api_type_paths = BTreeMap::new(); for item_id in &trait_ids { let item = krate .index @@ -197,6 +210,20 @@ fn extract(krate: &Crate) -> Result { continue; } + if let Some(methods) = trait_inner + .get("items") + .and_then(serde_json::Value::as_array) + { + for id in methods { + let id = id + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| id.to_string()); + if let Some(method) = krate.index.get(&id) { + collect_api_type_paths(&method.inner, krate, &mut api_type_paths)?; + } + } + } traits.push(extract_capability_trait( &name, item, @@ -212,11 +239,57 @@ fn extract(krate: &Crate) -> Result { Ok(PlatformDefinition { traits, types, + api_type_paths, super_trait, optional_super_trait, }) } +fn collect_api_type_paths( + value: &serde_json::Value, + krate: &Crate, + paths: &mut BTreeMap, +) -> Result<()> { + match value { + serde_json::Value::Object(object) => { + if let Some(id) = object.get("resolved_path").and_then(|path| path.get("id")) { + let id = id + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| id.to_string()); + if let Some(path) = krate.paths.get(&id) { + let module = path.path.get(1).map(String::as_str).unwrap_or_default(); + if path.path.first().is_some_and(|name| name == "truapi") + && (module == "latest" + || module.strip_prefix('v').is_some_and(|v| { + !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()) + })) + && let Some(name) = path.path.last() + { + let public_path = format!("truapi::{module}::{name}"); + if paths + .insert(name.clone(), public_path.clone()) + .is_some_and(|old| old != public_path) + { + bail!("platform API type `{name}` uses conflicting Rust versions"); + } + } + } + } + for value in object.values() { + collect_api_type_paths(value, krate, paths)?; + } + } + serde_json::Value::Array(values) => { + for value in values { + collect_api_type_paths(value, krate, paths)?; + } + } + _ => {} + } + Ok(()) +} + /// Extract every local struct or enum whose name appears in a trait method /// signature. fn collect_referenced_local_types( diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index 32d68d798..c32a026fc 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -14,10 +14,12 @@ use convert_case::{Case, Casing}; use crate::platform::PlatformDefinition; use crate::rustdoc::*; +mod client; mod dispatcher; mod wasm_bridge; mod wire_table; +pub use client::generate_client; pub use dispatcher::generate_dispatcher; pub use wasm_bridge::generate_wasm_bridge; pub use wire_table::generate_wire_table; diff --git a/rust/crates/truapi-codegen/src/rust/client.rs b/rust/crates/truapi-codegen/src/rust/client.rs new file mode 100644 index 000000000..931f11bfb --- /dev/null +++ b/rust/crates/truapi-codegen/src/rust/client.rs @@ -0,0 +1,269 @@ +//! no_std Rust client-catalog generation. + +use anyhow::{Result, bail}; +use convert_case::{Case, Casing}; +use std::fmt::Write; + +use super::{module_for_trait, wire_method_name, wire_table}; +use crate::rustdoc::{ApiDefinition, MethodDef, MethodKind, ReturnType, TraitDef, TypeRef}; + +pub fn generate_client(api: &ApiDefinition, schema_hash: &str) -> Result { + let mut out = String::new(); + writeln!(out, "//! Canonical TrUAPI client method catalog.")?; + writeln!(out, "//!")?; + writeln!(out, "//! Auto-generated by truapi-codegen. Do not edit.\n")?; + writeln!(out, "use super::*;\n")?; + writeln!(out, "/// Fingerprint of the generated wire contract.")?; + writeln!( + out, + "pub const TRUAPI_WIRE_SCHEMA_HASH: &str = \"{schema_hash}\";\n" + )?; + + let mut app = Vec::new(); + let mut widget = Vec::new(); + let mut worker = Vec::new(); + let mut worker_only = Vec::new(); + + for trait_name in &api.public_trait_order { + let service = api + .traits + .iter() + .find(|candidate| candidate.name == *trait_name) + .ok_or_else(|| anyhow::anyhow!("public trait `{trait_name}` was not extracted"))?; + let requirement = execution_requirement(service)?; + for method in &service.methods { + let marker = marker_name(service, method); + emit_method(&mut out, service, method, requirement, &marker)?; + match requirement { + None => { + app.push(marker.clone()); + widget.push(marker.clone()); + worker.push(marker); + } + Some("App") => app.push(marker), + Some("Widget") => widget.push(marker), + Some("Worker") => { + worker.push(marker.clone()); + worker_only.push(marker); + } + Some(other) => bail!("unsupported execution requirement `{other}`"), + } + } + } + + emit_catalog(&mut out, "APP_METHODS", &app)?; + emit_catalog(&mut out, "WIDGET_METHODS", &widget)?; + emit_catalog(&mut out, "WORKER_METHODS", &worker)?; + emit_catalog(&mut out, "WORKER_ONLY_METHODS", &worker_only)?; + Ok(out) +} + +fn execution_requirement(service: &TraitDef) -> Result> { + let requirement = service.required_execution(); + if let Some(value) = requirement + && !matches!(value, "App" | "Widget" | "Worker") + { + bail!( + "service `{}` has unknown execution requirement `{value}`", + service.name + ); + } + Ok(requirement) +} + +fn marker_name(service: &TraitDef, method: &MethodDef) -> String { + format!("{}_{}", service.name, method.name).to_case(Case::Pascal) +} + +fn emit_method( + out: &mut String, + service: &TraitDef, + method: &MethodDef, + requirement: Option<&str>, + marker: &str, +) -> Result<()> { + let module = module_for_trait(&service.name); + let wire_name = wire_method_name(&service.name, &method.name); + let execution = requirement + .map(|value| format!("Some(ExecutionKind::{value})")) + .unwrap_or_else(|| "None".to_string()); + let direction = if method.wire.host_initiated { + "Direction::HostToProduct" + } else { + "Direction::ProductToHost" + }; + let kind = match method.kind { + MethodKind::Request => "MethodKind::Request", + MethodKind::Subscription => "MethodKind::Subscription", + }; + let trait_id = wire_table::trait_wire_id(service)?; + let method_id = wire_table::method_wire_id(service, method)?; + let wire_kind = match method.kind { + MethodKind::Request => "Request", + MethodKind::Subscription => "Subscription", + }; + let wire = format!( + "MethodWire::{wire_kind}(MethodIds {{ trait_id: {trait_id}, method_id: {method_id} }})" + ); + let request_name = request_type(method, &module)?; + let (response_name, error_name) = match &method.return_type { + ReturnType::Result { ok, err } => ( + rust_type(ok, &module)?, + Some(rust_type(call_error_domain(err)?, &module)?), + ), + ReturnType::Subscription { item, interrupt } => ( + rust_type(item, &module)?, + Some(rust_type(call_error_domain(interrupt)?, &module)?), + ), + }; + + writeln!(out, "/// `{wire_name}` method marker.")?; + writeln!(out, "pub struct {marker};")?; + writeln!(out, "impl {marker} {{")?; + writeln!( + out, + " /// Canonical metadata and frame ids for this method." + )?; + writeln!( + out, + " pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor {{" + )?; + writeln!(out, " service: \"{}\",", service.name)?; + writeln!(out, " method: \"{}\",", method.name)?; + writeln!(out, " wire_name: \"{wire_name}\",")?; + writeln!(out, " request_type: \"{request_name}\",")?; + writeln!(out, " response_type: \"{response_name}\",")?; + match error_name { + Some(error) => writeln!(out, " error_type: Some(\"{error}\"),")?, + None => writeln!(out, " error_type: None,")?, + } + writeln!(out, " kind: {kind},")?; + writeln!(out, " direction: {direction},")?; + writeln!(out, " required_execution: {execution},")?; + writeln!(out, " wire: {wire},")?; + writeln!(out, " }};")?; + writeln!(out, "}}")?; + + let request = request_type(method, &module)?; + match ( + &method.kind, + &method.return_type, + method.wire.host_initiated, + ) { + (MethodKind::Request, ReturnType::Result { ok, err }, false) => { + let response = rust_type(ok, &module)?; + let error = rust_type(call_error_domain(err)?, &module)?; + writeln!(out, "impl RequestMethod for {marker} {{")?; + writeln!(out, " type Request = {request};")?; + writeln!(out, " type Response = {response};")?; + writeln!(out, " type Error = {error};")?; + writeln!( + out, + " const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR;" + )?; + writeln!(out, "}}\n")?; + } + ( + MethodKind::Subscription, + ReturnType::Subscription { item, interrupt }, + host_initiated, + ) => { + let item = rust_type(item, &module)?; + let error = rust_type(call_error_domain(interrupt)?, &module)?; + let subscription_trait = if host_initiated { + "HostSubscriptionMethod" + } else { + "SubscriptionMethod" + }; + writeln!(out, "impl {subscription_trait} for {marker} {{")?; + writeln!(out, " type Request = {request};")?; + writeln!(out, " type Error = {error};")?; + writeln!(out, " type Item = {item};")?; + writeln!( + out, + " const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR;" + )?; + writeln!(out, "}}\n")?; + } + _ => bail!("method `{wire_name}` has unsupported kind/return/direction combination"), + } + Ok(()) +} + +fn request_type(method: &MethodDef, module: &str) -> Result { + match method.params.as_slice() { + [] => Ok("()".to_string()), + [param] => rust_type(¶m.type_ref, module), + params => bail!( + "method `{}` has {} wire parameters; expected at most one request wrapper", + method.name, + params.len() + ), + } +} + +fn call_error_domain(error: &TypeRef) -> Result<&TypeRef> { + match error { + TypeRef::Named { name, args } if name == "CallError" && args.len() == 1 => Ok(&args[0]), + _ => bail!("method error is not CallError: {error:?}"), + } +} + +fn rust_type(value: &TypeRef, module: &str) -> Result { + Ok(match value { + TypeRef::Primitive(name) if name == "String" => "alloc::string::String".to_string(), + TypeRef::Primitive(name) => name.clone(), + TypeRef::Named { name, args } if name == "String" && args.is_empty() => { + "alloc::string::String".to_string() + } + TypeRef::Named { name, args } if name == "Vec" && args.len() == 1 => { + format!("alloc::vec::Vec<{}>", rust_type(&args[0], module)?) + } + TypeRef::Named { name, args } if name == "Option" && args.len() == 1 => { + format!("Option<{}>", rust_type(&args[0], module)?) + } + TypeRef::Named { name, args } if name == "Infallible" && args.is_empty() => { + "core::convert::Infallible".to_string() + } + TypeRef::Named { name, args } if name == "GenericError" && args.is_empty() => { + "truapi::v01::GenericError".to_string() + } + TypeRef::Named { name, args } if args.is_empty() => { + format!("truapi::versioned::{module}::{name}") + } + TypeRef::Named { name, args } => { + let args = args + .iter() + .map(|arg| rust_type(arg, module)) + .collect::>>()? + .join(", "); + format!("truapi::versioned::{module}::{name}<{args}>") + } + TypeRef::Vec(inner) => format!("alloc::vec::Vec<{}>", rust_type(inner, module)?), + TypeRef::Option(inner) => format!("Option<{}>", rust_type(inner, module)?), + TypeRef::Tuple(items) => { + let mut rendered = items + .iter() + .map(|item| rust_type(item, module)) + .collect::>>()? + .join(", "); + if items.len() == 1 { + rendered.push(','); + } + format!("({rendered})") + } + TypeRef::Array(inner, len) => format!("[{}; {len}]", rust_type(inner, module)?), + TypeRef::Generic(name) => bail!("unresolved generic type `{name}` in method surface"), + TypeRef::Unit => "()".to_string(), + }) +} + +fn emit_catalog(out: &mut String, name: &str, markers: &[String]) -> Result<()> { + writeln!(out, "/// Generated `{name}` execution catalog.")?; + writeln!(out, "pub const {name}: &[MethodDescriptor] = &[")?; + for marker in markers { + writeln!(out, " {marker}::DESCRIPTOR,")?; + } + writeln!(out, "];\n")?; + Ok(()) +} diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index 4cb98fb1e..0df14fce8 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -144,6 +144,7 @@ pub fn generate_wasm_bridge( struct BridgeCtx<'a> { api_types: BTreeMap<&'a str, &'a TypeDef>, + api_type_paths: &'a BTreeMap, codec_types: BTreeSet<&'a str>, local_types: BTreeSet<&'a str>, local_codec_types: BTreeSet<&'a str>, @@ -170,6 +171,7 @@ impl<'a> BridgeCtx<'a> { let local_codec_types = collect_local_bridge_payload_types(definition); Self { api_types, + api_type_paths: &definition.api_type_paths, codec_types, local_types, local_codec_types, @@ -531,7 +533,11 @@ fn rust_type(ty: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { )), TypeRef::Named { name, args } if ctx.api_types.contains_key(name.as_str()) => { if args.is_empty() { - Ok(format!("v01::{name}")) + Ok(match ctx.api_type_paths.get(name) { + Some(path) if path.starts_with("truapi::v01::") => format!("v01::{name}"), + Some(path) => path.clone(), + None => format!("v01::{name}"), + }) } else { bail!("generic API type `{name}` is not supported in wasm bridge") } diff --git a/rust/crates/truapi-codegen/src/rust/wire_table.rs b/rust/crates/truapi-codegen/src/rust/wire_table.rs index ad46f31a1..51759cb5b 100644 --- a/rust/crates/truapi-codegen/src/rust/wire_table.rs +++ b/rust/crates/truapi-codegen/src/rust/wire_table.rs @@ -106,7 +106,7 @@ pub fn generate_wire_table(api: &ApiDefinition, schema_hash: &str) -> Result Result { +pub(super) fn trait_wire_id(trait_def: &TraitDef) -> Result { trait_def.wire_trait_id.ok_or_else(|| { anyhow::anyhow!( "trait `{}` is missing #[wire_trait(id = N)] annotation", @@ -115,14 +115,18 @@ fn trait_wire_id(trait_def: &TraitDef) -> Result { }) } -fn method_entry(trait_def: &TraitDef, trait_id: u8, method: &MethodDef) -> Result { - let method_id = method.wire.id.ok_or_else(|| { +pub(super) fn method_wire_id(trait_def: &TraitDef, method: &MethodDef) -> Result { + method.wire.id.ok_or_else(|| { anyhow::anyhow!( "method `{}::{}` is missing #[wire(id = N)] annotation", trait_def.name, method.name ) - })?; + }) +} + +fn method_entry(trait_def: &TraitDef, trait_id: u8, method: &MethodDef) -> Result { + let method_id = method_wire_id(trait_def, method)?; let ids = MethodIds { trait_id, method_id, diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index 8e4c15ea8..2cfdba9cd 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -1803,6 +1803,7 @@ fn render_ts_doc_line(line: &str) -> String { #[cfg(test)] mod tests { use super::*; + use std::collections::BTreeMap; fn named(name: &str) -> TypeRef { TypeRef::Named { @@ -1826,6 +1827,7 @@ mod tests { fn local_codec_imports_external_alias_as_runtime_value() { let definition = PlatformDefinition { traits: Vec::new(), + api_type_paths: BTreeMap::new(), types: vec![TypeDef { name: "SessionUiInfo".to_string(), module_path: vec!["truapi".to_string(), "platform".to_string()], @@ -1861,6 +1863,7 @@ mod tests { methods: vec![method], }], types: Vec::new(), + api_type_paths: BTreeMap::new(), super_trait: None, optional_super_trait: None, } diff --git a/rust/crates/truapi-codegen/tests/deterministic_emit.rs b/rust/crates/truapi-codegen/tests/deterministic_emit.rs new file mode 100644 index 000000000..f6e0d5843 --- /dev/null +++ b/rust/crates/truapi-codegen/tests/deterministic_emit.rs @@ -0,0 +1,144 @@ +//! Integration test for deterministic protocol code generation. +//! +//! Protocol rustdoc uses a dedicated target directory, separate from concurrent +//! runtime documentation. The nightly named in `nightly-toolchain` is required; +//! `TRUAPI_NIGHTLY_TOOLCHAIN` overrides it. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The dated nightly CI runs, unless `TRUAPI_NIGHTLY_TOOLCHAIN` names another. +fn nightly_toolchain() -> String { + std::env::var("TRUAPI_NIGHTLY_TOOLCHAIN").unwrap_or_else(|_| { + include_str!("../../../../nightly-toolchain") + .trim() + .to_string() + }) +} + +/// Build the protocol definitions with the same nightly used by codegen. +fn produce_rustdoc_json(workspace_root: &Path) -> PathBuf { + let target_dir = workspace_root + .join("target/codegen-test-rustdoc/truapi_--no-default-features_--features_host-api"); + let toolchain = nightly_toolchain(); + let output = Command::new("cargo") + .arg(format!("+{toolchain}")) + .args([ + "rustdoc", + "-p", + "truapi", + "--no-default-features", + "--features", + "host-api", + "--target-dir", + ]) + .arg(&target_dir) + .args(["--", "-Z", "unstable-options", "--output-format", "json"]) + .current_dir(workspace_root) + .output() + .expect("failed to spawn rustdoc; install the pinned nightly named in nightly-toolchain"); + assert!( + output.status.success(), + "`cargo +{toolchain} rustdoc -p truapi` failed (status {}); that nightly toolchain is required.\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + let json = target_dir.join("doc/truapi.json"); + assert!( + json.exists(), + "rustdoc JSON not found at {} after successful rustdoc invocation", + json.display(), + ); + json +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .expect("workspace root above rust/crates/truapi-codegen") + .to_path_buf() +} + +fn workspace_tempdir(workspace: &Path) -> tempfile::TempDir { + let parent = workspace.join("target/codegen-test-tmp"); + fs::create_dir_all(&parent).expect("create workspace codegen temp directory"); + tempfile::Builder::new() + .prefix("golden-") + .tempdir_in(parent) + .expect("workspace tempdir") +} + +/// Idempotence guard at the integration level: running the binary twice +/// against the same input must produce identical output. This catches +/// non-determinism (HashMap iteration order, timestamps, etc.) that the +/// inline unit tests might miss because they exercise smaller APIs. +#[test] +fn binary_emission_is_idempotent() { + let workspace = workspace_root(); + let rustdoc_json = produce_rustdoc_json(&workspace); + + // Every emitted file, not a hand-picked list: the nondeterminism this + // guards against has landed in the TypeScript output as readily as in the + // Rust output, and a list only covers what someone remembered to add. + let run_once = || -> BTreeMap { + let tmp = workspace_tempdir(&workspace); + let status = Command::new(env!("CARGO_BIN_EXE_truapi-codegen")) + .args([ + "--input", + rustdoc_json.to_str().unwrap(), + "--output", + tmp.path().join("ts").to_str().unwrap(), + "--rust-output", + tmp.path().join("rust").to_str().unwrap(), + ]) + .status() + .expect("run truapi-codegen"); + assert!(status.success(), "codegen run failed"); + read_tree(tmp.path()) + }; + + let first = run_once(); + let second = run_once(); + assert!(!first.is_empty(), "codegen emitted nothing"); + assert_eq!( + first.keys().collect::>(), + second.keys().collect::>(), + "the two runs emitted different files" + ); + for (path, contents) in &first { + assert_eq!( + contents, + &second[path], + "{} differs between runs", + path.display() + ); + } +} + +/// Every file under `root`, keyed by its path relative to `root`. +fn read_tree(root: &Path) -> BTreeMap { + let mut files = BTreeMap::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(dir) = pending.pop() { + for entry in fs::read_dir(&dir).expect("read generated directory") { + let path = entry.expect("read generated entry").path(); + if path.is_dir() { + pending.push(path); + } else { + let relative = path + .strip_prefix(root) + .expect("path under root") + .to_path_buf(); + files.insert( + relative, + fs::read_to_string(&path).expect("read generated file"), + ); + } + } + } + files +} diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs deleted file mode 100644 index c893deb05..000000000 --- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs +++ /dev/null @@ -1,2630 +0,0 @@ -//! Wire dispatcher for the unified `TrUApi` trait. -//! -//! Auto-generated by truapi-codegen. Do not edit. - -// Responses are downgraded to the caller's version uniformly, including -// the methods whose payload is unit and for which the conversion is a -// no-op. -#![allow(clippy::unit_arg)] - -use std::sync::Arc; - -use parity_scale_codec::{DecodeAll, Encode}; - -use truapi::CallContext; -use truapi::api::{ - Account, - Chain, - Chat, - CoinPayment, - Contacts, - Entropy, - LocalStorage, - Locale, - Notifications, - Payment, - Permissions, - Pocket, - Preimage, - Renderer, - ResourceAllocation, - Signing, - StatementStore, - System, - Theme, - Worker, -}; -use truapi::versioned::{self, Versioned}; -use crate::platform::ProductExecutionKind; - -use crate::dispatcher::Dispatcher; -use crate::frame::downgrade_call_error; -use crate::generated::wire_table; -use crate::subscription::{ - HostInitiatedSubscriptionManager, subscription_interrupt, subscription_stream, -}; -use crate::transport::Transport; - -/// Register every TrUAPI method with the dispatcher. -pub fn register

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: truapi::api::TrUApi + 'static, -{ - register_account(dispatcher, host.clone()); - register_chain(dispatcher, host.clone()); - register_chat(dispatcher, host.clone()); - register_coin_payment(dispatcher, host.clone()); - register_contacts(dispatcher, host.clone()); - register_entropy(dispatcher, host.clone()); - register_local_storage(dispatcher, host.clone()); - register_locale(dispatcher, host.clone()); - register_notifications(dispatcher, host.clone()); - register_payment(dispatcher, host.clone()); - register_permissions(dispatcher, host.clone()); - register_pocket(dispatcher, host.clone()); - register_preimage(dispatcher, host.clone()); - register_renderer(dispatcher, host.clone()); - register_resource_allocation(dispatcher, host.clone()); - register_signing(dispatcher, host.clone()); - register_statement_store(dispatcher, host.clone()); - register_system(dispatcher, host.clone()); - register_theme(dispatcher, host.clone()); - register_worker(dispatcher, host); -} - -/// Start the host-initiated `renderer_render` subscription. -pub fn renderer_render( - subscriptions: &HostInitiatedSubscriptionManager, - transport: Arc, - request: versioned::renderer::ProductRendererRenderRequest, -) -> truapi::Subscription> { - subscriptions.start( - wire_table::RENDERER_RENDER, - parity_scale_codec::Encode::encode(&request), - transport, - ) -} - -fn register_account

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Account + Send + Sync + 'static, -{ - { - let host = host.clone(); - dispatcher.on_subscription(wire_table::ACCOUNT_CONNECTION_STATUS_SUBSCRIBE, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::account::HostAccountConnectionStatusSubscribeRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - let stream = host.connection_status_subscribe(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::ACCOUNT_GET_ACCOUNT, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::account::HostAccountGetRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.get_account(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::ACCOUNT_GET_ACCOUNT_ALIAS, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::account::HostAccountGetAliasRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.get_account_alias(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::ACCOUNT_CREATE_ACCOUNT_PROOF, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::account::HostAccountCreateProofRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.create_account_proof(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::ACCOUNT_SIGN_VRF, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::account::HostAccountSignVrfRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.sign_vrf(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::ACCOUNT_REGISTER_RING_VRF_KEY, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::account::HostAccountRegisterRingVrfKeyRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.register_ring_vrf_key(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::ACCOUNT_LIST_RING_VRF_KEYS, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::account::HostAccountListRingVrfKeysRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.list_ring_vrf_keys(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::ACCOUNT_RING_VRF_SIGN, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::account::HostAccountRingVrfSignRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.ring_vrf_sign(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::ACCOUNT_GET_LEGACY_ACCOUNTS, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::account::HostGetLegacyAccountsRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.get_legacy_accounts(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::ACCOUNT_GET_USER_ID, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::account::HostGetUserIdRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.get_user_id(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host; - dispatcher.on_request(wire_table::ACCOUNT_REQUEST_LOGIN, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::account::HostRequestLoginRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.request_login(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } -} - -fn register_chain

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Chain + Send + Sync + 'static, -{ - { - let host = host.clone(); - dispatcher.on_subscription(wire_table::CHAIN_FOLLOW_HEAD_SUBSCRIBE, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chain::RemoteChainHeadFollowRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - let stream = host.follow_head_subscribe(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::CHAIN_GET_HEAD_HEADER, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chain::RemoteChainHeadHeaderRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.get_head_header(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::CHAIN_GET_HEAD_BODY, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chain::RemoteChainHeadBodyRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.get_head_body(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::CHAIN_GET_HEAD_STORAGE, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chain::RemoteChainHeadStorageRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.get_head_storage(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::CHAIN_CALL_HEAD, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chain::RemoteChainHeadCallRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.call_head(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::CHAIN_UNPIN_HEAD, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chain::RemoteChainHeadUnpinRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.unpin_head(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::CHAIN_CONTINUE_HEAD, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chain::RemoteChainHeadContinueRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.continue_head(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::CHAIN_STOP_HEAD_OPERATION, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chain::RemoteChainHeadStopOperationRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.stop_head_operation(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::CHAIN_GET_SPEC_GENESIS_HASH, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chain::RemoteChainSpecGenesisHashRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.get_spec_genesis_hash(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::CHAIN_GET_SPEC_CHAIN_NAME, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chain::RemoteChainSpecChainNameRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.get_spec_chain_name(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::CHAIN_GET_SPEC_PROPERTIES, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chain::RemoteChainSpecPropertiesRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.get_spec_properties(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::CHAIN_BROADCAST_TRANSACTION, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chain::RemoteChainTransactionBroadcastRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.broadcast_transaction(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::CHAIN_STOP_TRANSACTION, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chain::RemoteChainTransactionStopRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.stop_transaction(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host; - dispatcher.on_request(wire_table::CHAIN_GET_CHAIN_INFO, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chain::RemoteChainInfoRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.get_chain_info(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } -} - -fn register_chat

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Chat + Send + Sync + 'static, -{ - { - let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); - let host = host.clone(); - dispatcher.on_request(wire_table::CHAT_CREATE_ROOM, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chat::HostChatCreateRoomRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - if !execution_allowed { - let error: truapi::CallError = truapi::CallError::Denied; - let result: Result> = Err(error); - return result.encode(); - } - let result: Result> = - match host.create_room(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); - let host = host.clone(); - dispatcher.on_request(wire_table::CHAT_REGISTER_BOT, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chat::HostChatRegisterBotRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - if !execution_allowed { - let error: truapi::CallError = truapi::CallError::Denied; - let result: Result> = Err(error); - return result.encode(); - } - let result: Result> = - match host.register_bot(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); - let host = host.clone(); - dispatcher.on_subscription(wire_table::CHAT_LIST_SUBSCRIBE, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chat::HostChatListSubscribeRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - if !execution_allowed { - let error: truapi::CallError = truapi::CallError::Denied; - return Err(subscription_interrupt(error)); - } - let stream = host.list_subscribe(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } - { - let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); - let host = host.clone(); - dispatcher.on_request(wire_table::CHAT_POST_MESSAGE, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chat::HostChatPostMessageRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - if !execution_allowed { - let error: truapi::CallError = truapi::CallError::Denied; - let result: Result> = Err(error); - return result.encode(); - } - let result: Result> = - match host.post_message(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); - let host = host; - dispatcher.on_subscription(wire_table::CHAT_ACTION_SUBSCRIBE, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::chat::HostChatActionSubscribeRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - if !execution_allowed { - let error: truapi::CallError = truapi::CallError::Denied; - return Err(subscription_interrupt(error)); - } - let stream = host.action_subscribe(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } -} - -fn register_coin_payment

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: CoinPayment + Send + Sync + 'static, -{ - { - let host = host.clone(); - dispatcher.on_request(wire_table::COIN_PAYMENT_CREATE_PURSE, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::coin_payment::HostCoinPaymentCreatePurseRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.create_purse(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::COIN_PAYMENT_QUERY_PURSE, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::coin_payment::HostCoinPaymentQueryPurseRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.query_purse(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_subscription(wire_table::COIN_PAYMENT_REBALANCE_PURSE, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::coin_payment::HostCoinPaymentRebalancePurseRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - let stream = host.rebalance_purse(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } - { - let host = host.clone(); - dispatcher.on_subscription(wire_table::COIN_PAYMENT_DELETE_PURSE, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::coin_payment::HostCoinPaymentDeletePurseRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - let stream = host.delete_purse(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::COIN_PAYMENT_CREATE_RECEIVABLE, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::coin_payment::HostCoinPaymentCreateReceivableRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.create_receivable(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::COIN_PAYMENT_CREATE_CHEQUE, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::coin_payment::HostCoinPaymentCreateChequeRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.create_cheque(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_subscription(wire_table::COIN_PAYMENT_DEPOSIT, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::coin_payment::HostCoinPaymentDepositRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - let stream = host.deposit(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } - { - let host = host.clone(); - dispatcher.on_subscription(wire_table::COIN_PAYMENT_REFUND, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::coin_payment::HostCoinPaymentRefundRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - let stream = host.refund(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } - { - let host = host; - dispatcher.on_subscription(wire_table::COIN_PAYMENT_LISTEN_FOR_PAYMENT, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::coin_payment::HostCoinPaymentListenForRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - let stream = host.listen_for_payment(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } -} - -fn register_contacts

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Contacts + Send + Sync + 'static, -{ - { - let host = host; - dispatcher.on_request(wire_table::CONTACTS_PICK, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::contacts::HostContactsPickRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.pick(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } -} - -fn register_entropy

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Entropy + Send + Sync + 'static, -{ - { - let host = host; - dispatcher.on_request(wire_table::ENTROPY_DERIVE, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::entropy::HostDeriveEntropyRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.derive(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } -} - -fn register_local_storage

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: LocalStorage + Send + Sync + 'static, -{ - { - let host = host.clone(); - dispatcher.on_request(wire_table::LOCAL_STORAGE_READ, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::local_storage::HostLocalStorageReadRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.read(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::LOCAL_STORAGE_WRITE, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::local_storage::HostLocalStorageWriteRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.write(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::LOCAL_STORAGE_CLEAR, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::local_storage::HostLocalStorageClearRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.clear(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host; - dispatcher.on_subscription(wire_table::LOCAL_STORAGE_SUBSCRIBE, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::local_storage::HostLocalStorageSubscribeRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - let stream = host.subscribe(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } -} - -fn register_locale

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Locale + Send + Sync + 'static, -{ - { - let host = host; - dispatcher.on_subscription(wire_table::LOCALE_SUBSCRIBE, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::locale::HostLocaleSubscribeRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - let stream = host.subscribe(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } -} - -fn register_notifications

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Notifications + Send + Sync + 'static, -{ - { - let host = host.clone(); - dispatcher.on_request(wire_table::NOTIFICATIONS_SEND_PUSH_NOTIFICATION, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::notifications::HostPushNotificationRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.send_push_notification(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host; - dispatcher.on_request(wire_table::NOTIFICATIONS_CANCEL_PUSH_NOTIFICATION, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::notifications::HostPushNotificationCancelRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.cancel_push_notification(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } -} - -fn register_payment

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Payment + Send + Sync + 'static, -{ - { - let host = host.clone(); - dispatcher.on_subscription(wire_table::PAYMENT_BALANCE_SUBSCRIBE, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::payment::HostPaymentBalanceSubscribeRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - let stream = host.balance_subscribe(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::PAYMENT_REQUEST, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::payment::HostPaymentRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.request(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_subscription(wire_table::PAYMENT_STATUS_SUBSCRIBE, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::payment::HostPaymentStatusSubscribeRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - let stream = host.status_subscribe(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } - { - let host = host; - dispatcher.on_request(wire_table::PAYMENT_TOP_UP, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::payment::HostPaymentTopUpRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.top_up(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } -} - -fn register_permissions

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Permissions + Send + Sync + 'static, -{ - { - let host = host.clone(); - dispatcher.on_request(wire_table::PERMISSIONS_REQUEST_DEVICE_PERMISSION, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::permissions::HostDevicePermissionRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.request_device_permission(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::PERMISSIONS_REQUEST_REMOTE_PERMISSION, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::permissions::RemotePermissionRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.request_remote_permission(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::PERMISSIONS_AUTHORIZE_REMOTE_PERMISSION, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::permissions::RemotePermissionRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.authorize_remote_permission(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host; - dispatcher.on_request(wire_table::PERMISSIONS_AUTHORIZE_DEVICE_PERMISSION, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::permissions::HostDevicePermissionRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.authorize_device_permission(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } -} - -fn register_pocket

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Pocket + Send + Sync + 'static, -{ - { - let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); - let host = host.clone(); - dispatcher.on_subscription(wire_table::POCKET_LIST_SUBSCRIBE, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::pocket::HostPocketListSubscribeRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - if !execution_allowed { - let error: truapi::CallError = truapi::CallError::Denied; - return Err(subscription_interrupt(error)); - } - let stream = host.list_subscribe(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } - { - let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); - let host = host; - dispatcher.on_request(wire_table::POCKET_REMOVE_CARD, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::pocket::HostPocketRemoveCardRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - if !execution_allowed { - let error: truapi::CallError = truapi::CallError::Denied; - let result: Result> = Err(error); - return result.encode(); - } - let result: Result> = - match host.remove_card(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } -} - -fn register_preimage

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Preimage + Send + Sync + 'static, -{ - { - let host = host.clone(); - dispatcher.on_subscription(wire_table::PREIMAGE_LOOKUP_SUBSCRIBE, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::preimage::RemotePreimageLookupSubscribeRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - let stream = host.lookup_subscribe(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } - { - let host = host; - dispatcher.on_request(wire_table::PREIMAGE_SUBMIT, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::preimage::RemotePreimageSubmitRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.submit(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } -} - -fn register_renderer

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Renderer + Send + Sync + 'static, -{ - { - let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); - let host = host; - dispatcher.on_subscription(wire_table::RENDERER_ACTION_SUBSCRIBE, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::renderer::HostRendererActionSubscribeRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - if !execution_allowed { - let error: truapi::CallError = truapi::CallError::Denied; - return Err(subscription_interrupt(error)); - } - let stream = host.action_subscribe(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } -} - -fn register_resource_allocation

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: ResourceAllocation + Send + Sync + 'static, -{ - { - let host = host; - dispatcher.on_request(wire_table::RESOURCE_ALLOCATION_REQUEST, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::resource_allocation::HostRequestResourceAllocationRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.request(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } -} - -fn register_signing

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Signing + Send + Sync + 'static, -{ - { - let host = host.clone(); - dispatcher.on_request(wire_table::SIGNING_CREATE_TRANSACTION, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::signing::HostCreateTransactionRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.create_transaction(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::SIGNING_CREATE_TRANSACTION_WITH_LEGACY_ACCOUNT, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::signing::HostCreateTransactionWithLegacyAccountRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.create_transaction_with_legacy_account(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::SIGNING_SIGN_RAW_WITH_LEGACY_ACCOUNT, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::signing::HostSignRawWithLegacyAccountRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.sign_raw_with_legacy_account(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::SIGNING_SIGN_PAYLOAD_WITH_LEGACY_ACCOUNT, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::signing::HostSignPayloadWithLegacyAccountRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.sign_payload_with_legacy_account(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::SIGNING_SIGN_RAW, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::signing::HostSignRawRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.sign_raw(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::SIGNING_SIGN_PAYLOAD, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::signing::HostSignPayloadRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.sign_payload(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::SIGNING_SIGN_RAW_UNWATERMARKED_DEPRECATED, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::signing::HostSignRawRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.sign_raw_unwatermarked_deprecated(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host; - dispatcher.on_request(wire_table::SIGNING_SIGN_RAW_UNWATERMARKED_DEPRECATED_WITH_LEGACY_ACCOUNT, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::signing::HostSignRawWithLegacyAccountRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.sign_raw_unwatermarked_deprecated_with_legacy_account(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } -} - -fn register_statement_store

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: StatementStore + Send + Sync + 'static, -{ - { - let host = host.clone(); - dispatcher.on_subscription(wire_table::STATEMENT_STORE_SUBSCRIBE, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::statement_store::RemoteStatementStoreSubscribeRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - let stream = host.subscribe(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::STATEMENT_STORE_CREATE_PROOF, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::statement_store::RemoteStatementStoreCreateProofRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.create_proof(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::STATEMENT_STORE_CREATE_PROOF_AUTHORIZED, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.create_proof_authorized(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host; - dispatcher.on_request(wire_table::STATEMENT_STORE_SUBMIT, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::statement_store::RemoteStatementStoreSubmitRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.submit(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } -} - -fn register_system

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: System + Send + Sync + 'static, -{ - { - let host = host.clone(); - dispatcher.on_request(wire_table::SYSTEM_HANDSHAKE, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::system::HostHandshakeRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.handshake(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::SYSTEM_FEATURE_SUPPORTED, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::system::HostFeatureSupportedRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.feature_supported(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::SYSTEM_NAVIGATE_TO, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::system::HostNavigateToRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.navigate_to(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host.clone(); - dispatcher.on_request(wire_table::SYSTEM_HOST_INFO, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::system::HostInfoRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.host_info(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let host = host; - dispatcher.on_request(wire_table::SYSTEM_GET_PRODUCT_CONTEXT, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::system::HostGetProductContextRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - let result: Result> = - match host.get_product_context(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } -} - -fn register_theme

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Theme + Send + Sync + 'static, -{ - { - let host = host; - dispatcher.on_subscription(wire_table::THEME_SUBSCRIBE, move |request_id: String, bytes: Vec| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::theme::HostThemeSubscribeRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - return Err(subscription_interrupt(error)); - } - }; - let target_version = request.version(); - let cx = CallContext::with_request_id(request_id); - let stream = host.subscribe(&cx, request).await; - let stream = futures::StreamExt::map( - stream, - move |item: Result>| { - item.map(|item| { - ::from_latest( - truapi::versioned::IntoLatest::into_latest(item), - target_version, - ) - }) - .map_err(|error| downgrade_call_error(error, target_version)) - }, - ); - Ok(subscription_stream(stream)) - }) - }); - } -} - -fn register_worker

(dispatcher: &mut Dispatcher, host: Arc

) -where - P: Worker + Send + Sync + 'static, -{ - { - let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); - let host = host.clone(); - dispatcher.on_request(wire_table::WORKER_BEGIN_OPERATION, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::worker::HostWorkerBeginOperationRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - if !execution_allowed { - let error: truapi::CallError = truapi::CallError::Denied; - let result: Result> = Err(error); - return result.encode(); - } - let result: Result> = - match host.begin_operation(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } - { - let execution_allowed = dispatcher.allows_execution(ProductExecutionKind::Worker); - let host = host; - dispatcher.on_request(wire_table::WORKER_END_OPERATION, move |request_id: String, bytes: Vec, cancel: truapi::CancellationToken| { - let host = host.clone(); - Box::pin(async move { - let request: versioned::worker::HostWorkerEndOperationRequest = match DecodeAll::decode_all(&mut &bytes[..]) { - Ok(request) => request, - Err(err) => { - let error: truapi::CallError = - truapi::CallError::MalformedFrame { reason: err.to_string() }; - let result: Result> = Err(error); - return result.encode(); - } - }; - let target_version = request.version(); - let cx = CallContext::with_parts(request_id, cancel); - if !execution_allowed { - let error: truapi::CallError = truapi::CallError::Denied; - let result: Result> = Err(error); - return result.encode(); - } - let result: Result> = - match host.end_operation(&cx, request).await { - Ok(response) => Ok(::from_latest( - truapi::versioned::IntoLatest::into_latest(response), - target_version, - )), - Err(err) => Err(downgrade_call_error(err, target_version)), - }; - result.encode() - }) - }); - } -} diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts deleted file mode 100644 index ef28b7caf..000000000 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ /dev/null @@ -1,295 +0,0 @@ -// Auto-generated by truapi-codegen. Do not edit. -// -// Adapts the typed `HostCallbacks` surface onto the byte-oriented -// callback surface the WASM core invokes. Codec-backed wire and -// platform-local types cross as SCALE bytes (`.enc`/`.dec`); strings, -// primitives and byte blobs pass through unchanged. - -import { - HostChatCreateRoomRequest, - HostChatCreateRoomResponse, - HostChatListSubscribeItem, - HostChatPostMessageRequest, - HostChatPostMessageResponse, - HostChatRegisterBotRequest, - HostChatRegisterBotResponse, - HostDevicePermissionRequest, - HostFeatureSupportedRequest, - HostFeatureSupportedResponse, - HostLocalStorageChangeItem, - HostLocaleSubscribeItem, - HostPocketListSubscribeItem, - HostPocketRemoveCardRequest, - HostPushNotificationRequest, - HostPushNotificationResponse, - HostThemeSubscribeItem, - HostWorkerBeginOperationResponse, - RemotePermissionRequest, -} from "@parity/truapi"; -import type { GenericError } from "@parity/truapi"; -import { - AuthState, - CoreStorageKey, - DevicePermissionStatus, - HostChainSet, - HostContactLookup, - HostContactMatches, - HostContactPick, - PermissionDecision, - ProductContext, - UserConfirmationReview, -} from "./host-callbacks.js"; -import type { RequiredHostCallbacks } from "./host-callbacks.js"; - -import type { ChainConnect } from "../runtime.js"; -import { chainConnectAdapter, driveResultStream } from "../adapter-support.js"; - -/** - * Byte-oriented callback surface the WASM core invokes. Members of an - * optional capability are absent when the host omits the capability; - * the core then answers the matching product calls with `Unsupported`. - */ -export interface RawCallbacks { - authStateChanged(state: Uint8Array): void; - chainConnect: ChainConnect; - createChatRoom?( - product: Uint8Array, - request: Uint8Array, - ): Promise; - registerChatBot?( - product: Uint8Array, - request: Uint8Array, - ): Promise; - postChatMessage?( - product: Uint8Array, - request: Uint8Array, - ): Promise; - subscribeChatRooms?( - product: Uint8Array, - sendItem: (item?: Uint8Array) => void, - sendError: (error: GenericError) => void, - ): (() => void) | void; - contacts?(lookup: Uint8Array): Promise; - pickContact?(product: Uint8Array): Promise; - readCoreStorage(key: Uint8Array): Promise; - writeCoreStorage(key: Uint8Array, value: Uint8Array): Promise; - clearCoreStorage(key: Uint8Array): Promise; - featureSupported(request: Uint8Array): Promise; - supportedChains(): Promise; - subscribeLocale( - sendItem: (item?: Uint8Array) => void, - sendError: (error: GenericError) => void, - ): (() => void) | void; - navigateTo(url: string): Promise; - pushNotification(notification: Uint8Array): Promise; - cancelNotification(id: number): Promise; - devicePermissionStatus?(request: Uint8Array): Promise; - devicePermission( - product: Uint8Array, - request: Uint8Array, - ): Promise; - remotePermission( - product: Uint8Array, - request: Uint8Array, - ): Promise; - subscribePocketCards?( - product: Uint8Array, - sendItem: (item?: Uint8Array) => void, - sendError: (error: GenericError) => void, - ): (() => void) | void; - removePocketCard?(product: Uint8Array, request: Uint8Array): Promise; - lookupPreimage( - key: Uint8Array, - sendItem: (item?: Uint8Array) => void, - sendError: (error: GenericError) => void, - ): (() => void) | void; - beginOperation(product: Uint8Array, label: string): Promise; - endOperation(product: Uint8Array, id: number): Promise; - read(key: string): Promise; - write(key: string, value: Uint8Array): Promise; - clear(key: string): Promise; - subscribeStorage( - key: string, - sendItem: (item?: Uint8Array) => void, - sendError: (error: GenericError) => void, - ): (() => void) | void; - subscribeTheme( - sendItem: (item?: Uint8Array) => void, - sendError: (error: GenericError) => void, - ): (() => void) | void; - confirmPermission(review: Uint8Array): Promise; - confirmUserAction(review: Uint8Array): Promise; -} -/** Adapt typed host callbacks into the raw SCALE callback surface the - * WASM core invokes. */ -export function createWasmRawCallbacks( - callbacks: RequiredHostCallbacks, -): RawCallbacks { - const chat = callbacks.chat; - const contacts = callbacks.contacts; - const permissionStatus = callbacks.permissionStatus; - const pocket = callbacks.pocket; - return { - authStateChanged: async (state) => - await callbacks.auth.authStateChanged(AuthState.dec(state)), - chainConnect: chainConnectAdapter(callbacks.chain), - ...(chat - ? { - createChatRoom: async (product, request) => - HostChatCreateRoomResponse.enc( - await chat.createChatRoom( - ProductContext.dec(product), - HostChatCreateRoomRequest.dec(request), - ), - ), - registerChatBot: async (product, request) => - HostChatRegisterBotResponse.enc( - await chat.registerChatBot( - ProductContext.dec(product), - HostChatRegisterBotRequest.dec(request), - ), - ), - postChatMessage: async (product, request) => - HostChatPostMessageResponse.enc( - await chat.postChatMessage( - ProductContext.dec(product), - HostChatPostMessageRequest.dec(request), - ), - ), - subscribeChatRooms: (product, sendItem, sendError) => - driveResultStream( - chat.subscribeChatRooms(ProductContext.dec(product)), - (item) => sendItem(HostChatListSubscribeItem.enc(item)), - sendError, - ), - } - : {}), - ...(contacts - ? { - contacts: async (lookup) => - HostContactMatches.enc( - await contacts.contacts(HostContactLookup.dec(lookup)), - ), - pickContact: async (product) => - HostContactPick.enc( - await contacts.pickContact(ProductContext.dec(product)), - ), - } - : {}), - readCoreStorage: async (key) => - await callbacks.coreStorage.readCoreStorage(CoreStorageKey.dec(key)), - writeCoreStorage: async (key, value) => - await callbacks.coreStorage.writeCoreStorage( - CoreStorageKey.dec(key), - value, - ), - clearCoreStorage: async (key) => - await callbacks.coreStorage.clearCoreStorage(CoreStorageKey.dec(key)), - featureSupported: async (request) => - HostFeatureSupportedResponse.enc( - await callbacks.features.featureSupported( - HostFeatureSupportedRequest.dec(request), - ), - ), - supportedChains: async () => - HostChainSet.enc(await callbacks.features.supportedChains()), - subscribeLocale: (sendItem, sendError) => - driveResultStream( - callbacks.locale.subscribeLocale(), - (item) => sendItem(HostLocaleSubscribeItem.enc(item)), - sendError, - ), - navigateTo: async (url) => await callbacks.navigation.navigateTo(url), - pushNotification: async (notification) => - HostPushNotificationResponse.enc( - await callbacks.notifications.pushNotification( - HostPushNotificationRequest.dec(notification), - ), - ), - cancelNotification: async (id) => - await callbacks.notifications.cancelNotification(id), - ...(permissionStatus - ? { - devicePermissionStatus: async (request) => - DevicePermissionStatus.enc( - await permissionStatus.devicePermissionStatus( - HostDevicePermissionRequest.dec(request), - ), - ), - } - : {}), - devicePermission: async (product, request) => - PermissionDecision.enc( - await callbacks.permissions.devicePermission( - ProductContext.dec(product), - HostDevicePermissionRequest.dec(request), - ), - ), - remotePermission: async (product, request) => - PermissionDecision.enc( - await callbacks.permissions.remotePermission( - ProductContext.dec(product), - RemotePermissionRequest.dec(request), - ), - ), - ...(pocket - ? { - subscribePocketCards: (product, sendItem, sendError) => - driveResultStream( - pocket.subscribePocketCards(ProductContext.dec(product)), - (item) => sendItem(HostPocketListSubscribeItem.enc(item)), - sendError, - ), - removePocketCard: async (product, request) => - await pocket.removePocketCard( - ProductContext.dec(product), - HostPocketRemoveCardRequest.dec(request), - ), - } - : {}), - lookupPreimage: (key, sendItem, sendError) => - driveResultStream( - callbacks.preimage.lookupPreimage(key), - sendItem, - sendError, - ), - beginOperation: async (product, label) => - HostWorkerBeginOperationResponse.enc( - await callbacks.productOperations.beginOperation( - ProductContext.dec(product), - label, - ), - ), - endOperation: async (product, id) => - await callbacks.productOperations.endOperation( - ProductContext.dec(product), - id, - ), - read: async (key) => await callbacks.productStorage.read(key), - write: async (key, value) => - await callbacks.productStorage.write(key, value), - clear: async (key) => await callbacks.productStorage.clear(key), - subscribeStorage: (key, sendItem, sendError) => - driveResultStream( - callbacks.productStorage.subscribeStorage(key), - (item) => sendItem(HostLocalStorageChangeItem.enc(item)), - sendError, - ), - subscribeTheme: (sendItem, sendError) => - driveResultStream( - callbacks.theme.subscribeTheme(), - (item) => sendItem(HostThemeSubscribeItem.enc(item)), - sendError, - ), - confirmPermission: async (review) => - PermissionDecision.enc( - await callbacks.userConfirmation.confirmPermission( - UserConfirmationReview.dec(review), - ), - ), - confirmUserAction: async (review) => - await callbacks.userConfirmation.confirmUserAction( - UserConfirmationReview.dec(review), - ), - }; -} diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts deleted file mode 100644 index 708e3463f..000000000 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ /dev/null @@ -1,1713 +0,0 @@ -// Auto-generated by truapi-codegen. Do not edit. -// -// Typed host-callbacks surface derived from the `platform` modules' -// capability traits. One interface per Rust trait + a composite -// `HostCallbacks` interface that mirrors the `Platform` super-trait. - -import * as S from "@parity/truapi/scale"; - -import { - AllocatableResource, - Bytes32, - ChainIdentifier, - HostAccountSignVrfRequest, - HostDevicePermissionRequest, - HostSignPayloadRequest, - HostSignPayloadWithLegacyAccountRequest, - HostSignRawRequest, - HostSignRawWithLegacyAccountRequest, - LegacyAccountTxPayload, - ProductAccountId, - ProductAccountTxPayload, - ProductProofContext, - RemotePermissionRequest, - RingLocation, -} from "@parity/truapi"; - -import type { - GenericError, - HostChatCreateRoomRequest, - HostChatCreateRoomResponse, - HostChatListSubscribeItem, - HostChatPostMessageRequest, - HostChatPostMessageResponse, - HostChatRegisterBotRequest, - HostChatRegisterBotResponse, - HostFeatureSupportedRequest, - HostFeatureSupportedResponse, - HostLocalStorageChangeItem, - HostLocaleSubscribeItem, - HostPocketListSubscribeItem, - HostPocketRemoveCardRequest, - HostPushNotificationRequest, - HostPushNotificationResponse, - HostThemeSubscribeItem, - HostWorkerBeginOperationResponse, - Result, -} from "@parity/truapi"; - -/** - * Review shown before a product asks to access another product account. - */ -export interface AccountAccessReview { - /** - * Product currently handling the request. - */ - requestingProductId: string; - - /** - * Product whose account is being requested. - */ - targetProductId: string; -} - -/** - * Review shown before a product derives a contextual alias (RFC 0004). - */ -export interface AccountAliasReview { - /** - * Product requesting the alias. - */ - callingProductId: string; - - /** - * Product-scoped context the alias is bound to. - */ - context: ProductProofContext; - - /** - * Ring the alias is derived against. - */ - ringLocation: RingLocation; -} - -/** - * Auth/session lifecycle state the core projects for host UI. The core owns - * every transition and emits states in order; hosts render the current state - * and never derive auth UI from any other signal. - */ -export type AuthState = - /** - * No active session and no login in progress. - */ - | { tag: "Disconnected"; value?: undefined } - /** - * A login is in progress: present the pairing deeplink/QR. Leave this - * state only on a subsequent emission (connected, failed, or - * disconnected after cancellation). - */ - | { tag: "Pairing"; value: { deeplink: string } } - /** - * A session is active. - */ - | { tag: "Connected"; value: SessionUiInfo } - /** - * The last login attempt failed; show the reason and offer a retry. - */ - | { tag: "LoginFailed"; value: { kind: LoginFailureKind; reason: string } } - /** - * The wallet accepted the pairing request and the core is resolving and - * persisting the session. Hosts should replace the pairing QR with an - * in-progress presentation until a terminal state is emitted. - */ - | { tag: "Authenticating"; value?: undefined }; - -/** - * Core-owned host-private storage slots. Products never address these slots; - * the host chooses the backing store for each slot. - * - * Storage is host-local; `storage.md` records the current status quo: - * - */ -export type CoreStorageKey = - /** - * Opaque SSO/auth session blob. - */ - | { tag: "AuthSession"; value?: undefined } - /** - * Pairing device identity used during SSO flows. - */ - | { tag: "PairingDeviceIdentity"; value?: undefined } - /** - * Persisted authorization for one product-scoped permission request. - */ - | { - tag: "PermissionAuthorization"; - value: { productId: string; request: PermissionAuthorizationRequest }; - } - /** - * Persisted allowance-slot keys for one paired SSO session. - */ - | { tag: "AllowanceKeys"; value: { sessionId: string } } - /** - * Last processed SSO pairing response statement for the pairing device. - */ - | { tag: "LastProcessedPairingStatement"; value?: undefined } - /** - * Legacy unscoped RFC-0010 AutoSigning secret. Core only addresses this - * slot to reject and erase pre-scoping entries. - */ - | { tag: "AutoSigningKey"; value: { productId: string } } - /** - * Wallet-bound RFC-0010 AutoSigning capabilities for the active pairing. - */ - | { tag: "AutoSigningKeys"; value?: undefined } - /** - * Wallet-bound RFC-0024 ring-VRF registry snapshot. - */ - | { tag: "RingVrfRegistry"; value: { rootPublicKey: Uint8Array } } - /** - * Statement-store allowance targets the signing host keeps renewed. - */ - | { tag: "StatementRenewalTargets"; value?: undefined } - /** - * This device's long-lived X25519 encryption secret, advertised to peers - * as the device encryption public key. Random rather than identity-derived - * so devices restoring one identity stay individually addressable. - * - * Hosts must back this slot with storage scoped to the install, outliving - * logout and any per-user namespacing: once it changes, peers addressing - * the previous key can no longer reach this device. - */ - | { tag: "DeviceEncryptionKey"; value?: undefined } - /** - * One product's hard-subtree public key, as the Account Holder answered it - * for this paired session. Product account is a hard derivation, so the - * answer is fixed for the pair and read back instead of re-asking the - * wallet on every launch. - * - * The value is the 32-byte key with no framing, so a host can derive - * product account addresses from the slot it already stores. These are - * public keys: every address derived from them already appears on the - * reviews the host draws. - */ - | { tag: "ProductSubtree"; value: { sessionId: string; productId: string } } - /** - * Signing-host request replay state for one wallet and pairing peer. - * - * The value is a versioned, bounded replay ledger owned by the core. - */ - | { - tag: "SsoResponderRequestLedger"; - value: { - rootPublicKey: Uint8Array; - peerStatementAccountId: Uint8Array; - peerEncryptionPublicKey: Uint8Array; - }; - } - /** - * Cached root manifest of one product, as published to dotNS. - * - * The value carries the manifest JSON alongside the time it was read. The - * core honours it for a bounded lifetime, which is what makes a revoked - * trust grant eventually take effect. - */ - | { tag: "ProductManifest"; value: { productId: string } }; - -/** - * Review shown before a product creates a ring-VRF proof (RFC 0004). - */ -export interface CreateProofReview { - /** - * Product requesting the proof. - */ - callingProductId: string; - - /** - * Product-scoped context the proof's alias is bound to. - */ - context: ProductProofContext; - - /** - * Ring the proof is generated against. - */ - ringLocation: RingLocation; - - /** - * Opaque message bound into the proof. - */ - message: Uint8Array; -} - -/** - * Review shown before a transaction-creation request is sent to the paired wallet. - */ -export type CreateTransactionReview = - /** - * Product-account transaction request. - */ - | { - tag: "Product"; - value: { callingProductId?: string; payload: ProductAccountTxPayload }; - } - /** - * Legacy-account transaction request. - */ - | { tag: "LegacyAccount"; value: LegacyAccountTxPayload }; - -/** - * What the operating system currently says about a device capability. - * - * Distinct from `PermissionAuthorizationStatus`, which is the product-scoped - * decision the user made through TrUAPI. The two answer different questions - * and are combined rather than substituted: a capability is usable only when - * the product holds a grant *and* the OS still allows it. - */ -export type DevicePermissionStatus = - | "Granted" - | "Denied" - | "NotDetermined" - | "NotApplicable"; - -/** - * One chain a host serves: a protocol chain role mapped to the concrete - * chain of the host's configured environment. - */ -export interface HostChainEntry { - /** - * Protocol role this entry answers for. - */ - identifier: ChainIdentifier; - - /** - * Genesis hash identifying the chain in all chain-scoped calls. - */ - genesisHash: Bytes32; -} - -/** - * The chain set a host serves: its environment plus one entry per chain role. - */ -export interface HostChainSet { - /** - * Ecosystem the host is configured for, e.g. "polkadot", "paseo". - */ - network: string; - - /** - * Chains this host serves, keyed by protocol role. - */ - chains: Array; -} - -/** - * Contact handles the core needs turned back into accounts, and the key they - * were minted under. - * - * A handle is `BLAKE2b-256(key = handle_key, message = account)`, the account - * being its 32 raw bytes. The host holds the accounts, so it is the one that - * can match: hash each contact's account under `handle_key`, or keep that hash - * as an indexed column for the session, and look the handles up. - */ -export interface HostContactLookup { - /** - * The key every handle here was minted under. Per session, and never - * given to a product. - */ - handleKey: Bytes32; - - /** - * The handles to resolve, in the order the answer must follow. - */ - handles: Array; -} - -/** - * The host's answer to a `HostContactLookup`. - * - * A named wrapper because the callback emitter cannot return a bare `Vec`. - */ -export interface HostContactMatches { - /** - * One entry per requested handle, in order: the contact's account, or - * ``undefined`` when no current contact hashes to it. - */ - accounts: Array; -} - -/** - * How a host's contact picker ended. - */ -export type HostContactPick = - /** - * The user chose this account. - * - * Consumed by the core to mint the product-facing handle and never - * forwarded to a product: it is the person's real account, and the handle - * exists precisely so a product does not receive it. - */ - | { tag: "Picked"; value: { account: Bytes32 } } - /** - * The user closed the picker without choosing. - */ - | { tag: "Dismissed"; value?: undefined } - /** - * The user has no contacts, so the host drew nothing. - */ - | { tag: "NoContacts"; value?: undefined } - /** - * This host resolves contacts but cannot present a picker. The core - * answers the product `Unsupported`, so it can tell "try again later" - * apart from "this host will never pick". - */ - | { tag: "Unsupported"; value?: undefined }; - -/** - * Review shown before a product learns the user's primary identity. - */ -export interface IdentityDisclosureReview { - /** - * Product currently handling the request. - */ - productId: string; -} - -/** - * Why a login attempt failed, for hosts that need to act on the cause rather - * than only display it. - */ -export type LoginFailureKind = "NoFreeAllowanceSlots" | "Other"; - -/** - * Permission request whose authorization status can be inspected or updated - * by host administration UI. - */ -export type PermissionAuthorizationRequest = - /** - * Device-level permission such as camera, microphone, or location. - */ - | { tag: "Device"; value: HostDevicePermissionRequest } - /** - * Remote/product-scoped permission such as chain submit or HTTP access. - */ - | { tag: "Remote"; value: RemotePermissionRequest } - /** - * Product-scoped permission to disclose the user's primary identity. - */ - | { tag: "IdentityDisclosure"; value?: undefined } - /** - * Product-scoped permission to access another product's account context. - */ - | { tag: "AccountAccess"; value: { targetProductId: string } }; - -/** - * Authorization status for a permission request. - * - * `NotDetermined` means the core has no saved or one-use answer and will prompt the - * host the next time the product requests this permission. - */ -export type PermissionAuthorizationStatus = - | "NotDetermined" - | "Denied" - | "Authorized"; - -/** - * User decision including how long an authorization should last. - */ -export type PermissionDecision = "AllowOnce" | "AllowAlways" | "Deny"; - -/** - * Review shown before a preimage is submitted. - */ -export interface PreimageSubmitReview { - /** - * Size of the preimage in bytes. - */ - size: bigint; -} - -/** - * Product identity attached to one product-facing TrUAPI connection. - * - * A host may create multiple product runtimes from the same long-lived host - * runtime, each with its own product context. - */ -export interface ProductContext { - /** - * Product identifier used for account derivation and product-scoped - * storage/permission namespaces. - * - * Host-spec C.7 defines accepted product id forms: - * - */ - productId: string; - - /** - * Trusted kind of executable attached to this connection by the host. - */ - executionKind: ProductExecutionKind; -} - -/** - * Trusted kind of product executable attached to a TrUAPI connection. - * - * Mirrors the executable kinds a product manifest declares. The variants are - * capability classes: a connection reaches an execution-gated service only - * when its kind matches exactly, so `App` and `Widget` carry the same - * capability and differ only in how the host presents them, and `Worker` is - * the only kind that may serve the Chat modality. - */ -export type ProductExecutionKind = "App" | "Widget" | "Worker"; - -/** - * Review shown before a product resolves its own account subtree over SSO, - * when the value is not cached and the core must ask the Account Holder. - */ -export interface ProductSubtreeReview { - /** - * Product resolving its own account. - */ - productId: string; -} - -/** - * Review shown before allocating resources for a product. Names the - * beneficiary product so the user knows which product receives the - * (signing-capable) allowance key they are approving. - */ -export interface ResourceAllocationReview { - /** - * Product the allocation is requested for. - */ - callingProductId: string; - - /** - * Resources to allocate. - */ - resources: Array; -} - -/** - * Decoded session fields a host shell needs to render account UI without - * parsing the opaque session blob the core persists through `CoreStorage`. - */ -export interface SessionUiInfo { - /** - * 32-byte sr25519 root public key of the active session. - */ - publicKey: Bytes32; - - /** - * Wallet identity account id used for the dotNS username lookup on Asset Hub. - */ - identityAccountId?: Bytes32; - - /** - * X25519 public key addressing this identity in chat. Public counterpart - * of the key `CoreAdmin::get_session_chat_identity_key` serves. - */ - chatPublicKey?: Bytes32; - - /** - * X25519 public key of the wallet device that answered pairing. Hosts - * running their own encrypted device-sync channel key it against this. - */ - deviceEncPublicKey?: Bytes32; - - /** - * Statement-store account id the paired wallet signs every session-channel - * statement with. Whether it is scoped to the wallet device or to the - * wallet identity is the wallet's choice, so hosts must not treat it as a - * device discriminator; use `Self::device_enc_public_key` for that. - */ - peerStatementAccountId?: Bytes32; - - /** - * Statement-store account id this device advertises in the pairing - * proposal; the paired wallet registers the statement-store allowance for - * it, and peers address this host on topics derived from it. Rotated per - * login. Secret at `CoreAdmin::get_device_statement_key`. - */ - deviceStatementAccountId?: Bytes32; - - /** - * Short username from the dotNS identity record on Asset Hub. - */ - liteUsername?: string; - - /** - * Fully qualified username from the dotNS identity record on Asset Hub. - */ - fullUsername?: string; -} - -/** - * Review shown before a sign-payload request is sent to the paired wallet. - */ -export type SignPayloadReview = - /** - * Product-account signing request. - */ - | { - tag: "Product"; - value: { callingProductId?: string; request: HostSignPayloadRequest }; - } - /** - * Legacy-account signing request. - */ - | { tag: "LegacyAccount"; value: HostSignPayloadWithLegacyAccountRequest }; - -/** - * Review shown before a sign-raw request is sent to the paired wallet. - * Hosts must display the payload according to `watermarked` and warn that - * unwatermarked signatures can authorize transactions. - */ -export type SignRawReview = - /** - * Product-account raw signing request. - */ - | { - tag: "Product"; - value: { - callingProductId?: string; - request: HostSignRawRequest; - watermarked: boolean; - }; - } - /** - * Legacy-account raw signing request. - */ - | { - tag: "LegacyAccount"; - value: { - request: HostSignRawWithLegacyAccountRequest; - watermarked: boolean; - }; - }; - -/** - * Review shown before signing an RFC-0023 VRF transcript. - */ -export interface SignVrfReview { - /** - * Product making the request. - */ - callingProductId: string; - - /** - * Product account and exact ordered transcript. - */ - request: HostAccountSignVrfRequest; -} - -/** - * Review shown before a product account signs a Statement Store proof - * payload. Distinct from raw-message signing: the payload is the exact - * unsigned statement, signed as-is (no `` envelope), so the host must - * not present it with the raw-signing convention. - */ -export interface StatementStoreProductSignReview { - /** - * Product that asked, when the request carries a caller. See - * `SignPayloadReview::Product`. - */ - callingProductId?: string; - - /** - * Product account that will sign the statement payload. - */ - account: ProductAccountId; - - /** - * Exact unsigned statement payload to be signed. - */ - payload: Uint8Array; -} - -/** - * Review shown before a user-confirmed core action continues. - */ -export type UserConfirmationReview = - /** - * Sign a SCALE payload with a product or legacy account. - */ - | { tag: "SignPayload"; value: SignPayloadReview } - /** - * Sign raw bytes with a product or legacy account. - */ - | { tag: "SignRaw"; value: SignRawReview } - /** - * Sign a Statement Store proof payload with a product account. - */ - | { tag: "StatementStoreProductSign"; value: StatementStoreProductSignReview } - /** - * Create a transaction with a product or legacy account. - */ - | { tag: "CreateTransaction"; value: CreateTransactionReview } - /** - * Allow a product to derive a contextual alias for a ring. - */ - | { tag: "AccountAlias"; value: AccountAliasReview } - /** - * Allow a product to create a ring-VRF proof for a ring. - */ - | { tag: "CreateProof"; value: CreateProofReview } - /** - * Allow a product to learn the user's primary identity. - */ - | { tag: "IdentityDisclosure"; value: IdentityDisclosureReview } - /** - * Allocate resources for the requesting product. - */ - | { tag: "ResourceAllocation"; value: ResourceAllocationReview } - /** - * Submit a preimage to the host-selected backend. - */ - | { tag: "PreimageSubmit"; value: PreimageSubmitReview } - /** - * Allow a product to access another product account. - */ - | { tag: "AccountAccess"; value: AccountAccessReview } - /** - * Sign an RFC-0023 VRF transcript with a product account. - */ - | { tag: "SignVrf"; value: SignVrfReview } - /** - * Resolve a product's own account subtree over SSO. - */ - | { tag: "ProductSubtree"; value: ProductSubtreeReview }; - -/** - * Review shown before a product asks to access another product account. - */ -export const AccountAccessReview: S.Codec = S.lazy( - (): S.Codec => - S.Struct({ - requestingProductId: S.str, - targetProductId: S.str, - }) as S.Codec, -); - -/** - * Review shown before a product derives a contextual alias (RFC 0004). - */ -export const AccountAliasReview: S.Codec = S.lazy( - (): S.Codec => - S.Struct({ - callingProductId: S.str, - context: ProductProofContext, - ringLocation: RingLocation, - }) as S.Codec, -); - -/** - * Auth/session lifecycle state the core projects for host UI. The core owns - * every transition and emits states in order; hosts render the current state - * and never derive auth UI from any other signal. - */ -export const AuthState: S.Codec = S.lazy( - (): S.Codec => - S.TaggedUnion({ - Disconnected: S._void, - Pairing: S.Struct({ deeplink: S.str }) as S.Codec<{ deeplink: string }>, - Connected: SessionUiInfo, - LoginFailed: S.Struct({ - kind: LoginFailureKind, - reason: S.str, - }) as S.Codec<{ kind: LoginFailureKind; reason: string }>, - Authenticating: S._void, - }), -); - -/** - * Core-owned host-private storage slots. Products never address these slots; - * the host chooses the backing store for each slot. - * - * Storage is host-local; `storage.md` records the current status quo: - * - */ -export const CoreStorageKey: S.Codec = S.lazy( - (): S.Codec => - S.TaggedUnion({ - AuthSession: S._void, - PairingDeviceIdentity: S._void, - PermissionAuthorization: S.Struct({ - productId: S.str, - request: PermissionAuthorizationRequest, - }) as S.Codec<{ - productId: string; - request: PermissionAuthorizationRequest; - }>, - AllowanceKeys: S.Struct({ sessionId: S.str }) as S.Codec<{ - sessionId: string; - }>, - LastProcessedPairingStatement: S._void, - AutoSigningKey: S.Struct({ productId: S.str }) as S.Codec<{ - productId: string; - }>, - AutoSigningKeys: S._void, - RingVrfRegistry: S.Struct({ rootPublicKey: S.Bytes(32) }) as S.Codec<{ - rootPublicKey: Uint8Array; - }>, - StatementRenewalTargets: S._void, - DeviceEncryptionKey: S._void, - ProductSubtree: S.Struct({ - sessionId: S.str, - productId: S.str, - }) as S.Codec<{ sessionId: string; productId: string }>, - SsoResponderRequestLedger: S.Struct({ - rootPublicKey: S.Bytes(32), - peerStatementAccountId: S.Bytes(32), - peerEncryptionPublicKey: S.Bytes(32), - }) as S.Codec<{ - rootPublicKey: Uint8Array; - peerStatementAccountId: Uint8Array; - peerEncryptionPublicKey: Uint8Array; - }>, - ProductManifest: S.Struct({ productId: S.str }) as S.Codec<{ - productId: string; - }>, - }), -); - -/** - * Review shown before a product creates a ring-VRF proof (RFC 0004). - */ -export const CreateProofReview: S.Codec = S.lazy( - (): S.Codec => - S.Struct({ - callingProductId: S.str, - context: ProductProofContext, - ringLocation: RingLocation, - message: S.Bytes(), - }) as S.Codec, -); - -/** - * Review shown before a transaction-creation request is sent to the paired wallet. - */ -export const CreateTransactionReview: S.Codec = S.lazy( - (): S.Codec => - S.TaggedUnion({ - Product: S.Struct({ - callingProductId: S.Option(S.str), - payload: ProductAccountTxPayload, - }) as S.Codec<{ - callingProductId?: string; - payload: ProductAccountTxPayload; - }>, - LegacyAccount: LegacyAccountTxPayload, - }), -); - -/** - * What the operating system currently says about a device capability. - * - * Distinct from `PermissionAuthorizationStatus`, which is the product-scoped - * decision the user made through TrUAPI. The two answer different questions - * and are combined rather than substituted: a capability is usable only when - * the product holds a grant *and* the OS still allows it. - */ -export const DevicePermissionStatus: S.Codec = S.lazy( - (): S.Codec => - S.Status("Granted", "Denied", "NotDetermined", "NotApplicable"), -); - -/** - * One chain a host serves: a protocol chain role mapped to the concrete - * chain of the host's configured environment. - */ -export const HostChainEntry: S.Codec = S.lazy( - (): S.Codec => - S.Struct({ - identifier: ChainIdentifier, - genesisHash: Bytes32, - }) as S.Codec, -); - -/** - * The chain set a host serves: its environment plus one entry per chain role. - */ -export const HostChainSet: S.Codec = S.lazy( - (): S.Codec => - S.Struct({ - network: S.str, - chains: S.Vector(HostChainEntry), - }) as S.Codec, -); - -/** - * Contact handles the core needs turned back into accounts, and the key they - * were minted under. - * - * A handle is `BLAKE2b-256(key = handle_key, message = account)`, the account - * being its 32 raw bytes. The host holds the accounts, so it is the one that - * can match: hash each contact's account under `handle_key`, or keep that hash - * as an indexed column for the session, and look the handles up. - */ -export const HostContactLookup: S.Codec = S.lazy( - (): S.Codec => - S.Struct({ - handleKey: Bytes32, - handles: S.Vector(Bytes32), - }) as S.Codec, -); - -/** - * The host's answer to a `HostContactLookup`. - * - * A named wrapper because the callback emitter cannot return a bare `Vec`. - */ -export const HostContactMatches: S.Codec = S.lazy( - (): S.Codec => - S.Struct({ - accounts: S.Vector(S.Option(Bytes32)), - }) as S.Codec, -); - -/** - * How a host's contact picker ended. - */ -export const HostContactPick: S.Codec = S.lazy( - (): S.Codec => - S.TaggedUnion({ - Picked: S.Struct({ account: Bytes32 }) as S.Codec<{ account: Bytes32 }>, - Dismissed: S._void, - NoContacts: S._void, - Unsupported: S._void, - }), -); - -/** - * Review shown before a product learns the user's primary identity. - */ -export const IdentityDisclosureReview: S.Codec = - S.lazy( - (): S.Codec => - S.Struct({ productId: S.str }) as S.Codec, - ); - -/** - * Why a login attempt failed, for hosts that need to act on the cause rather - * than only display it. - */ -export const LoginFailureKind: S.Codec = S.lazy( - (): S.Codec => S.Status("NoFreeAllowanceSlots", "Other"), -); - -/** - * Permission request whose authorization status can be inspected or updated - * by host administration UI. - */ -export const PermissionAuthorizationRequest: S.Codec = - S.lazy( - (): S.Codec => - S.TaggedUnion({ - Device: HostDevicePermissionRequest, - Remote: RemotePermissionRequest, - IdentityDisclosure: S._void, - AccountAccess: S.Struct({ targetProductId: S.str }) as S.Codec<{ - targetProductId: string; - }>, - }), - ); - -/** - * Authorization status for a permission request. - * - * `NotDetermined` means the core has no saved or one-use answer and will prompt the - * host the next time the product requests this permission. - */ -export const PermissionAuthorizationStatus: S.Codec = - S.lazy( - (): S.Codec => - S.Status("NotDetermined", "Denied", "Authorized"), - ); - -/** - * User decision including how long an authorization should last. - */ -export const PermissionDecision: S.Codec = S.lazy( - (): S.Codec => - S.Status("AllowOnce", "AllowAlways", "Deny"), -); - -/** - * Review shown before a preimage is submitted. - */ -export const PreimageSubmitReview: S.Codec = S.lazy( - (): S.Codec => - S.Struct({ size: S.u64 }) as S.Codec, -); - -/** - * Product identity attached to one product-facing TrUAPI connection. - * - * A host may create multiple product runtimes from the same long-lived host - * runtime, each with its own product context. - */ -export const ProductContext: S.Codec = S.lazy( - (): S.Codec => - S.Struct({ - productId: S.str, - executionKind: ProductExecutionKind, - }) as S.Codec, -); - -/** - * Trusted kind of product executable attached to a TrUAPI connection. - * - * Mirrors the executable kinds a product manifest declares. The variants are - * capability classes: a connection reaches an execution-gated service only - * when its kind matches exactly, so `App` and `Widget` carry the same - * capability and differ only in how the host presents them, and `Worker` is - * the only kind that may serve the Chat modality. - */ -export const ProductExecutionKind: S.Codec = S.lazy( - (): S.Codec => S.Status("App", "Widget", "Worker"), -); - -/** - * Review shown before a product resolves its own account subtree over SSO, - * when the value is not cached and the core must ask the Account Holder. - */ -export const ProductSubtreeReview: S.Codec = S.lazy( - (): S.Codec => - S.Struct({ productId: S.str }) as S.Codec, -); - -/** - * Review shown before allocating resources for a product. Names the - * beneficiary product so the user knows which product receives the - * (signing-capable) allowance key they are approving. - */ -export const ResourceAllocationReview: S.Codec = - S.lazy( - (): S.Codec => - S.Struct({ - callingProductId: S.str, - resources: S.Vector(AllocatableResource), - }) as S.Codec, - ); - -/** - * Decoded session fields a host shell needs to render account UI without - * parsing the opaque session blob the core persists through `CoreStorage`. - */ -export const SessionUiInfo: S.Codec = S.lazy( - (): S.Codec => - S.Struct({ - publicKey: Bytes32, - identityAccountId: S.Option(Bytes32), - chatPublicKey: S.Option(Bytes32), - deviceEncPublicKey: S.Option(Bytes32), - peerStatementAccountId: S.Option(Bytes32), - deviceStatementAccountId: S.Option(Bytes32), - liteUsername: S.Option(S.str), - fullUsername: S.Option(S.str), - }) as S.Codec, -); - -/** - * Review shown before a sign-payload request is sent to the paired wallet. - */ -export const SignPayloadReview: S.Codec = S.lazy( - (): S.Codec => - S.TaggedUnion({ - Product: S.Struct({ - callingProductId: S.Option(S.str), - request: HostSignPayloadRequest, - }) as S.Codec<{ - callingProductId?: string; - request: HostSignPayloadRequest; - }>, - LegacyAccount: HostSignPayloadWithLegacyAccountRequest, - }), -); - -/** - * Review shown before a sign-raw request is sent to the paired wallet. - * Hosts must display the payload according to `watermarked` and warn that - * unwatermarked signatures can authorize transactions. - */ -export const SignRawReview: S.Codec = S.lazy( - (): S.Codec => - S.TaggedUnion({ - Product: S.Struct({ - callingProductId: S.Option(S.str), - request: HostSignRawRequest, - watermarked: S.bool, - }) as S.Codec<{ - callingProductId?: string; - request: HostSignRawRequest; - watermarked: boolean; - }>, - LegacyAccount: S.Struct({ - request: HostSignRawWithLegacyAccountRequest, - watermarked: S.bool, - }) as S.Codec<{ - request: HostSignRawWithLegacyAccountRequest; - watermarked: boolean; - }>, - }), -); - -/** - * Review shown before signing an RFC-0023 VRF transcript. - */ -export const SignVrfReview: S.Codec = S.lazy( - (): S.Codec => - S.Struct({ - callingProductId: S.str, - request: HostAccountSignVrfRequest, - }) as S.Codec, -); - -/** - * Review shown before a product account signs a Statement Store proof - * payload. Distinct from raw-message signing: the payload is the exact - * unsigned statement, signed as-is (no `` envelope), so the host must - * not present it with the raw-signing convention. - */ -export const StatementStoreProductSignReview: S.Codec = - S.lazy( - (): S.Codec => - S.Struct({ - callingProductId: S.Option(S.str), - account: ProductAccountId, - payload: S.Bytes(), - }) as S.Codec, - ); - -/** - * Review shown before a user-confirmed core action continues. - */ -export const UserConfirmationReview: S.Codec = S.lazy( - (): S.Codec => - S.TaggedUnion({ - SignPayload: SignPayloadReview, - SignRaw: SignRawReview, - StatementStoreProductSign: StatementStoreProductSignReview, - CreateTransaction: CreateTransactionReview, - AccountAlias: AccountAliasReview, - CreateProof: CreateProofReview, - IdentityDisclosure: IdentityDisclosureReview, - ResourceAllocation: ResourceAllocationReview, - PreimageSubmit: PreimageSubmitReview, - AccountAccess: AccountAccessReview, - SignVrf: SignVrfReview, - ProductSubtree: ProductSubtreeReview, - }), -); - -/** - * Host auth UI driven by core-owned `AuthState` transitions. - */ -export interface AuthPresenter { - /** - * Observe an auth state change, in transition order. A pairing host - * always receives an opening state once the core has restored the - * persisted session, `Disconnected` included; later emissions happen - * only when the state changes. Default is a no-op for hosts that render - * no auth UI. - */ - authStateChanged?(state: AuthState): void; -} - -/** - * JSON-RPC provider factory for chain access. - * - * The platform provides a way to get a JSON-RPC connection for a given chain. - * The server runtime manages the chainHead v1 state machine on top of this. - * Host-spec N.6 requires products to access chains through host-mediated - * providers: - * - */ -export interface ChainProvider { - /** - * Open a JSON-RPC connection for the chain identified by `genesis_hash`. - * Drop the returned connection to disconnect. - */ - connect(genesisHash: Uint8Array): Promise; -} - -/** - * Host-implemented adapter through which product Chat calls reach host - * storage and UI. Optional: a host that omits it leaves Chat requests - * answered `Unsupported`. See `OptionalPlatform`. - * - * The core bounds and screens the product-supplied fields it forwards. Ids, - * names and icons on `create_chat_room`, `register_chat_bot` and - * `post_chat_message` are NFC-normalized and rejected for control and bidi - * characters. Message bodies are bounded and screened but pass through - * byte-for-byte, keeping line breaks and tabs, so a product reads back the - * bytes it sent. Counts and byte budgets are enforced, and any URL a host may - * fetch or open is restricted to `https` or an inline raster image and - * delivered as the parser resolved it. - * - * The core screens a URL's shape, not its reachability. `https://127.0.0.1`, - * `https://[::1]`, a private range and `https://169.254.169.254` (the cloud - * metadata endpoint) all pass: which networks a host is willing to fetch from - * depends on where that host runs, and a core that guessed would break a host - * serving its own media from localhost. A host that fetches these URLs owns - * that decision. Credentials are the exception and are refused, because - * `user:pass@` survives resolution into whatever the host fetches and logs. - * - * `ChatFile::size_bytes` is a product assertion and is not verified against - * the resource it names. Contextual output escaping, storage limits, and - * anything a host derives from product-supplied values remain host-owned. - */ -export interface ChatPlatform { - /** - * Create or resolve a product-scoped native chat room. - */ - createChatRoom( - product: ProductContext, - request: HostChatCreateRoomRequest, - ): Promise; - - /** - * Register or resolve a product-scoped native chat bot. Host-owned in the - * same way rooms are. - */ - registerChatBot( - product: ProductContext, - request: HostChatRegisterBotRequest, - ): Promise; - - /** - * Persist a product-authored message in a native chat room. A host that - * cannot store a given content variant reports a domain error for it. - */ - postChatMessage( - product: ProductContext, - request: HostChatPostMessageRequest, - ): Promise; - - /** - * Emit the current product-scoped room list and later replacements. - */ - subscribeChatRooms( - product: ProductContext, - ): AsyncIterable>; -} - -/** - * Host-owned contact picker, drawn from the chat lists the host's chat - * extensions hold. - * - * Optional, and listed on `OptionalPlatform` as `ChatPlatform` is. - * - * The host owns the UI and the list. It draws the names, so nothing it renders - * reaches the product, and the list never crosses to the core either: the core - * asks only about the handles a transaction names. A host omits contacts the - * user has blocked, from the picker and from lookups alike. - */ -export interface ContactsPlatform { - /** - * Resolve `lookup.handles` to the contacts they name. - * - * The one method a host has to write. Answer one entry per handle, in - * order, with ``undefined`` for a handle no current contact hashes to — a - * removed or blocked contact, or a handle a product made up. The core - * re-hashes every account returned and refuses one that does not match - * its handle, so a wrong answer is caught rather than trusted. - */ - contacts(lookup: HostContactLookup): Promise; - - /** - * Present the contact picker on behalf of `product` and return the user's - * choice. - * - * Defaults to `HostContactPick::Unsupported`, so a Rust host that - * implements `Self::contacts` alone still compiles and its products get - * a truthful answer rather than a dismissal they would retry forever. - * - * A JS host reaches the same answer by another route: the generated - * surface types this method optional, but a capability group counts as - * served only when every callback in it is present, so omitting this one - * makes the whole group absent and `contacts.pick` answers `Unsupported` - * before any of it is reached. - * - * The core cannot draw UI, so a selection has to come from the host; the - * whole point is that the host renders the names rather than shipping - * them to the product. `product` is passed so the host can say who is - * asking; it is not a filter. A host with no contacts answers - * `HostContactPick::NoContacts` instead of drawing an empty overlay. - */ - pickContact?(product: ProductContext): Promise; -} - -/** - * Core-owned administration API exposed to host UI. - * - * Hosts call this surface to drive global runtime actions or inspect/update - * core-owned state without going through a product-scoped TrUAPI request. - */ -export interface CoreAdmin { - /** - * Best-effort logout/disconnect. Clears the active session and emits the - * resulting auth state transition. - */ - disconnectSession(): Promise; - - /** - * Read a stored permission authorization status without prompting. - * - * A device capability also resolves the host application's OS gate, so an - * OS refusal reads as `Denied` whatever is stored. Remote, - * identity-disclosure and account-access decisions have no OS gate. - */ - getPermissionAuthorizationStatus( - request: PermissionAuthorizationRequest, - ): Promise; - - /** - * Read stored permission authorization statuses without prompting. - * - * A device capability also resolves the host application's OS gate, so an - * OS refusal reads as `Denied` whatever is stored. Remote, - * identity-disclosure and account-access decisions have no OS gate. - * - * Results are returned in the same order as `requests`. - */ - getPermissionAuthorizationStatuses( - requests: Array, - ): Promise>; - - /** - * Update a stored permission authorization status. `NotDetermined` clears - * the stored value so the next product request prompts again. - */ - setPermissionAuthorizationStatus( - request: PermissionAuthorizationRequest, - status: PermissionAuthorizationStatus, - ): Promise; - - /** - * Read the active session's X25519 chat identity private key, for hosts - * that run their own P2P chat channel for the paired identity. - * - * The wallet derives this key from the identity root and shares it during - * pairing; the core retains it verbatim, because a value derived - * host-side would address an identity no existing peer can reach. ``undefined`` - * when no session is active. - * - * Deliberately not on `SessionUiInfo`: that projection rides every - * `AuthState` broadcast to all registered `AuthPresenter`s, so a - * secret placed there would reach hosts that never asked for it. - */ - getSessionChatIdentityKey(): Promise; - - /** - * Read the active session's expanded sr25519 statement-store secret, for - * hosts that run their own statement-store traffic. 64 bytes. - * - * The key behind `SessionUiInfo::device_statement_account_id`: signing - * with anything else produces statements no allowance covers. ``undefined`` - * without an active pairing-host session; a signing host has no pairing - * proposal of its own. - * - * Deliberately not on `SessionUiInfo`, for the reason given on - * `Self::get_session_chat_identity_key`. - */ - getDeviceStatementKey(): Promise; - - /** - * Read this device's X25519 encryption secret, for hosts that run device - * sync against the peer's `SessionUiInfo::device_enc_public_key`. - * - * Generated and persisted on first read, so the returned key is stable for - * the install and matches the public key peers were told to address. - */ - getDeviceEncryptionKey(): Promise; - - /** - * Read `product_id`'s hard-subtree public key, so a host can name the - * account a review will sign with instead of showing a bare derivation - * path. - * - * Resolves from the memory cache, then the persisted slot, then the - * Account Holder. A pairing host reaching the wallet sends an SSO request, - * which answers without prompting the user, though it can wake the phone. - * A signing host derives locally and never waits. - * - * `timeout_ms` bounds that wait, and exceeding it is an error rather than - * ``undefined``. The underlying wait has no deadline of its own, so a host - * calling this while drawing a review should pass a timeout it is willing - * to block for. ``undefined`` uses a default sized for a product awaiting a - * signature, which is far too long to hold a render. - * - * ``undefined`` means no active session. Derive account public keys from the - * answer with `deriveProductAccountPublicKey`. - */ - getProductSubtreePublicKey( - productId: string, - timeoutMs: number | undefined, - ): Promise; -} - -/** - * Host-private persistence for core-owned state. - * - * Clearing product-indexed slots is the host's job. The core drops the ones - * it is holding when a session ends, but a product it never opened this run - * has no entry to drop, so those slots outlive the disconnect. A host that - * removes a product must clear them with the rest of that product's state, or - * they accumulate for the life of the install. - * - * `describe_core_storage_key` names the product owning a slot: - * `CoreStorageKeyDescription::product_id` is `Some` exactly for the - * product-indexed variants, which are `PermissionAuthorization`, - * `AutoSigningKey`, and `ProductSubtree`. Keying host storage by that value - * makes the sweep a prefix delete rather than a scan. - */ -export interface CoreStorage { - /** - * Read a core-owned value by typed slot. - */ - readCoreStorage(key: CoreStorageKey): Promise; - - /** - * Write a core-owned value by typed slot. - */ - writeCoreStorage(key: CoreStorageKey, value: Uint8Array): Promise; - - /** - * Clear a core-owned value by typed slot. - */ - clearCoreStorage(key: CoreStorageKey): Promise; -} - -/** - * Feature-support probing. The host answers whether it can service a given - * capability (currently scoped to per-chain support). - */ -export interface Features { - /** - * Report whether the requested feature is supported. - */ - featureSupported( - request: HostFeatureSupportedRequest, - ): Promise; - - /** - * Enumerate the chains this host serves (RFC 0026). The core resolves - * `get_chain_info` requests against the returned set. - */ - supportedChains(): Promise; -} - -/** - * A live JSON-RPC connection to a chain. - */ -export interface JsonRpcConnection { - /** - * Send a JSON-RPC request string. - */ - send(request: string): void; - - /** - * Stream of JSON-RPC response strings. - */ - responses(): AsyncIterable; - - /** - * Close the connection lease. - * - * Hosts may keep a shared underlying transport alive, but this handle - * must stop receiving responses and release any per-caller resources. - */ - close(): void; -} - -/** - * Host locale source. - */ -export interface LocaleHost { - /** - * Emits the currently selected locale immediately, then future changes. - */ - subscribeLocale(): AsyncIterable< - Result - >; -} - -/** - * Open URLs in the system browser. Input is already trimmed, categorized, - * and (where needed) normalized by the core; the host implementation only - * needs to hand the URL to the OS URL handler. - */ -export interface Navigation { - /** - * Open the given URL in the system browser. - */ - navigateTo(url: string): Promise; -} - -/** - * Deliver push notifications. - */ -export interface Notifications { - /** - * Schedule or immediately display the given notification and return the - * host-assigned id. - */ - pushNotification( - notification: HostPushNotificationRequest, - ): Promise; - - /** - * Cancel a notification by id. Idempotent: cancelling an already-fired or - * unknown id still returns `success`. - */ - cancelNotification?(id: number): Promise; -} - -/** - * Pairing-host-only administration API exposed to host UI. - */ -export interface PairingHostAdmin { - /** - * Cancel any in-flight pairing request. - */ - cancelPairing(): void; - - /** - * Notify the core that the persisted auth-session blob may have changed. - * - * The host owns persistence and change detection. The pairing core owns - * decoding that blob into live `SessionState` / `AuthState`. - */ - notifySessionStoreChanged(): void; -} - -/** - * Live OS permission state, read without prompting. - * - * A product-scoped grant is persisted once and never expires, but the OS - * grant behind it can be revoked in system settings, suspended by device - * policy, or reset by the platform — Android auto-resets runtime permissions - * for apps that go unused. Without this capability the core keeps answering - * from the stored grant alone and tells a product `granted` for a capability - * the OS has since taken away. - * - * This is deliberately separate from `Permissions::device_permission`: that - * call may show UI, so it cannot be used to re-check a decision the user has - * already made without prompting them again on every request. - */ -export interface PermissionStatusHost { - /** - * Current OS status of a device capability. Must not prompt. - */ - devicePermissionStatus( - request: HostDevicePermissionRequest, - ): Promise; -} - -/** - * Permission prompts. Device permissions (camera, mic, NFC, ...) are separate - * from remote permissions (domain access, chain submit, ...), so the platform - * surface mirrors that split. - */ -export interface Permissions { - /** - * Prompt the user for a device-level permission `product` requested. - */ - devicePermission( - product: ProductContext, - request: HostDevicePermissionRequest, - ): Promise; - - /** - * Prompt the user for a remote permission bundle `product` requested. - */ - remotePermission( - product: ProductContext, - request: RemotePermissionRequest, - ): Promise; -} - -/** - * Host-implemented adapter through which product Pocket calls reach the - * host's card collection. Optional: a host that omits it leaves Pocket - * requests answered `Unsupported`. See `OptionalPlatform`. - * - * The host owns the collection: it decides which cards are privileged and - * keeps each card's newest face. A face does not cross this boundary. - */ -export interface PocketPlatform { - /** - * Emit the calling product's current cards and every later replacement. - */ - subscribePocketCards( - product: ProductContext, - ): AsyncIterable>; - - /** - * Remove one of the calling product's cards. Removing an absent card - * succeeds; a privileged card is refused with `Privileged`. - */ - removePocketCard( - product: ProductContext, - request: HostPocketRemoveCardRequest, - ): Promise; -} - -/** - * Host preimage backend. The core builds, signs, and submits the Bulletin - * `TransactionStorage.store` transaction itself; the host only owns preimage - * content retrieval (P2P/IPFS lookup). - */ -export interface PreimageHost { - /** - * Emits current value/miss immediately, then future updates. - */ - lookupPreimage( - key: Uint8Array, - ): AsyncIterable>; -} - -/** - * Host store for a product's pending operations, which the host uses to keep - * the product's worker runtime alive. Reached only through the `Worker` - * protocol trait, so non-worker products never call these. - */ -export interface ProductOperations { - /** - * Record a pending operation. `label` is a host log and UI hint, empty - * when the product gave none. - * - * The returned id must be unique among this product's open operations. - * The core keys the worker reference an operation holds by that id, so an - * id already open for the product records nothing the second time, and - * ending it once drops the demand both were holding. Ids may repeat - * across products, and may be reused once an operation has ended. - */ - beginOperation( - product: ProductContext, - label: string, - ): Promise; - - /** - * Remove a pending operation. Idempotent: an unknown or already-ended id - * returns `Ok`, so a retry after an ambiguous failure is safe. - */ - endOperation(product: ProductContext, id: number): Promise; -} - -/** - * Product-scoped key-value storage. - * - * The core namespaces product keys before calling this trait, and the key - * names the product that owns the value. Host implementations may treat - * `key` as opaque in one shared store. A host that separates physical storage - * by product must decode the owner with `ProductStorageKey` on read, since - * that owner is another product on a granted foreign read. - * Storage errors are pinned to `v01` rather than taken from `truapi::latest`. - * The read error gained a cross-product refusal in v0.2 that the core decides - * before it ever calls a host, so a host has no way to produce it and should - * not have to match on it. - */ -export interface ProductStorage { - /** - * Read a value by key. - * - * `key` belongs to the calling product, or to another product the core - * has already found granting the caller read access in its manifest. The - * core refuses every other foreign read itself, so a host is never asked - * to enforce a grant and has no variant for one. Writes and clears always - * carry the calling product's own keys. - */ - read(key: string): Promise; - - /** - * Write a value to a key. - */ - write(key: string, value: Uint8Array): Promise; - - /** - * Clear a value at a key. - */ - clear(key: string): Promise; - - /** - * Emit `key`'s current value, then each later change from any of the - * product's runtimes. `key` is namespaced exactly as `Self::read` takes - * it. - * - * Reporting a write that left the bytes unchanged is allowed: the core - * drops an item repeating the value it last delivered, so the product - * sees only real changes whether or not a host filters them itself. - */ - subscribeStorage( - key: string, - ): AsyncIterable>; -} - -/** - * Host theme source. - */ -export interface ThemeHost { - /** - * Emits current theme immediately, then future changes. Hosts with no - * named themes report `ThemeName::Default`. - */ - subscribeTheme(): AsyncIterable>; -} - -/** - * Local user confirmation UI for sensitive core-owned operations. - */ -export interface UserConfirmation { - /** - * Preserve the lifetime of consent for identity and account disclosures. - */ - confirmPermission?( - review: UserConfirmationReview, - ): Promise; - - /** - * Confirm a reviewed action before the core continues. - * - * The core drops this future when the request behind the review is - * withdrawn, and an answer given afterwards reaches nobody. A host should - * dismiss its prompt when that happens. - */ - confirmUserAction(review: UserConfirmationReview): Promise; -} - -/** - * Combined platform interface. A host must provide every capability trait - * listed here. Members marked optional may be omitted; the core answers their - * product calls with `Unsupported`. See `OptionalPlatform`. - */ -export interface HostCallbacks { - navigation: Navigation; - notifications: Notifications; - permissions: Permissions; - features: Features; - productStorage: ProductStorage; - coreStorage: CoreStorage; - chain: ChainProvider; - auth: AuthPresenter; - userConfirmation: UserConfirmation; - theme: ThemeHost; - locale: LocaleHost; - preimage: PreimageHost; - productOperations: ProductOperations; - chat?: ChatPlatform; - contacts?: ContactsPlatform; - permissionStatus?: PermissionStatusHost; - pocket?: PocketPlatform; -} - -export interface RequiredHostCallbacks { - navigation: Required; - notifications: Required; - permissions: Required; - features: Required; - productStorage: Required; - coreStorage: Required; - chain: Required; - auth: Required; - userConfirmation: Required; - theme: Required; - locale: Required; - preimage: Required; - productOperations: Required; - chat?: Required; - contacts?: Required; - permissionStatus?: Required; - pocket?: Required; -} diff --git a/rust/crates/truapi-codegen/tests/golden/mod.rs b/rust/crates/truapi-codegen/tests/golden/mod.rs deleted file mode 100644 index 770a015d0..000000000 --- a/rust/crates/truapi-codegen/tests/golden/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Generated by truapi-codegen. Do not edit. - -pub mod dispatcher; -pub mod wire_table; diff --git a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs deleted file mode 100644 index 1639e4a48..000000000 --- a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs +++ /dev/null @@ -1,661 +0,0 @@ -//! Auto-generated by truapi-codegen. Do not edit. -//! -//! Mechanical wasm-bindgen callback bridge derived from the `platform` -//! modules. Raw callback names and payload shapes match the generated -//! TypeScript host-callback adapter. - -use futures::stream::BoxStream; -use js_sys::{Function, Uint8Array}; -use parity_scale_codec::Encode; -use truapi::v01; -use wasm_bindgen::JsValue; - -use super::{ - WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, - get_optional_function, invoke_bool, invoke_bytes_return, invoke_js_subscription, - invoke_optional_bytes_return, invoke_unit, missing_callback, parse_optional_bytes_item, -}; - -/// JS-side callbacks invoked by the wasm platform bridge. Methods with -/// Rust default bodies are still required here because the generated TS -/// adapter resolves optional host callbacks before constructing this -/// raw callback object. -/// -/// Callbacks of an optional capability trait are replaced by a throwing -/// stub when the host omits the group. The core never reaches them: it -/// only holds an adapter for a capability whose `has_*` accessor is -/// true, and answers the rest with `Unsupported`. -pub struct JsBridge { - pub auth_state_changed: Function, - pub chain_connect: Function, - pub create_chat_room: Function, - pub register_chat_bot: Function, - pub post_chat_message: Function, - pub subscribe_chat_rooms: Function, - pub contacts: Function, - pub pick_contact: Function, - pub read_core_storage: Function, - pub write_core_storage: Function, - pub clear_core_storage: Function, - pub feature_supported: Function, - pub supported_chains: Function, - pub subscribe_locale: Function, - pub navigate_to: Function, - pub push_notification: Function, - pub cancel_notification: Function, - pub device_permission_status: Function, - pub device_permission: Function, - pub remote_permission: Function, - pub subscribe_pocket_cards: Function, - pub remove_pocket_card: Function, - pub lookup_preimage: Function, - pub begin_operation: Function, - pub end_operation: Function, - pub read: Function, - pub write: Function, - pub clear: Function, - pub subscribe_storage: Function, - pub subscribe_theme: Function, - pub confirm_permission: Function, - pub confirm_user_action: Function, - pub chat_present: bool, - pub contacts_present: bool, - pub permission_status_present: bool, - pub pocket_present: bool, -} - -impl JsBridge { - pub fn from_js(callbacks: &JsValue) -> Result { - Ok(Self { - auth_state_changed: get_function(callbacks, "authStateChanged")?, - chain_connect: get_function(callbacks, "chainConnect")?, - create_chat_room: get_optional_function(callbacks, "createChatRoom")? - .unwrap_or_else(|| missing_callback("createChatRoom")), - register_chat_bot: get_optional_function(callbacks, "registerChatBot")? - .unwrap_or_else(|| missing_callback("registerChatBot")), - post_chat_message: get_optional_function(callbacks, "postChatMessage")? - .unwrap_or_else(|| missing_callback("postChatMessage")), - subscribe_chat_rooms: get_optional_function(callbacks, "subscribeChatRooms")? - .unwrap_or_else(|| missing_callback("subscribeChatRooms")), - contacts: get_optional_function(callbacks, "contacts")? - .unwrap_or_else(|| missing_callback("contacts")), - pick_contact: get_optional_function(callbacks, "pickContact")? - .unwrap_or_else(|| missing_callback("pickContact")), - read_core_storage: get_function(callbacks, "readCoreStorage")?, - write_core_storage: get_function(callbacks, "writeCoreStorage")?, - clear_core_storage: get_function(callbacks, "clearCoreStorage")?, - feature_supported: get_function(callbacks, "featureSupported")?, - supported_chains: get_function(callbacks, "supportedChains")?, - subscribe_locale: get_function(callbacks, "subscribeLocale")?, - navigate_to: get_function(callbacks, "navigateTo")?, - push_notification: get_function(callbacks, "pushNotification")?, - cancel_notification: get_function(callbacks, "cancelNotification")?, - device_permission_status: get_optional_function(callbacks, "devicePermissionStatus")? - .unwrap_or_else(|| missing_callback("devicePermissionStatus")), - device_permission: get_function(callbacks, "devicePermission")?, - remote_permission: get_function(callbacks, "remotePermission")?, - subscribe_pocket_cards: get_optional_function(callbacks, "subscribePocketCards")? - .unwrap_or_else(|| missing_callback("subscribePocketCards")), - remove_pocket_card: get_optional_function(callbacks, "removePocketCard")? - .unwrap_or_else(|| missing_callback("removePocketCard")), - lookup_preimage: get_function(callbacks, "lookupPreimage")?, - begin_operation: get_function(callbacks, "beginOperation")?, - end_operation: get_function(callbacks, "endOperation")?, - read: get_function(callbacks, "read")?, - write: get_function(callbacks, "write")?, - clear: get_function(callbacks, "clear")?, - subscribe_storage: get_function(callbacks, "subscribeStorage")?, - subscribe_theme: get_function(callbacks, "subscribeTheme")?, - confirm_permission: get_function(callbacks, "confirmPermission")?, - confirm_user_action: get_function(callbacks, "confirmUserAction")?, - chat_present: get_optional_function(callbacks, "createChatRoom")?.is_some() - && get_optional_function(callbacks, "registerChatBot")?.is_some() - && get_optional_function(callbacks, "postChatMessage")?.is_some() - && get_optional_function(callbacks, "subscribeChatRooms")?.is_some(), - contacts_present: get_optional_function(callbacks, "contacts")?.is_some() - && get_optional_function(callbacks, "pickContact")?.is_some(), - permission_status_present: get_optional_function(callbacks, "devicePermissionStatus")? - .is_some(), - pocket_present: get_optional_function(callbacks, "subscribePocketCards")?.is_some() - && get_optional_function(callbacks, "removePocketCard")?.is_some(), - }) - } - - /// Whether the host supplied every `chat` callback. - pub fn has_chat(&self) -> bool { - self.chat_present - } - - /// Whether the host supplied every `contacts` callback. - pub fn has_contacts(&self) -> bool { - self.contacts_present - } - - /// Whether the host supplied every `permission_status` callback. - pub fn has_permission_status(&self) -> bool { - self.permission_status_present - } - - /// Whether the host supplied every `pocket` callback. - pub fn has_pocket(&self) -> bool { - self.pocket_present - } -} - -impl crate::platform::AuthPresenter for WasmPlatform { - fn auth_state_changed(&self, state: crate::platform::AuthState) { - if let Err(reason) = call_js_function( - &self.bridge.auth_state_changed, - &[Uint8Array::from(state.encode().as_slice()).into()], - ) { - web_sys::console::error_1(&JsValue::from_str(&reason)); - } - } -} - -#[crate::platform::async_trait] -impl crate::platform::ChatPlatform for WasmPlatform { - async fn create_chat_room( - &self, - product: &crate::platform::ProductContext, - request: v01::HostChatCreateRoomRequest, - ) -> Result { - let bytes = invoke_bytes_return( - &self.bridge.create_chat_room, - vec![ - Uint8Array::from(product.encode().as_slice()).into(), - Uint8Array::from(request.encode().as_slice()).into(), - ], - ) - .await - .map_err(|reason| v01::HostChatCreateRoomError::Unknown { reason })?; - decode_bytes::( - bytes, - "createChatRoom response did not decode", - ) - .map_err(|reason| v01::HostChatCreateRoomError::Unknown { reason }) - } - - async fn register_chat_bot( - &self, - product: &crate::platform::ProductContext, - request: v01::HostChatRegisterBotRequest, - ) -> Result { - let bytes = invoke_bytes_return( - &self.bridge.register_chat_bot, - vec![ - Uint8Array::from(product.encode().as_slice()).into(), - Uint8Array::from(request.encode().as_slice()).into(), - ], - ) - .await - .map_err(|reason| v01::HostChatRegisterBotError::Unknown { reason })?; - decode_bytes::( - bytes, - "registerChatBot response did not decode", - ) - .map_err(|reason| v01::HostChatRegisterBotError::Unknown { reason }) - } - - async fn post_chat_message( - &self, - product: &crate::platform::ProductContext, - request: v01::HostChatPostMessageRequest, - ) -> Result { - let bytes = invoke_bytes_return( - &self.bridge.post_chat_message, - vec![ - Uint8Array::from(product.encode().as_slice()).into(), - Uint8Array::from(request.encode().as_slice()).into(), - ], - ) - .await - .map_err(|reason| v01::HostChatPostMessageError::Unknown { reason })?; - decode_bytes::( - bytes, - "postChatMessage response did not decode", - ) - .map_err(|reason| v01::HostChatPostMessageError::Unknown { reason }) - } - - fn subscribe_chat_rooms( - &self, - product: &crate::platform::ProductContext, - ) -> BoxStream<'static, Result> { - invoke_js_subscription( - &self.bridge.subscribe_chat_rooms, - Some(Uint8Array::from(product.encode().as_slice()).into()), - parse_host_chat_list_subscribe_item_item, - ) - } -} - -#[crate::platform::async_trait] -impl crate::platform::ContactsPlatform for WasmPlatform { - async fn contacts( - &self, - lookup: &crate::platform::HostContactLookup, - ) -> Result { - let bytes = invoke_bytes_return( - &self.bridge.contacts, - vec![Uint8Array::from(lookup.encode().as_slice()).into()], - ) - .await - .map_err(generic)?; - decode_bytes::( - bytes, - "contacts response did not decode", - ) - .map_err(generic) - } - - async fn pick_contact( - &self, - _product: &crate::platform::ProductContext, - ) -> Result { - let bytes = invoke_bytes_return( - &self.bridge.pick_contact, - vec![Uint8Array::from(_product.encode().as_slice()).into()], - ) - .await - .map_err(generic)?; - decode_bytes::( - bytes, - "pickContact response did not decode", - ) - .map_err(generic) - } -} - -#[crate::platform::async_trait] -impl crate::platform::CoreStorage for WasmPlatform { - async fn read_core_storage( - &self, - key: crate::platform::CoreStorageKey, - ) -> Result>, v01::GenericError> { - invoke_optional_bytes_return( - &self.bridge.read_core_storage, - vec![Uint8Array::from(key.encode().as_slice()).into()], - "readCoreStorage must resolve to Uint8Array, null or undefined", - ) - .await - .map_err(generic) - } - - async fn write_core_storage( - &self, - key: crate::platform::CoreStorageKey, - value: Vec, - ) -> Result<(), v01::GenericError> { - invoke_unit( - &self.bridge.write_core_storage, - vec![ - Uint8Array::from(key.encode().as_slice()).into(), - Uint8Array::from(value.as_slice()).into(), - ], - ) - .await - .map_err(generic) - } - - async fn clear_core_storage( - &self, - key: crate::platform::CoreStorageKey, - ) -> Result<(), v01::GenericError> { - invoke_unit( - &self.bridge.clear_core_storage, - vec![Uint8Array::from(key.encode().as_slice()).into()], - ) - .await - .map_err(generic) - } -} - -#[crate::platform::async_trait] -impl crate::platform::Features for WasmPlatform { - async fn feature_supported( - &self, - request: v01::HostFeatureSupportedRequest, - ) -> Result { - let bytes = invoke_bytes_return( - &self.bridge.feature_supported, - vec![Uint8Array::from(request.encode().as_slice()).into()], - ) - .await - .map_err(generic)?; - decode_bytes::( - bytes, - "featureSupported response did not decode", - ) - .map_err(generic) - } - - async fn supported_chains(&self) -> Result { - let bytes = invoke_bytes_return(&self.bridge.supported_chains, Vec::new()) - .await - .map_err(generic)?; - decode_bytes::( - bytes, - "supportedChains response did not decode", - ) - .map_err(generic) - } -} - -impl crate::platform::LocaleHost for WasmPlatform { - fn subscribe_locale( - &self, - ) -> BoxStream<'static, Result> { - invoke_js_subscription( - &self.bridge.subscribe_locale, - None, - parse_host_locale_subscribe_item_item, - ) - } -} - -#[crate::platform::async_trait] -impl crate::platform::Navigation for WasmPlatform { - async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { - invoke_unit(&self.bridge.navigate_to, vec![JsValue::from_str(&url)]) - .await - .map_err(|reason| v01::HostNavigateToError::Unknown { reason }) - } -} - -#[crate::platform::async_trait] -impl crate::platform::Notifications for WasmPlatform { - async fn push_notification( - &self, - notification: v01::HostPushNotificationRequest, - ) -> Result { - let bytes = invoke_bytes_return( - &self.bridge.push_notification, - vec![Uint8Array::from(notification.encode().as_slice()).into()], - ) - .await - .map_err(generic)?; - decode_bytes::( - bytes, - "pushNotification response did not decode", - ) - .map_err(generic) - } - - async fn cancel_notification(&self, id: u32) -> Result<(), v01::GenericError> { - invoke_unit( - &self.bridge.cancel_notification, - vec![JsValue::from_f64(f64::from(id))], - ) - .await - .map_err(generic) - } -} - -#[crate::platform::async_trait] -impl crate::platform::PermissionStatusHost for WasmPlatform { - async fn device_permission_status( - &self, - request: v01::HostDevicePermissionRequest, - ) -> Result { - let bytes = invoke_bytes_return( - &self.bridge.device_permission_status, - vec![Uint8Array::from(request.encode().as_slice()).into()], - ) - .await - .map_err(generic)?; - decode_bytes::( - bytes, - "devicePermissionStatus response did not decode", - ) - .map_err(generic) - } -} - -#[crate::platform::async_trait] -impl crate::platform::Permissions for WasmPlatform { - async fn device_permission( - &self, - product: &crate::platform::ProductContext, - request: v01::HostDevicePermissionRequest, - ) -> Result { - let bytes = invoke_bytes_return( - &self.bridge.device_permission, - vec![ - Uint8Array::from(product.encode().as_slice()).into(), - Uint8Array::from(request.encode().as_slice()).into(), - ], - ) - .await - .map_err(generic)?; - decode_bytes::( - bytes, - "devicePermission response did not decode", - ) - .map_err(generic) - } - - async fn remote_permission( - &self, - product: &crate::platform::ProductContext, - request: v01::RemotePermissionRequest, - ) -> Result { - let bytes = invoke_bytes_return( - &self.bridge.remote_permission, - vec![ - Uint8Array::from(product.encode().as_slice()).into(), - Uint8Array::from(request.encode().as_slice()).into(), - ], - ) - .await - .map_err(generic)?; - decode_bytes::( - bytes, - "remotePermission response did not decode", - ) - .map_err(generic) - } -} - -#[crate::platform::async_trait] -impl crate::platform::PocketPlatform for WasmPlatform { - fn subscribe_pocket_cards( - &self, - product: &crate::platform::ProductContext, - ) -> BoxStream<'static, Result> { - invoke_js_subscription( - &self.bridge.subscribe_pocket_cards, - Some(Uint8Array::from(product.encode().as_slice()).into()), - parse_host_pocket_list_subscribe_item_item, - ) - } - - async fn remove_pocket_card( - &self, - product: &crate::platform::ProductContext, - request: v01::HostPocketRemoveCardRequest, - ) -> Result<(), v01::HostPocketRemoveCardError> { - invoke_unit( - &self.bridge.remove_pocket_card, - vec![ - Uint8Array::from(product.encode().as_slice()).into(), - Uint8Array::from(request.encode().as_slice()).into(), - ], - ) - .await - .map_err(|reason| v01::HostPocketRemoveCardError::Unknown { reason }) - } -} - -impl crate::platform::PreimageHost for WasmPlatform { - fn lookup_preimage( - &self, - key: Vec, - ) -> BoxStream<'static, Result>, v01::GenericError>> { - invoke_js_subscription( - &self.bridge.lookup_preimage, - Some(Uint8Array::from(key.as_slice()).into()), - parse_optional_bytes_item, - ) - } -} - -#[crate::platform::async_trait] -impl crate::platform::ProductOperations for WasmPlatform { - async fn begin_operation( - &self, - product: &crate::platform::ProductContext, - label: String, - ) -> Result { - let bytes = invoke_bytes_return( - &self.bridge.begin_operation, - vec![ - Uint8Array::from(product.encode().as_slice()).into(), - JsValue::from_str(&label), - ], - ) - .await - .map_err(|reason| v01::HostWorkerOperationError::Unknown { reason })?; - decode_bytes::( - bytes, - "beginOperation response did not decode", - ) - .map_err(|reason| v01::HostWorkerOperationError::Unknown { reason }) - } - - async fn end_operation( - &self, - product: &crate::platform::ProductContext, - id: u32, - ) -> Result<(), v01::HostWorkerOperationError> { - invoke_unit( - &self.bridge.end_operation, - vec![ - Uint8Array::from(product.encode().as_slice()).into(), - JsValue::from_f64(f64::from(id)), - ], - ) - .await - .map_err(|reason| v01::HostWorkerOperationError::Unknown { reason }) - } -} - -#[crate::platform::async_trait] -impl crate::platform::ProductStorage for WasmPlatform { - async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { - invoke_optional_bytes_return( - &self.bridge.read, - vec![JsValue::from_str(&key)], - "read must resolve to Uint8Array, null or undefined", - ) - .await - .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) - } - - async fn write( - &self, - key: String, - value: Vec, - ) -> Result<(), v01::HostLocalStorageReadError> { - invoke_unit( - &self.bridge.write, - vec![ - JsValue::from_str(&key), - Uint8Array::from(value.as_slice()).into(), - ], - ) - .await - .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) - } - - async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { - invoke_unit(&self.bridge.clear, vec![JsValue::from_str(&key)]) - .await - .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) - } - - fn subscribe_storage( - &self, - key: String, - ) -> BoxStream<'static, Result> { - invoke_js_subscription( - &self.bridge.subscribe_storage, - Some(JsValue::from_str(&key)), - parse_host_local_storage_change_item_item, - ) - } -} - -impl crate::platform::ThemeHost for WasmPlatform { - fn subscribe_theme( - &self, - ) -> BoxStream<'static, Result> { - invoke_js_subscription( - &self.bridge.subscribe_theme, - None, - parse_host_theme_subscribe_item_item, - ) - } -} - -#[crate::platform::async_trait] -impl crate::platform::UserConfirmation for WasmPlatform { - async fn confirm_permission( - &self, - review: crate::platform::UserConfirmationReview, - ) -> Result { - let bytes = invoke_bytes_return( - &self.bridge.confirm_permission, - vec![Uint8Array::from(review.encode().as_slice()).into()], - ) - .await - .map_err(generic)?; - decode_bytes::( - bytes, - "confirmPermission response did not decode", - ) - .map_err(generic) - } - - async fn confirm_user_action( - &self, - review: crate::platform::UserConfirmationReview, - ) -> Result { - invoke_bool( - &self.bridge.confirm_user_action, - vec![Uint8Array::from(review.encode().as_slice()).into()], - ) - .await - .map_err(generic) - } -} - -fn parse_host_chat_list_subscribe_item_item( - value: JsValue, -) -> Result { - decode_js_item::(value, "HostChatListSubscribeItem") -} - -fn parse_host_local_storage_change_item_item( - value: JsValue, -) -> Result { - decode_js_item::(value, "HostLocalStorageChangeItem") -} - -fn parse_host_locale_subscribe_item_item( - value: JsValue, -) -> Result { - decode_js_item::(value, "HostLocaleSubscribeItem") -} - -fn parse_host_pocket_list_subscribe_item_item( - value: JsValue, -) -> Result { - decode_js_item::(value, "HostPocketListSubscribeItem") -} - -fn parse_host_theme_subscribe_item_item( - value: JsValue, -) -> Result { - decode_js_item::(value, "HostThemeSubscribeItem") -} diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs deleted file mode 100644 index ddb261216..000000000 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ /dev/null @@ -1,881 +0,0 @@ -//! Wire-protocol discriminant table. -//! -//! Auto-generated by truapi-codegen. Do not edit. -//! -//! Every frame carries a `(trait, method)` discriminant pair; one -//! method id addresses every frame a method ever sends or receives, -//! regardless of shape. Which leg a frame carries (request/response, -//! or a subscription's start/stop/interrupt/receive) is named by its -//! `message_type` byte, and the payload carries its own version. The -//! ids for each method are exposed as a named const -//! (`PREIMAGE_SUBMIT`, ...); [`WIRE_TABLE`] and the generated -//! dispatcher both reference those consts so the numbers live in -//! exactly one place. The table is sorted by (trait id, method id). - -/// Wire discriminants for one method. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MethodIds { - /// Trait discriminant carried by every frame of this method. - pub trait_id: u8, - /// Method discriminant carried by every frame of this method. - pub method_id: u8, -} - -/// A single wire-table row. -pub struct WireEntry { - /// Method name from the Rust trait. - pub method: &'static str, - /// What kind of slot this entry describes. - pub kind: WireKind, -} - -/// Wire-slot shape: request/response or a subscription's -/// start/stop/interrupt/receive quartet. -pub enum WireKind { - /// Request/response method. - Request(MethodIds), - /// Subscription method. - Subscription(MethodIds), -} -/// Fingerprint of this build's wire contract: frame ids, method legs, -/// sensitivity, and codec version, identical to the TS client's -/// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so -/// the debugger refuses to decode a frame whose contract differs from -/// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "ea1a1441ff0219b1"; - -/// Wire discriminants for `system_handshake`. -pub const SYSTEM_HANDSHAKE: MethodIds = MethodIds { - trait_id: 1, - method_id: 0, -}; - -/// Wire discriminants for `system_feature_supported`. -pub const SYSTEM_FEATURE_SUPPORTED: MethodIds = MethodIds { - trait_id: 1, - method_id: 1, -}; - -/// Wire discriminants for `system_navigate_to`. -pub const SYSTEM_NAVIGATE_TO: MethodIds = MethodIds { - trait_id: 1, - method_id: 2, -}; - -/// Wire discriminants for `system_host_info`. -pub const SYSTEM_HOST_INFO: MethodIds = MethodIds { - trait_id: 1, - method_id: 3, -}; - -/// Wire discriminants for `system_get_product_context`. -pub const SYSTEM_GET_PRODUCT_CONTEXT: MethodIds = MethodIds { - trait_id: 1, - method_id: 4, -}; - -/// Wire discriminants for `account_connection_status_subscribe`. -pub const ACCOUNT_CONNECTION_STATUS_SUBSCRIBE: MethodIds = MethodIds { - trait_id: 2, - method_id: 0, -}; - -/// Wire discriminants for `account_get_account`. -pub const ACCOUNT_GET_ACCOUNT: MethodIds = MethodIds { - trait_id: 2, - method_id: 1, -}; - -/// Wire discriminants for `account_get_account_alias`. -pub const ACCOUNT_GET_ACCOUNT_ALIAS: MethodIds = MethodIds { - trait_id: 2, - method_id: 2, -}; - -/// Wire discriminants for `account_create_account_proof`. -pub const ACCOUNT_CREATE_ACCOUNT_PROOF: MethodIds = MethodIds { - trait_id: 2, - method_id: 3, -}; - -/// Wire discriminants for `account_get_legacy_accounts`. -pub const ACCOUNT_GET_LEGACY_ACCOUNTS: MethodIds = MethodIds { - trait_id: 2, - method_id: 4, -}; - -/// Wire discriminants for `account_get_user_id`. -pub const ACCOUNT_GET_USER_ID: MethodIds = MethodIds { - trait_id: 2, - method_id: 5, -}; - -/// Wire discriminants for `account_request_login`. -pub const ACCOUNT_REQUEST_LOGIN: MethodIds = MethodIds { - trait_id: 2, - method_id: 6, -}; - -/// Wire discriminants for `account_sign_vrf`. -pub const ACCOUNT_SIGN_VRF: MethodIds = MethodIds { - trait_id: 2, - method_id: 7, -}; - -/// Wire discriminants for `account_register_ring_vrf_key`. -pub const ACCOUNT_REGISTER_RING_VRF_KEY: MethodIds = MethodIds { - trait_id: 2, - method_id: 8, -}; - -/// Wire discriminants for `account_list_ring_vrf_keys`. -pub const ACCOUNT_LIST_RING_VRF_KEYS: MethodIds = MethodIds { - trait_id: 2, - method_id: 9, -}; - -/// Wire discriminants for `account_ring_vrf_sign`. -pub const ACCOUNT_RING_VRF_SIGN: MethodIds = MethodIds { - trait_id: 2, - method_id: 10, -}; - -/// Wire discriminants for `chain_follow_head_subscribe`. -pub const CHAIN_FOLLOW_HEAD_SUBSCRIBE: MethodIds = MethodIds { - trait_id: 3, - method_id: 0, -}; - -/// Wire discriminants for `chain_get_head_header`. -pub const CHAIN_GET_HEAD_HEADER: MethodIds = MethodIds { - trait_id: 3, - method_id: 1, -}; - -/// Wire discriminants for `chain_get_head_body`. -pub const CHAIN_GET_HEAD_BODY: MethodIds = MethodIds { - trait_id: 3, - method_id: 2, -}; - -/// Wire discriminants for `chain_get_head_storage`. -pub const CHAIN_GET_HEAD_STORAGE: MethodIds = MethodIds { - trait_id: 3, - method_id: 3, -}; - -/// Wire discriminants for `chain_call_head`. -pub const CHAIN_CALL_HEAD: MethodIds = MethodIds { - trait_id: 3, - method_id: 4, -}; - -/// Wire discriminants for `chain_unpin_head`. -pub const CHAIN_UNPIN_HEAD: MethodIds = MethodIds { - trait_id: 3, - method_id: 5, -}; - -/// Wire discriminants for `chain_continue_head`. -pub const CHAIN_CONTINUE_HEAD: MethodIds = MethodIds { - trait_id: 3, - method_id: 6, -}; - -/// Wire discriminants for `chain_stop_head_operation`. -pub const CHAIN_STOP_HEAD_OPERATION: MethodIds = MethodIds { - trait_id: 3, - method_id: 7, -}; - -/// Wire discriminants for `chain_get_spec_genesis_hash`. -pub const CHAIN_GET_SPEC_GENESIS_HASH: MethodIds = MethodIds { - trait_id: 3, - method_id: 8, -}; - -/// Wire discriminants for `chain_get_spec_chain_name`. -pub const CHAIN_GET_SPEC_CHAIN_NAME: MethodIds = MethodIds { - trait_id: 3, - method_id: 9, -}; - -/// Wire discriminants for `chain_get_spec_properties`. -pub const CHAIN_GET_SPEC_PROPERTIES: MethodIds = MethodIds { - trait_id: 3, - method_id: 10, -}; - -/// Wire discriminants for `chain_broadcast_transaction`. -pub const CHAIN_BROADCAST_TRANSACTION: MethodIds = MethodIds { - trait_id: 3, - method_id: 11, -}; - -/// Wire discriminants for `chain_stop_transaction`. -pub const CHAIN_STOP_TRANSACTION: MethodIds = MethodIds { - trait_id: 3, - method_id: 12, -}; - -/// Wire discriminants for `chain_get_chain_info`. -pub const CHAIN_GET_CHAIN_INFO: MethodIds = MethodIds { - trait_id: 3, - method_id: 13, -}; - -/// Wire discriminants for `chat_create_room`. -pub const CHAT_CREATE_ROOM: MethodIds = MethodIds { - trait_id: 4, - method_id: 0, -}; - -/// Wire discriminants for `chat_register_bot`. -pub const CHAT_REGISTER_BOT: MethodIds = MethodIds { - trait_id: 4, - method_id: 1, -}; - -/// Wire discriminants for `chat_list_subscribe`. -pub const CHAT_LIST_SUBSCRIBE: MethodIds = MethodIds { - trait_id: 4, - method_id: 2, -}; - -/// Wire discriminants for `chat_post_message`. -pub const CHAT_POST_MESSAGE: MethodIds = MethodIds { - trait_id: 4, - method_id: 3, -}; - -/// Wire discriminants for `chat_action_subscribe`. -pub const CHAT_ACTION_SUBSCRIBE: MethodIds = MethodIds { - trait_id: 4, - method_id: 4, -}; - -/// Wire discriminants for `coin_payment_create_purse`. -pub const COIN_PAYMENT_CREATE_PURSE: MethodIds = MethodIds { - trait_id: 5, - method_id: 0, -}; - -/// Wire discriminants for `coin_payment_query_purse`. -pub const COIN_PAYMENT_QUERY_PURSE: MethodIds = MethodIds { - trait_id: 5, - method_id: 1, -}; - -/// Wire discriminants for `coin_payment_rebalance_purse`. -pub const COIN_PAYMENT_REBALANCE_PURSE: MethodIds = MethodIds { - trait_id: 5, - method_id: 2, -}; - -/// Wire discriminants for `coin_payment_delete_purse`. -pub const COIN_PAYMENT_DELETE_PURSE: MethodIds = MethodIds { - trait_id: 5, - method_id: 3, -}; - -/// Wire discriminants for `coin_payment_create_receivable`. -pub const COIN_PAYMENT_CREATE_RECEIVABLE: MethodIds = MethodIds { - trait_id: 5, - method_id: 4, -}; - -/// Wire discriminants for `coin_payment_create_cheque`. -pub const COIN_PAYMENT_CREATE_CHEQUE: MethodIds = MethodIds { - trait_id: 5, - method_id: 5, -}; - -/// Wire discriminants for `coin_payment_deposit`. -pub const COIN_PAYMENT_DEPOSIT: MethodIds = MethodIds { - trait_id: 5, - method_id: 6, -}; - -/// Wire discriminants for `coin_payment_refund`. -pub const COIN_PAYMENT_REFUND: MethodIds = MethodIds { - trait_id: 5, - method_id: 7, -}; - -/// Wire discriminants for `coin_payment_listen_for_payment`. -pub const COIN_PAYMENT_LISTEN_FOR_PAYMENT: MethodIds = MethodIds { - trait_id: 5, - method_id: 8, -}; - -/// Wire discriminants for `entropy_derive`. -pub const ENTROPY_DERIVE: MethodIds = MethodIds { - trait_id: 6, - method_id: 0, -}; - -/// Wire discriminants for `local_storage_read`. -pub const LOCAL_STORAGE_READ: MethodIds = MethodIds { - trait_id: 7, - method_id: 0, -}; - -/// Wire discriminants for `local_storage_write`. -pub const LOCAL_STORAGE_WRITE: MethodIds = MethodIds { - trait_id: 7, - method_id: 1, -}; - -/// Wire discriminants for `local_storage_clear`. -pub const LOCAL_STORAGE_CLEAR: MethodIds = MethodIds { - trait_id: 7, - method_id: 2, -}; - -/// Wire discriminants for `local_storage_subscribe`. -pub const LOCAL_STORAGE_SUBSCRIBE: MethodIds = MethodIds { - trait_id: 7, - method_id: 3, -}; - -/// Wire discriminants for `notifications_send_push_notification`. -pub const NOTIFICATIONS_SEND_PUSH_NOTIFICATION: MethodIds = MethodIds { - trait_id: 8, - method_id: 0, -}; - -/// Wire discriminants for `notifications_cancel_push_notification`. -pub const NOTIFICATIONS_CANCEL_PUSH_NOTIFICATION: MethodIds = MethodIds { - trait_id: 8, - method_id: 1, -}; - -/// Wire discriminants for `payment_balance_subscribe`. -pub const PAYMENT_BALANCE_SUBSCRIBE: MethodIds = MethodIds { - trait_id: 9, - method_id: 0, -}; - -/// Wire discriminants for `payment_top_up`. -pub const PAYMENT_TOP_UP: MethodIds = MethodIds { - trait_id: 9, - method_id: 1, -}; - -/// Wire discriminants for `payment_request`. -pub const PAYMENT_REQUEST: MethodIds = MethodIds { - trait_id: 9, - method_id: 2, -}; - -/// Wire discriminants for `payment_status_subscribe`. -pub const PAYMENT_STATUS_SUBSCRIBE: MethodIds = MethodIds { - trait_id: 9, - method_id: 3, -}; - -/// Wire discriminants for `permissions_request_device_permission`. -pub const PERMISSIONS_REQUEST_DEVICE_PERMISSION: MethodIds = MethodIds { - trait_id: 10, - method_id: 0, -}; - -/// Wire discriminants for `permissions_request_remote_permission`. -pub const PERMISSIONS_REQUEST_REMOTE_PERMISSION: MethodIds = MethodIds { - trait_id: 10, - method_id: 1, -}; - -/// Wire discriminants for `permissions_authorize_remote_permission`. -pub const PERMISSIONS_AUTHORIZE_REMOTE_PERMISSION: MethodIds = MethodIds { - trait_id: 10, - method_id: 2, -}; - -/// Wire discriminants for `permissions_authorize_device_permission`. -pub const PERMISSIONS_AUTHORIZE_DEVICE_PERMISSION: MethodIds = MethodIds { - trait_id: 10, - method_id: 3, -}; - -/// Wire discriminants for `preimage_lookup_subscribe`. -pub const PREIMAGE_LOOKUP_SUBSCRIBE: MethodIds = MethodIds { - trait_id: 11, - method_id: 0, -}; - -/// Wire discriminants for `preimage_submit`. -pub const PREIMAGE_SUBMIT: MethodIds = MethodIds { - trait_id: 11, - method_id: 1, -}; - -/// Wire discriminants for `resource_allocation_request`. -pub const RESOURCE_ALLOCATION_REQUEST: MethodIds = MethodIds { - trait_id: 12, - method_id: 0, -}; - -/// Wire discriminants for `signing_create_transaction`. -pub const SIGNING_CREATE_TRANSACTION: MethodIds = MethodIds { - trait_id: 13, - method_id: 0, -}; - -/// Wire discriminants for `signing_create_transaction_with_legacy_account`. -pub const SIGNING_CREATE_TRANSACTION_WITH_LEGACY_ACCOUNT: MethodIds = MethodIds { - trait_id: 13, - method_id: 1, -}; - -/// Wire discriminants for `signing_sign_raw_with_legacy_account`. -pub const SIGNING_SIGN_RAW_WITH_LEGACY_ACCOUNT: MethodIds = MethodIds { - trait_id: 13, - method_id: 2, -}; - -/// Wire discriminants for `signing_sign_payload_with_legacy_account`. -pub const SIGNING_SIGN_PAYLOAD_WITH_LEGACY_ACCOUNT: MethodIds = MethodIds { - trait_id: 13, - method_id: 3, -}; - -/// Wire discriminants for `signing_sign_raw`. -pub const SIGNING_SIGN_RAW: MethodIds = MethodIds { - trait_id: 13, - method_id: 4, -}; - -/// Wire discriminants for `signing_sign_payload`. -pub const SIGNING_SIGN_PAYLOAD: MethodIds = MethodIds { - trait_id: 13, - method_id: 5, -}; - -/// Wire discriminants for `signing_sign_raw_unwatermarked_deprecated`. -pub const SIGNING_SIGN_RAW_UNWATERMARKED_DEPRECATED: MethodIds = MethodIds { - trait_id: 13, - method_id: 6, -}; - -/// Wire discriminants for `signing_sign_raw_unwatermarked_deprecated_with_legacy_account`. -pub const SIGNING_SIGN_RAW_UNWATERMARKED_DEPRECATED_WITH_LEGACY_ACCOUNT: MethodIds = MethodIds { - trait_id: 13, - method_id: 7, -}; - -/// Wire discriminants for `statement_store_subscribe`. -pub const STATEMENT_STORE_SUBSCRIBE: MethodIds = MethodIds { - trait_id: 14, - method_id: 0, -}; - -/// Wire discriminants for `statement_store_create_proof`. -pub const STATEMENT_STORE_CREATE_PROOF: MethodIds = MethodIds { - trait_id: 14, - method_id: 1, -}; - -/// Wire discriminants for `statement_store_submit`. -pub const STATEMENT_STORE_SUBMIT: MethodIds = MethodIds { - trait_id: 14, - method_id: 2, -}; - -/// Wire discriminants for `statement_store_create_proof_authorized`. -pub const STATEMENT_STORE_CREATE_PROOF_AUTHORIZED: MethodIds = MethodIds { - trait_id: 14, - method_id: 3, -}; - -/// Wire discriminants for `theme_subscribe`. -pub const THEME_SUBSCRIBE: MethodIds = MethodIds { - trait_id: 15, - method_id: 0, -}; - -/// Wire discriminants for `locale_subscribe`. -pub const LOCALE_SUBSCRIBE: MethodIds = MethodIds { - trait_id: 16, - method_id: 0, -}; - -/// Wire discriminants for `renderer_render`. -pub const RENDERER_RENDER: MethodIds = MethodIds { - trait_id: 17, - method_id: 0, -}; - -/// Wire discriminants for `renderer_action_subscribe`. -pub const RENDERER_ACTION_SUBSCRIBE: MethodIds = MethodIds { - trait_id: 17, - method_id: 1, -}; - -/// Wire discriminants for `pocket_list_subscribe`. -pub const POCKET_LIST_SUBSCRIBE: MethodIds = MethodIds { - trait_id: 18, - method_id: 0, -}; - -/// Wire discriminants for `pocket_remove_card`. -pub const POCKET_REMOVE_CARD: MethodIds = MethodIds { - trait_id: 18, - method_id: 1, -}; - -/// Wire discriminants for `worker_begin_operation`. -pub const WORKER_BEGIN_OPERATION: MethodIds = MethodIds { - trait_id: 19, - method_id: 0, -}; - -/// Wire discriminants for `worker_end_operation`. -pub const WORKER_END_OPERATION: MethodIds = MethodIds { - trait_id: 19, - method_id: 1, -}; - -/// Wire discriminants for `contacts_pick`. -pub const CONTACTS_PICK: MethodIds = MethodIds { - trait_id: 20, - method_id: 0, -}; - -/// The full wire table. Trait ids and per-trait method ordering are -/// part of the wire protocol; only ever append within a trait. -/// Removed methods leave their slot empty. -pub const WIRE_TABLE: &[WireEntry] = &[ - WireEntry { - method: "system_handshake", - kind: WireKind::Request(SYSTEM_HANDSHAKE), - }, - WireEntry { - method: "system_feature_supported", - kind: WireKind::Request(SYSTEM_FEATURE_SUPPORTED), - }, - WireEntry { - method: "system_navigate_to", - kind: WireKind::Request(SYSTEM_NAVIGATE_TO), - }, - WireEntry { - method: "system_host_info", - kind: WireKind::Request(SYSTEM_HOST_INFO), - }, - WireEntry { - method: "system_get_product_context", - kind: WireKind::Request(SYSTEM_GET_PRODUCT_CONTEXT), - }, - WireEntry { - method: "account_connection_status_subscribe", - kind: WireKind::Subscription(ACCOUNT_CONNECTION_STATUS_SUBSCRIBE), - }, - WireEntry { - method: "account_get_account", - kind: WireKind::Request(ACCOUNT_GET_ACCOUNT), - }, - WireEntry { - method: "account_get_account_alias", - kind: WireKind::Request(ACCOUNT_GET_ACCOUNT_ALIAS), - }, - WireEntry { - method: "account_create_account_proof", - kind: WireKind::Request(ACCOUNT_CREATE_ACCOUNT_PROOF), - }, - WireEntry { - method: "account_get_legacy_accounts", - kind: WireKind::Request(ACCOUNT_GET_LEGACY_ACCOUNTS), - }, - WireEntry { - method: "account_get_user_id", - kind: WireKind::Request(ACCOUNT_GET_USER_ID), - }, - WireEntry { - method: "account_request_login", - kind: WireKind::Request(ACCOUNT_REQUEST_LOGIN), - }, - WireEntry { - method: "account_sign_vrf", - kind: WireKind::Request(ACCOUNT_SIGN_VRF), - }, - WireEntry { - method: "account_register_ring_vrf_key", - kind: WireKind::Request(ACCOUNT_REGISTER_RING_VRF_KEY), - }, - WireEntry { - method: "account_list_ring_vrf_keys", - kind: WireKind::Request(ACCOUNT_LIST_RING_VRF_KEYS), - }, - WireEntry { - method: "account_ring_vrf_sign", - kind: WireKind::Request(ACCOUNT_RING_VRF_SIGN), - }, - WireEntry { - method: "chain_follow_head_subscribe", - kind: WireKind::Subscription(CHAIN_FOLLOW_HEAD_SUBSCRIBE), - }, - WireEntry { - method: "chain_get_head_header", - kind: WireKind::Request(CHAIN_GET_HEAD_HEADER), - }, - WireEntry { - method: "chain_get_head_body", - kind: WireKind::Request(CHAIN_GET_HEAD_BODY), - }, - WireEntry { - method: "chain_get_head_storage", - kind: WireKind::Request(CHAIN_GET_HEAD_STORAGE), - }, - WireEntry { - method: "chain_call_head", - kind: WireKind::Request(CHAIN_CALL_HEAD), - }, - WireEntry { - method: "chain_unpin_head", - kind: WireKind::Request(CHAIN_UNPIN_HEAD), - }, - WireEntry { - method: "chain_continue_head", - kind: WireKind::Request(CHAIN_CONTINUE_HEAD), - }, - WireEntry { - method: "chain_stop_head_operation", - kind: WireKind::Request(CHAIN_STOP_HEAD_OPERATION), - }, - WireEntry { - method: "chain_get_spec_genesis_hash", - kind: WireKind::Request(CHAIN_GET_SPEC_GENESIS_HASH), - }, - WireEntry { - method: "chain_get_spec_chain_name", - kind: WireKind::Request(CHAIN_GET_SPEC_CHAIN_NAME), - }, - WireEntry { - method: "chain_get_spec_properties", - kind: WireKind::Request(CHAIN_GET_SPEC_PROPERTIES), - }, - WireEntry { - method: "chain_broadcast_transaction", - kind: WireKind::Request(CHAIN_BROADCAST_TRANSACTION), - }, - WireEntry { - method: "chain_stop_transaction", - kind: WireKind::Request(CHAIN_STOP_TRANSACTION), - }, - WireEntry { - method: "chain_get_chain_info", - kind: WireKind::Request(CHAIN_GET_CHAIN_INFO), - }, - WireEntry { - method: "chat_create_room", - kind: WireKind::Request(CHAT_CREATE_ROOM), - }, - WireEntry { - method: "chat_register_bot", - kind: WireKind::Request(CHAT_REGISTER_BOT), - }, - WireEntry { - method: "chat_list_subscribe", - kind: WireKind::Subscription(CHAT_LIST_SUBSCRIBE), - }, - WireEntry { - method: "chat_post_message", - kind: WireKind::Request(CHAT_POST_MESSAGE), - }, - WireEntry { - method: "chat_action_subscribe", - kind: WireKind::Subscription(CHAT_ACTION_SUBSCRIBE), - }, - WireEntry { - method: "coin_payment_create_purse", - kind: WireKind::Request(COIN_PAYMENT_CREATE_PURSE), - }, - WireEntry { - method: "coin_payment_query_purse", - kind: WireKind::Request(COIN_PAYMENT_QUERY_PURSE), - }, - WireEntry { - method: "coin_payment_rebalance_purse", - kind: WireKind::Subscription(COIN_PAYMENT_REBALANCE_PURSE), - }, - WireEntry { - method: "coin_payment_delete_purse", - kind: WireKind::Subscription(COIN_PAYMENT_DELETE_PURSE), - }, - WireEntry { - method: "coin_payment_create_receivable", - kind: WireKind::Request(COIN_PAYMENT_CREATE_RECEIVABLE), - }, - WireEntry { - method: "coin_payment_create_cheque", - kind: WireKind::Request(COIN_PAYMENT_CREATE_CHEQUE), - }, - WireEntry { - method: "coin_payment_deposit", - kind: WireKind::Subscription(COIN_PAYMENT_DEPOSIT), - }, - WireEntry { - method: "coin_payment_refund", - kind: WireKind::Subscription(COIN_PAYMENT_REFUND), - }, - WireEntry { - method: "coin_payment_listen_for_payment", - kind: WireKind::Subscription(COIN_PAYMENT_LISTEN_FOR_PAYMENT), - }, - WireEntry { - method: "entropy_derive", - kind: WireKind::Request(ENTROPY_DERIVE), - }, - WireEntry { - method: "local_storage_read", - kind: WireKind::Request(LOCAL_STORAGE_READ), - }, - WireEntry { - method: "local_storage_write", - kind: WireKind::Request(LOCAL_STORAGE_WRITE), - }, - WireEntry { - method: "local_storage_clear", - kind: WireKind::Request(LOCAL_STORAGE_CLEAR), - }, - WireEntry { - method: "local_storage_subscribe", - kind: WireKind::Subscription(LOCAL_STORAGE_SUBSCRIBE), - }, - WireEntry { - method: "notifications_send_push_notification", - kind: WireKind::Request(NOTIFICATIONS_SEND_PUSH_NOTIFICATION), - }, - WireEntry { - method: "notifications_cancel_push_notification", - kind: WireKind::Request(NOTIFICATIONS_CANCEL_PUSH_NOTIFICATION), - }, - WireEntry { - method: "payment_balance_subscribe", - kind: WireKind::Subscription(PAYMENT_BALANCE_SUBSCRIBE), - }, - WireEntry { - method: "payment_top_up", - kind: WireKind::Request(PAYMENT_TOP_UP), - }, - WireEntry { - method: "payment_request", - kind: WireKind::Request(PAYMENT_REQUEST), - }, - WireEntry { - method: "payment_status_subscribe", - kind: WireKind::Subscription(PAYMENT_STATUS_SUBSCRIBE), - }, - WireEntry { - method: "permissions_request_device_permission", - kind: WireKind::Request(PERMISSIONS_REQUEST_DEVICE_PERMISSION), - }, - WireEntry { - method: "permissions_request_remote_permission", - kind: WireKind::Request(PERMISSIONS_REQUEST_REMOTE_PERMISSION), - }, - WireEntry { - method: "permissions_authorize_remote_permission", - kind: WireKind::Request(PERMISSIONS_AUTHORIZE_REMOTE_PERMISSION), - }, - WireEntry { - method: "permissions_authorize_device_permission", - kind: WireKind::Request(PERMISSIONS_AUTHORIZE_DEVICE_PERMISSION), - }, - WireEntry { - method: "preimage_lookup_subscribe", - kind: WireKind::Subscription(PREIMAGE_LOOKUP_SUBSCRIBE), - }, - WireEntry { - method: "preimage_submit", - kind: WireKind::Request(PREIMAGE_SUBMIT), - }, - WireEntry { - method: "resource_allocation_request", - kind: WireKind::Request(RESOURCE_ALLOCATION_REQUEST), - }, - WireEntry { - method: "signing_create_transaction", - kind: WireKind::Request(SIGNING_CREATE_TRANSACTION), - }, - WireEntry { - method: "signing_create_transaction_with_legacy_account", - kind: WireKind::Request(SIGNING_CREATE_TRANSACTION_WITH_LEGACY_ACCOUNT), - }, - WireEntry { - method: "signing_sign_raw_with_legacy_account", - kind: WireKind::Request(SIGNING_SIGN_RAW_WITH_LEGACY_ACCOUNT), - }, - WireEntry { - method: "signing_sign_payload_with_legacy_account", - kind: WireKind::Request(SIGNING_SIGN_PAYLOAD_WITH_LEGACY_ACCOUNT), - }, - WireEntry { - method: "signing_sign_raw", - kind: WireKind::Request(SIGNING_SIGN_RAW), - }, - WireEntry { - method: "signing_sign_payload", - kind: WireKind::Request(SIGNING_SIGN_PAYLOAD), - }, - WireEntry { - method: "signing_sign_raw_unwatermarked_deprecated", - kind: WireKind::Request(SIGNING_SIGN_RAW_UNWATERMARKED_DEPRECATED), - }, - WireEntry { - method: "signing_sign_raw_unwatermarked_deprecated_with_legacy_account", - kind: WireKind::Request(SIGNING_SIGN_RAW_UNWATERMARKED_DEPRECATED_WITH_LEGACY_ACCOUNT), - }, - WireEntry { - method: "statement_store_subscribe", - kind: WireKind::Subscription(STATEMENT_STORE_SUBSCRIBE), - }, - WireEntry { - method: "statement_store_create_proof", - kind: WireKind::Request(STATEMENT_STORE_CREATE_PROOF), - }, - WireEntry { - method: "statement_store_submit", - kind: WireKind::Request(STATEMENT_STORE_SUBMIT), - }, - WireEntry { - method: "statement_store_create_proof_authorized", - kind: WireKind::Request(STATEMENT_STORE_CREATE_PROOF_AUTHORIZED), - }, - WireEntry { - method: "theme_subscribe", - kind: WireKind::Subscription(THEME_SUBSCRIBE), - }, - WireEntry { - method: "locale_subscribe", - kind: WireKind::Subscription(LOCALE_SUBSCRIBE), - }, - WireEntry { - method: "renderer_render", - kind: WireKind::Subscription(RENDERER_RENDER), - }, - WireEntry { - method: "renderer_action_subscribe", - kind: WireKind::Subscription(RENDERER_ACTION_SUBSCRIBE), - }, - WireEntry { - method: "pocket_list_subscribe", - kind: WireKind::Subscription(POCKET_LIST_SUBSCRIBE), - }, - WireEntry { - method: "pocket_remove_card", - kind: WireKind::Request(POCKET_REMOVE_CARD), - }, - WireEntry { - method: "worker_begin_operation", - kind: WireKind::Request(WORKER_BEGIN_OPERATION), - }, - WireEntry { - method: "worker_end_operation", - kind: WireKind::Request(WORKER_END_OPERATION), - }, - WireEntry { - method: "contacts_pick", - kind: WireKind::Request(CONTACTS_PICK), - }, -]; diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts deleted file mode 100644 index acba70ae3..000000000 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ /dev/null @@ -1,340 +0,0 @@ -// Auto-generated by truapi-codegen. Do not edit. -// -// Worker-side metadata and proxy functions for the raw WASM callback -// surface. The worker transport/lifecycle remains hand-written; this -// file owns the callback names, host-hook arity, and -// subscription payload shape derived from the `platform` modules. - -import type { RawCallbacks } from "./host-callbacks-adapter.js"; -import type { GenericError } from "@parity/truapi"; - -import type { ChainConnect } from "../runtime.js"; - -export const CALLBACK_NAMES = [ - "authStateChanged", - "createChatRoom", - "registerChatBot", - "postChatMessage", - "contacts", - "pickContact", - "readCoreStorage", - "writeCoreStorage", - "clearCoreStorage", - "featureSupported", - "supportedChains", - "navigateTo", - "pushNotification", - "cancelNotification", - "devicePermissionStatus", - "devicePermission", - "remotePermission", - "removePocketCard", - "beginOperation", - "endOperation", - "read", - "write", - "clear", - "confirmPermission", - "confirmUserAction", -] as const; -export type CallbackName = (typeof CALLBACK_NAMES)[number]; - -export const SUBSCRIPTION_NAMES = [ - "subscribeChatRooms", - "subscribeLocale", - "subscribePocketCards", - "lookupPreimage", - "subscribeStorage", - "subscribeTheme", -] as const; -export type SubscriptionName = (typeof SUBSCRIPTION_NAMES)[number]; - -export interface WorkerCallbackBridge { - callbackRequest( - name: CallbackName, - args: readonly unknown[], - ): Promise; - startSubscription( - name: SubscriptionName, - payload: Uint8Array | string | null, - sendItem: (value: T) => void, - sendError: (error: GenericError) => void, - ): () => void; - chainConnect: ChainConnect; -} - -function rawCallbacks( - bridge: WorkerCallbackBridge, -): Required< - Pick< - RawCallbacks, - | "authStateChanged" - | "readCoreStorage" - | "writeCoreStorage" - | "clearCoreStorage" - | "featureSupported" - | "supportedChains" - | "navigateTo" - | "pushNotification" - | "cancelNotification" - | "devicePermission" - | "remotePermission" - | "beginOperation" - | "endOperation" - | "read" - | "write" - | "clear" - | "confirmPermission" - | "confirmUserAction" - > -> { - return { - authStateChanged: (state) => - void bridge.callbackRequest("authStateChanged", [state]).catch(() => {}), - readCoreStorage: (key) => - bridge.callbackRequest("readCoreStorage", [key]) as ReturnType< - Required["readCoreStorage"] - >, - writeCoreStorage: (key, value) => - bridge.callbackRequest("writeCoreStorage", [key, value]) as ReturnType< - Required["writeCoreStorage"] - >, - clearCoreStorage: (key) => - bridge.callbackRequest("clearCoreStorage", [key]) as ReturnType< - Required["clearCoreStorage"] - >, - featureSupported: (request) => - bridge.callbackRequest("featureSupported", [request]) as ReturnType< - Required["featureSupported"] - >, - supportedChains: () => - bridge.callbackRequest("supportedChains", []) as ReturnType< - Required["supportedChains"] - >, - navigateTo: (url) => - bridge.callbackRequest("navigateTo", [url]) as ReturnType< - Required["navigateTo"] - >, - pushNotification: (notification) => - bridge.callbackRequest("pushNotification", [notification]) as ReturnType< - Required["pushNotification"] - >, - cancelNotification: (id) => - bridge.callbackRequest("cancelNotification", [id]) as ReturnType< - Required["cancelNotification"] - >, - devicePermission: (product, request) => - bridge.callbackRequest("devicePermission", [ - product, - request, - ]) as ReturnType["devicePermission"]>, - remotePermission: (product, request) => - bridge.callbackRequest("remotePermission", [ - product, - request, - ]) as ReturnType["remotePermission"]>, - beginOperation: (product, label) => - bridge.callbackRequest("beginOperation", [product, label]) as ReturnType< - Required["beginOperation"] - >, - endOperation: (product, id) => - bridge.callbackRequest("endOperation", [product, id]) as ReturnType< - Required["endOperation"] - >, - read: (key) => - bridge.callbackRequest("read", [key]) as ReturnType< - Required["read"] - >, - write: (key, value) => - bridge.callbackRequest("write", [key, value]) as ReturnType< - Required["write"] - >, - clear: (key) => - bridge.callbackRequest("clear", [key]) as ReturnType< - Required["clear"] - >, - confirmPermission: (review) => - bridge.callbackRequest("confirmPermission", [review]) as ReturnType< - Required["confirmPermission"] - >, - confirmUserAction: (review) => - bridge.callbackRequest("confirmUserAction", [review]) as ReturnType< - Required["confirmUserAction"] - >, - }; -} - -function subscriptionRawCallbacks( - bridge: WorkerCallbackBridge, -): Required< - Pick< - RawCallbacks, - "subscribeLocale" | "lookupPreimage" | "subscribeStorage" | "subscribeTheme" - > -> { - return { - subscribeLocale: (sendItem, sendError) => - bridge.startSubscription("subscribeLocale", null, sendItem, sendError), - lookupPreimage: (key, sendItem, sendError) => - bridge.startSubscription("lookupPreimage", key, sendItem, sendError), - subscribeStorage: (key, sendItem, sendError) => - bridge.startSubscription("subscribeStorage", key, sendItem, sendError), - subscribeTheme: (sendItem, sendError) => - bridge.startSubscription("subscribeTheme", null, sendItem, sendError), - }; -} - -function chatRawCallbacks( - bridge: WorkerCallbackBridge, -): Required< - Pick< - RawCallbacks, - | "createChatRoom" - | "registerChatBot" - | "postChatMessage" - | "subscribeChatRooms" - > -> { - return { - createChatRoom: (product, request) => - bridge.callbackRequest("createChatRoom", [ - product, - request, - ]) as ReturnType["createChatRoom"]>, - registerChatBot: (product, request) => - bridge.callbackRequest("registerChatBot", [ - product, - request, - ]) as ReturnType["registerChatBot"]>, - postChatMessage: (product, request) => - bridge.callbackRequest("postChatMessage", [ - product, - request, - ]) as ReturnType["postChatMessage"]>, - subscribeChatRooms: (product, sendItem, sendError) => - bridge.startSubscription( - "subscribeChatRooms", - product, - sendItem, - sendError, - ), - }; -} - -function contactsRawCallbacks( - bridge: WorkerCallbackBridge, -): Required> { - return { - contacts: (lookup) => - bridge.callbackRequest("contacts", [lookup]) as ReturnType< - Required["contacts"] - >, - pickContact: (product) => - bridge.callbackRequest("pickContact", [product]) as ReturnType< - Required["pickContact"] - >, - }; -} - -function permissionStatusRawCallbacks( - bridge: WorkerCallbackBridge, -): Required> { - return { - devicePermissionStatus: (request) => - bridge.callbackRequest("devicePermissionStatus", [request]) as ReturnType< - Required["devicePermissionStatus"] - >, - }; -} - -function pocketRawCallbacks( - bridge: WorkerCallbackBridge, -): Required> { - return { - subscribePocketCards: (product, sendItem, sendError) => - bridge.startSubscription( - "subscribePocketCards", - product, - sendItem, - sendError, - ), - removePocketCard: (product, request) => - bridge.callbackRequest("removePocketCard", [ - product, - request, - ]) as ReturnType["removePocketCard"]>, - }; -} - -/** - * Optional capabilities the main-thread host actually serves. A - * capability left out here is not proxied into the worker, so the - * core answers its product calls with `Unsupported`. - */ -export interface OptionalCapabilities { - /** Whether the host serves this capability. */ - chat?: boolean; - /** Whether the host serves this capability. */ - contacts?: boolean; - /** Whether the host serves this capability. */ - permissionStatus?: boolean; - /** Whether the host serves this capability. */ - pocket?: boolean; -} - -export function createWorkerRawCallbacks( - bridge: WorkerCallbackBridge, - capabilities: OptionalCapabilities = {}, -): Record { - const callbacks: Record = { - ...rawCallbacks(bridge), - ...subscriptionRawCallbacks(bridge), - chainConnect: bridge.chainConnect, - }; - if (capabilities.chat) Object.assign(callbacks, chatRawCallbacks(bridge)); - if (capabilities.contacts) - Object.assign(callbacks, contactsRawCallbacks(bridge)); - if (capabilities.permissionStatus) - Object.assign(callbacks, permissionStatusRawCallbacks(bridge)); - if (capabilities.pocket) Object.assign(callbacks, pocketRawCallbacks(bridge)); - return callbacks; -} - -export function startRawSubscription( - callbacks: RawCallbacks, - name: SubscriptionName, - payload: Uint8Array | string | null, - sendItem: (value?: unknown) => void, - sendError: (error: GenericError) => void, -): (() => void) | void { - switch (name) { - case "subscribeChatRooms": - if (!(payload instanceof Uint8Array)) { - console.warn(`[truapi worker] ${name} requires payload`); - return undefined; - } - return callbacks.subscribeChatRooms?.(payload, sendItem, sendError); - case "subscribeLocale": - return callbacks.subscribeLocale(sendItem, sendError); - case "subscribePocketCards": - if (!(payload instanceof Uint8Array)) { - console.warn(`[truapi worker] ${name} requires payload`); - return undefined; - } - return callbacks.subscribePocketCards?.(payload, sendItem, sendError); - case "lookupPreimage": - if (!(payload instanceof Uint8Array)) { - console.warn(`[truapi worker] ${name} requires payload`); - return undefined; - } - return callbacks.lookupPreimage(payload, sendItem, sendError); - case "subscribeStorage": - if (typeof payload !== "string") { - console.warn(`[truapi worker] ${name} requires payload`); - return undefined; - } - return callbacks.subscribeStorage(payload, sendItem, sendError); - case "subscribeTheme": - return callbacks.subscribeTheme(sendItem, sendError); - } -} diff --git a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs deleted file mode 100644 index d98623f39..000000000 --- a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs +++ /dev/null @@ -1,479 +0,0 @@ -//! Golden snapshot test for the Rust dispatcher emitter. -//! -//! `cargo rustdoc` runs once per package, under the nightly named in the -//! repository's `nightly-toolchain` file, into a dedicated -//! `target/codegen-test-rustdoc/` directory, off the shared -//! `target/doc/.json` path that a concurrent `cargo doc` would -//! claim. Every test reads the same JSON, so the build is paid once per -//! package per run. That toolchain is required; if it is not installed the -//! test panics rather than silently passing (`rustup toolchain install -//! "$(cat nightly-toolchain)"`). `TRUAPI_NIGHTLY_TOOLCHAIN` overrides it. - -use std::collections::{BTreeMap, HashMap}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::{Mutex, OnceLock}; - -/// The dated nightly CI runs, unless `TRUAPI_NIGHTLY_TOOLCHAIN` names another. -fn nightly_toolchain() -> String { - std::env::var("TRUAPI_NIGHTLY_TOOLCHAIN").unwrap_or_else(|_| { - include_str!("../../../../nightly-toolchain") - .trim() - .to_string() - }) -} - -fn quoted_strings_in_const_array(src: &str, const_name: &str) -> Vec { - let marker = format!("export const {const_name} = ["); - let start = src - .find(&marker) - .unwrap_or_else(|| panic!("missing {const_name}")); - let rest = &src[start + marker.len()..]; - let end = rest - .find("] as const") - .unwrap_or_else(|| panic!("unterminated {const_name}")); - let body = &rest[..end]; - let mut strings = Vec::new(); - let mut chars = body.chars(); - while let Some(ch) = chars.next() { - if ch != '"' { - continue; - } - let mut value = String::new(); - let mut escaped = false; - for ch in chars.by_ref() { - if escaped { - value.push(ch); - escaped = false; - } else if ch == '\\' { - escaped = true; - } else if ch == '"' { - break; - } else { - value.push(ch); - } - } - strings.push(value); - } - strings -} - -/// Path to the rustdoc JSON of `truapi`'s protocol definitions alone, the -/// input codegen reads the API from, building it on first use. -fn produce_rustdoc_json(workspace_root: &Path) -> PathBuf { - produce_rustdoc_json_for_package(workspace_root, "truapi", &["--no-default-features"]) -} - -/// Path to `package`'s rustdoc JSON built with `cargo_args`, building it on -/// first use and reusing that build for every later caller in this test -/// binary. Panics with a clear message if nightly is unavailable so CI cannot -/// pass vacuously. -fn produce_rustdoc_json_for_package( - workspace_root: &Path, - package: &str, - cargo_args: &[&str], -) -> PathBuf { - static BUILT: OnceLock>> = OnceLock::new(); - let built = BUILT.get_or_init(|| Mutex::new(HashMap::new())); - // Held across the build so two tests asking for the same package queue - // instead of racing into one target directory. - let mut built = built - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let key = [package] - .into_iter() - .chain(cargo_args.iter().copied()) - .collect::>() - .join(" "); - if let Some(json) = built.get(&key) { - return json.clone(); - } - - let target_dir = workspace_root - .join("target/codegen-test-rustdoc") - .join(key.replace(' ', "_")); - let json = run_rustdoc_json(workspace_root, &target_dir, package, cargo_args); - built.insert(key, json.clone()); - json -} - -/// One `cargo rustdoc --output-format json` invocation on the pinned nightly, returning -/// the path to the JSON it wrote. -fn run_rustdoc_json( - workspace_root: &Path, - target_dir: &Path, - package: &str, - cargo_args: &[&str], -) -> PathBuf { - let toolchain = nightly_toolchain(); - let mut command = Command::new("cargo"); - command - .arg(format!("+{toolchain}")) - .args(["rustdoc", "-p", package]) - .args(cargo_args) - .arg("--target-dir") - .arg(target_dir) - .args(["--", "-Z", "unstable-options", "--output-format", "json"]) - .current_dir(workspace_root); - let output = command.output().expect( - "failed to spawn rustdoc; install the pinned nightly named in nightly-toolchain via rustup", - ); - assert!( - output.status.success(), - "`cargo +{toolchain} rustdoc -p {package}` failed (status {}); that nightly toolchain is required.\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - let json_name = package.replace('-', "_"); - let json = target_dir.join(format!("doc/{json_name}.json")); - assert!( - json.exists(), - "rustdoc JSON not found at {} after successful rustdoc invocation", - json.display(), - ); - json -} - -fn workspace_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(3) - .expect("workspace root above rust/crates/truapi-codegen") - .to_path_buf() -} - -fn workspace_tempdir(workspace: &Path) -> tempfile::TempDir { - let parent = workspace.join("target/codegen-test-tmp"); - fs::create_dir_all(&parent).expect("create workspace codegen temp directory"); - tempfile::Builder::new() - .prefix("golden-") - .tempdir_in(parent) - .expect("workspace tempdir") -} - -fn rustfmt_generated(files: &[PathBuf]) { - if files.is_empty() { - return; - } - - // Keep this hermetic: rustfmt otherwise walks up from the generated file - // in the system temp directory and may inherit another checkout's config. - let config_dir = tempfile::tempdir().expect("rustfmt config tempdir"); - let config_path = config_dir.path().join("rustfmt.toml"); - fs::write(&config_path, "edition = \"2024\"\n").expect("write rustfmt config"); - - let toolchain = nightly_toolchain(); - let mut command = Command::new("rustfmt"); - command - .arg(format!("+{toolchain}")) - .args(["--edition", "2024", "--config-path"]) - .arg(config_path); - for file in files { - command.arg(file); - } - let output = command - .output() - .expect("failed to spawn rustfmt; install rustfmt for the pinned nightly via rustup"); - assert!( - output.status.success(), - "rustfmt failed (status {}).\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); -} - -fn prettier_generated(workspace_root: &Path, files: &[PathBuf]) { - if files.is_empty() { - return; - } - - let mut command = Command::new("npm"); - command - .args([ - "exec", - "--yes", - "--package=prettier@3.8.3", - "--", - "prettier", - "--write", - "--ignore-path", - "/dev/null", - "--config", - ]) - .arg(workspace_root.join(".prettierrc")); - for file in files { - command.arg(file); - } - let output = command - .current_dir(workspace_root) - .output() - .expect("failed to spawn `npm exec -- prettier`; run `npm ci` first"); - assert!( - output.status.success(), - "prettier failed (status {}).\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); -} - -#[test] -fn golden_dispatcher_and_wire_table() { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace = workspace_root(); - - let tempdir = workspace_tempdir(&workspace); - let rustdoc_json = produce_rustdoc_json(&workspace); - - let out = Command::new(env!("CARGO_BIN_EXE_truapi-codegen")) - .args([ - "--input", - rustdoc_json.to_str().unwrap(), - "--output", - tempdir.path().join("ts").to_str().unwrap(), - "--rust-output", - tempdir.path().join("rust").to_str().unwrap(), - ]) - .output() - .expect("run truapi-codegen"); - assert!( - out.status.success(), - "codegen failed: stdout=\n{}\nstderr=\n{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr), - ); - - // Compare the emitted files against the goldens. We assert on - // wire_table.rs first because it's small and the diff is easy to - // read when the wire ids drift. mod.rs is covered because the - // runtime declares `pub mod generated;` unconditionally, so dropping - // it stops the crate parsing at all. - let golden_dir = manifest_dir.join("tests/golden"); - let cases = [ - ("wire_table.rs", "wire_table.rs"), - ("dispatcher.rs", "dispatcher.rs"), - ("mod.rs", "mod.rs"), - ]; - for (golden_name, output_name) in cases { - let golden = fs::read_to_string(golden_dir.join(golden_name)) - .unwrap_or_else(|e| panic!("read {golden_name}: {e}")); - let actual = fs::read_to_string(tempdir.path().join("rust").join(output_name)) - .unwrap_or_else(|e| panic!("read generated {output_name}: {e}")); - if golden != actual { - // Dump actual to a sibling file for easy inspection - // when running locally. - let dump = manifest_dir.join(format!("tests/golden/{output_name}.actual")); - let _ = fs::write(&dump, &actual); - panic!( - "golden mismatch for {output_name}; wrote actual to {}", - dump.display() - ); - } - } - - // A method body should only ever reference its pre-built `T.{Method}Version` - // codec by name (see `method_envelope_name` in `ts.rs`); `types.ts` - // legitimately inlines `S.indexedTaggedUnion(` to define those wrapper - // codecs themselves, which is why only `client.ts` is scanned here. - let client_ts = fs::read_to_string(tempdir.path().join("ts").join("client.ts")) - .unwrap_or_else(|e| panic!("read generated client.ts: {e}")); - assert!( - !client_ts.contains("indexedTaggedUnion"), - "generated client.ts contains `S.indexedTaggedUnion(`; a method body should \ - reference its `T.{{Method}}Version` codec by name instead of inlining one" - ); -} - -/// Idempotence guard at the integration level: running the binary twice -/// against the same input must produce identical output. This catches -/// non-determinism (HashMap iteration order, timestamps, etc.) that the -/// inline unit tests might miss because they exercise smaller APIs. -#[test] -fn binary_emission_is_idempotent() { - let workspace = workspace_root(); - let rustdoc_json = produce_rustdoc_json(&workspace); - - // Every emitted file, not a hand-picked list: the nondeterminism this - // guards against has landed in the TypeScript output as readily as in the - // Rust output, and a list only covers what someone remembered to add. - let run_once = || -> BTreeMap { - let tmp = workspace_tempdir(&workspace); - let status = Command::new(env!("CARGO_BIN_EXE_truapi-codegen")) - .args([ - "--input", - rustdoc_json.to_str().unwrap(), - "--output", - tmp.path().join("ts").to_str().unwrap(), - "--rust-output", - tmp.path().join("rust").to_str().unwrap(), - ]) - .status() - .expect("run truapi-codegen"); - assert!(status.success(), "codegen run failed"); - read_tree(tmp.path()) - }; - - let first = run_once(); - let second = run_once(); - assert!(!first.is_empty(), "codegen emitted nothing"); - assert_eq!( - first.keys().collect::>(), - second.keys().collect::>(), - "the two runs emitted different files" - ); - for (path, contents) in &first { - assert_eq!( - contents, - &second[path], - "{} differs between runs", - path.display() - ); - } -} - -/// Every file under `root`, keyed by its path relative to `root`. -fn read_tree(root: &Path) -> BTreeMap { - let mut files = BTreeMap::new(); - let mut pending = vec![root.to_path_buf()]; - while let Some(dir) = pending.pop() { - for entry in fs::read_dir(&dir).expect("read generated directory") { - let path = entry.expect("read generated entry").path(); - if path.is_dir() { - pending.push(path); - } else { - let relative = path - .strip_prefix(root) - .expect("path under root") - .to_path_buf(); - files.insert( - relative, - fs::read_to_string(&path).expect("read generated file"), - ); - } - } - } - files -} - -#[test] -fn golden_host_callbacks_ts() { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace = workspace_root(); - - let tempdir = workspace_tempdir(&workspace); - let truapi_json = produce_rustdoc_json(&workspace); - let runtime_json = produce_rustdoc_json_for_package(&workspace, "truapi", &[]); - let provider_json = produce_rustdoc_json_for_package(&workspace, "truapi-provider", &[]); - - let out = Command::new(env!("CARGO_BIN_EXE_truapi-codegen")) - .args([ - "--input", - truapi_json.to_str().unwrap(), - "--output", - tempdir.path().join("ts").to_str().unwrap(), - "--platform-input", - runtime_json.to_str().unwrap(), - "--platform-input", - provider_json.to_str().unwrap(), - "--platform-ts-output", - tempdir.path().join("host").to_str().unwrap(), - "--platform-wasm-adapter-output", - tempdir.path().join("wasm").to_str().unwrap(), - "--platform-rust-output", - tempdir.path().join("rust-wasm").to_str().unwrap(), - ]) - .output() - .expect("run truapi-codegen"); - assert!( - out.status.success(), - "codegen failed: stdout=\n{}\nstderr=\n{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr), - ); - prettier_generated( - &workspace, - &[ - tempdir.path().join("host/host-callbacks.ts"), - tempdir.path().join("wasm/host-callbacks-adapter.ts"), - tempdir.path().join("wasm/worker-callbacks.ts"), - ], - ); - rustfmt_generated(&[tempdir.path().join("rust-wasm/generated_bridge.rs")]); - - let golden_path = manifest_dir.join("tests/golden/host-callbacks.ts"); - let golden = - fs::read_to_string(&golden_path).unwrap_or_else(|e| panic!("read host-callbacks.ts: {e}")); - let actual = fs::read_to_string(tempdir.path().join("host/host-callbacks.ts")) - .expect("read generated host-callbacks.ts"); - if golden != actual { - let dump = manifest_dir.join("tests/golden/host-callbacks.ts.actual"); - let _ = fs::write(&dump, &actual); - panic!( - "golden mismatch for host-callbacks.ts; wrote actual to {}", - dump.display() - ); - } - - let adapter_golden_path = manifest_dir.join("tests/golden/host-callbacks-adapter.ts"); - let adapter_actual = fs::read_to_string(tempdir.path().join("wasm/host-callbacks-adapter.ts")) - .expect("read generated host-callbacks-adapter.ts"); - let adapter_golden = fs::read_to_string(&adapter_golden_path).unwrap_or_default(); - if adapter_golden != adapter_actual { - let dump = manifest_dir.join("tests/golden/host-callbacks-adapter.ts.actual"); - let _ = fs::write(&dump, &adapter_actual); - panic!( - "golden mismatch for host-callbacks-adapter.ts; wrote actual to {}", - dump.display() - ); - } - - let worker_golden_path = manifest_dir.join("tests/golden/worker-callbacks.ts"); - let worker_actual = fs::read_to_string(tempdir.path().join("wasm/worker-callbacks.ts")) - .expect("read generated worker-callbacks.ts"); - let worker_golden = fs::read_to_string(&worker_golden_path).unwrap_or_default(); - if worker_golden != worker_actual { - let dump = manifest_dir.join("tests/golden/worker-callbacks.ts.actual"); - let _ = fs::write(&dump, &worker_actual); - panic!( - "golden mismatch for worker-callbacks.ts; wrote actual to {}", - dump.display() - ); - } - - let wasm_bridge_golden_path = manifest_dir.join("tests/golden/wasm_bridge.rs"); - let wasm_bridge_actual = - fs::read_to_string(tempdir.path().join("rust-wasm/generated_bridge.rs")) - .expect("read generated wasm bridge"); - let wasm_bridge_golden = fs::read_to_string(&wasm_bridge_golden_path).unwrap_or_default(); - if wasm_bridge_golden != wasm_bridge_actual { - let dump = manifest_dir.join("tests/golden/wasm_bridge.rs.actual"); - let _ = fs::write(&dump, &wasm_bridge_actual); - panic!( - "golden mismatch for wasm_bridge.rs; wrote actual to {}", - dump.display() - ); - } - - assert!( - !worker_actual.contains("OPTIONAL_CALLBACK_NAMES"), - "worker callback generation should not expose an optional callback manifest" - ); - let mut generated_names = quoted_strings_in_const_array(&worker_actual, "CALLBACK_NAMES"); - generated_names.extend(quoted_strings_in_const_array( - &worker_actual, - "SUBSCRIPTION_NAMES", - )); - for name in generated_names { - // Callbacks of an optional capability bind through the optional getter; - // either way the bridge must name every callback the worker proxies. - assert!( - wasm_bridge_actual.contains(&format!("get_function(callbacks, \"{name}\")?")) - || wasm_bridge_actual - .contains(&format!("get_optional_function(callbacks, \"{name}\")?")), - "generated wasm bridge must bind worker callback `{name}`" - ); - } -} diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml index 607bbc251..ee44dcef4 100644 --- a/rust/crates/truapi-host-cli/Cargo.toml +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -18,6 +18,7 @@ unsafe_code = "forbid" [dependencies] truapi = { path = "../truapi" } +truapi-provider = { workspace = true, features = ["networks"] } anyhow = { workspace = true } arboard = { workspace = true, features = ["image-data"] } async-trait = { workspace = true } diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index 9827e22e7..c451f1a68 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -15,6 +15,8 @@ and calls it like any product would. With `--script`, the CLI runs the script and exits with its status. Without `--script`, both roles open a full-screen terminal UI when stdin and stdout are TTYs. +The headless host reports its English interface language but has no native locale/time-zone formatting engine. Its locale subscription therefore reports no time zone, and `locale.localizeTimestamps` returns a domain error explaining that local time conversion is unavailable. Products must not interpret that absence as UTC or use a fixed offset as local time. + One binary, `truapi-host`: | Command | Role | @@ -1049,6 +1051,11 @@ one-shot modes. Every role the preset serves is routed unconditionally; `E2E_LIVE_CHAIN=1` only widens routing to endpoints it carries without serving. A rustls crypto provider is installed at startup for the TLS connections. +- **`TRUAPI_LIGHT_CLIENT=1`** sends the host's own chain traffic (statement store, + allowance renewal, preimages, PGAS claims) through the embedded smoldot light + client instead of the public nodes, for CI runners those nodes rate-limit. Account + setup and dotNS username reads still use RPC. The first connection to a chain + waits for it to sync. - **Ring-VRF product-account aliases and proofs** are implemented by the signing host via the `verifiable` crate (`get_account_alias` and `create_account_proof`). diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index d878c88a6..dac960c69 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -1669,6 +1669,13 @@ preset carries without serving them as a role, of which neither preset has any. The all-zero SSO sentinel and every genesis hash not present in the active route map fall back to the People RPC. +With `TRUAPI_LIGHT_CLIENT=1` the host's `ChainProvider` is the embedded light +client from `truapi-provider` instead. It resolves every chain of the preset from +the provider's bundled catalog by genesis hash, answers the all-zero sentinel from +People, and keeps one idle connection open per chain so a chain stays synced and +keeps its statement-store peers between the runtime's per-call connections. The +direct RPC reads made during account setup and dotNS username lookup are unchanged. + A rustls ring crypto provider is installed at process startup for `wss://` connections. diff --git a/rust/crates/truapi-host-cli/src/attestation.rs b/rust/crates/truapi-host-cli/src/attestation.rs index babb91dcd..ff124d734 100644 --- a/rust/crates/truapi-host-cli/src/attestation.rs +++ b/rust/crates/truapi-host-cli/src/attestation.rs @@ -17,7 +17,6 @@ use base64::engine::general_purpose::STANDARD as BASE64; use futures_util::{StreamExt as _, TryStreamExt as _, stream}; use serde::Deserialize; use serde_json::{Value, json}; -use sha2::{Digest as _, Sha256}; use std::collections::BTreeSet; use std::sync::Mutex; use tracing::{debug, warn}; @@ -26,8 +25,8 @@ use truapi::host_logic::dotns_gateway::{ MAX_BASE_LABEL_LEN, MIN_PERSON_LABEL_LEN, is_registrable_full_label, }; use truapi::host_logic::product_account::{ - SR25519_SIGNING_CONTEXT, derive_identity_keypair, derive_root_keypair_from_entropy, - identity_product_id, product_public_key_to_address, + derive_identity_keypair, derive_root_keypair_from_entropy, identity_product_id, + product_public_key_to_address, }; use crate::dotns_read::AssetHubReader; @@ -165,22 +164,14 @@ async fn mint_backend_token( .decode(&challenge) .context("challenge is not valid base64")?; - let keypair = derive_identity_keypair(auth_entropy, network_suffix) - .map_err(|err| anyhow::anyhow!("backend auth identity derivation failed: {err}"))?; - let client_id = keypair.public.to_bytes(); - - // The proof signs SHA256(challenge || clientId || SHA256(body)). It must - // cover the exact bytes the request carries, so the body is serialized once. let payload = b"{}"; - let mut hasher = Sha256::new(); - hasher.update(&challenge_bytes); - hasher.update(client_id); - hasher.update(Sha256::digest(payload)); - let message: [u8; 32] = hasher.finalize().into(); - let proof = keypair - .secret - .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) - .to_bytes(); + let (client_id, proof) = truapi::host_logic::attestation::sign_backend_challenge( + auth_entropy, + network_suffix, + &challenge_bytes, + payload, + ) + .context("backend auth identity derivation failed")?; let url = format!("{backend_base}/auth/token"); let response = client @@ -586,23 +577,11 @@ async fn submit_registration( ) -> Result<()> { let backend_base = config.backend_base.as_str(); let url = format!("{backend_base}/usernames"); - let mut dotns = json!({ - "signature": hex0x(®.dotns_signature), - "signedAt": signed_at, - }); - if let Some(reserved) = config.reserved_username.as_deref() { - dotns["reservedUsername"] = json!(reserved); - } - let body = json!({ - "username": config.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), - "dotns": dotns, - }); + let body = reg.request_body( + &config.username_base, + config.reserved_username.as_deref(), + signed_at, + ); let response = send_with_backend_auth( client, backend_base, @@ -628,10 +607,6 @@ async fn submit_registration( bail!("username registration failed ({status}): {text}"); } -fn hex0x(bytes: &[u8]) -> String { - format!("0x{}", hex::encode(bytes)) -} - async fn wait_for_dotns_username( reader: &mut AssetHubReader, candidate: &[u8; 32], diff --git a/rust/crates/truapi-host-cli/src/chain.rs b/rust/crates/truapi-host-cli/src/chain.rs index f831fe27d..af518a1ae 100644 --- a/rust/crates/truapi-host-cli/src/chain.rs +++ b/rust/crates/truapi-host-cli/src/chain.rs @@ -1,11 +1,12 @@ -//! Native WebSocket `ChainProvider` / `JsonRpcConnection`. +//! Native `ChainProvider`s: WebSocket JSON-RPC to the preset's public nodes, or +//! the embedded smoldot light client. //! -//! 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. +//! The headless hosts reach the real People-chain statement store. Over RPC +//! every `connect` opens a fresh socket; the runtime's `HostRpcClient` sits on +//! top and speaks statement-store RPC. use std::collections::HashMap; +use std::collections::hash_map::Entry; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -18,8 +19,9 @@ use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; use tracing::{debug, warn}; use truapi::platform::{ChainProvider, JsonRpcConnection, ProviderError}; +use truapi_provider::EmbeddedChainProvider; -use crate::network::ChainEndpoint; +use crate::network::{ChainEndpoint, NetworkConfig}; /// Broadcast backlog for inbound JSON-RPC frames per connection. const INBOUND_CHANNEL_CAPACITY: usize = 1024; @@ -101,6 +103,74 @@ impl ChainProvider for WsChainProvider { } } +/// How the headless hosts reach the chains. +/// +/// RPC dials the preset's public nodes. `TRUAPI_LIGHT_CLIENT=1` runs the embedded +/// light client instead, which needs no node, so a public node rate-limiting a +/// shared address (a CI runner) cannot refuse the host. +pub enum CliChainProvider { + /// The preset's public nodes, the default. + Rpc(WsChainProvider), + /// Resolves every chain of the preset from the provider's bundled network + /// catalog by genesis hash. + LightClient { + provider: Box, + people_genesis: [u8; 32], + /// One idle connection per chain for the life of the process. The light + /// client stops a chain when its last connection closes and the runtime + /// connects once per call, so without it every call would sync again and + /// start with no peers to broadcast a statement to. + kept_open: tokio::sync::Mutex>>, + }, +} + +impl CliChainProvider { + /// Light client when `TRUAPI_LIGHT_CLIENT=1`, RPC otherwise. + pub fn new(network: NetworkConfig) -> Self { + if std::env::var("TRUAPI_LIGHT_CLIENT").as_deref() == Ok("1") { + Self::LightClient { + provider: Box::new(EmbeddedChainProvider::builder().build()), + people_genesis: network.people_genesis, + kept_open: tokio::sync::Mutex::default(), + } + } else { + Self::Rpc(WsChainProvider::new( + network.people_ws, + network.live_chain_endpoints, + )) + } + } +} + +#[async_trait] +impl ChainProvider for CliChainProvider { + async fn connect( + &self, + genesis_hash: [u8; 32], + ) -> Result, ProviderError> { + match self { + Self::Rpc(provider) => provider.connect(genesis_hash).await, + Self::LightClient { + provider, + people_genesis, + kept_open, + } => { + // The all-zero SSO sentinel is answered by People, as over RPC. + let genesis = if genesis_hash == [0; 32] { + *people_genesis + } else { + genesis_hash + }; + debug!(genesis = %hex::encode(genesis), "light client connect"); + if let Entry::Vacant(slot) = kept_open.lock().await.entry(genesis) { + slot.insert(provider.connect(genesis).await?); + } + provider.connect(genesis).await + } + } + } +} + /// One WebSocket JSON-RPC connection: outbound requests are queued to a writer /// task, inbound frames are broadcast to every `responses()` stream. pub struct WsJsonRpcConnection { diff --git a/rust/crates/truapi-host-cli/src/frame_server.rs b/rust/crates/truapi-host-cli/src/frame_server.rs index eb57afe2c..e60a2a3a3 100644 --- a/rust/crates/truapi-host-cli/src/frame_server.rs +++ b/rust/crates/truapi-host-cli/src/frame_server.rs @@ -110,6 +110,9 @@ pub trait ProductRuntimeFactory: Send + Sync + 'static { } } +// async-trait's generated boxed Future is already must-use; newer Clippy +// otherwise reports the macro's duplicate annotation on this private trait. +#[allow(clippy::double_must_use)] #[async_trait::async_trait] trait ConnectionRuntime: Send + Sync + 'static { async fn receive_frame(&self, frame: Vec) -> Result<(), ProductRuntimeError>; diff --git a/rust/crates/truapi-host-cli/src/network.rs b/rust/crates/truapi-host-cli/src/network.rs index de2df03dd..f8717a6d7 100644 --- a/rust/crates/truapi-host-cli/src/network.rs +++ b/rust/crates/truapi-host-cli/src/network.rs @@ -297,6 +297,29 @@ mod tests { } } + /// `TRUAPI_LIGHT_CLIENT=1` finds each chain in the provider's bundled catalog by + /// the preset's genesis hash, so a network reset applied to only one of the two + /// copies leaves the light client unable to connect. + #[test] + fn every_preset_matches_the_light_client_catalog() { + for network in Network::value_variants() { + let config = network.config(); + let (_, catalog) = truapi_provider::EmbeddedChainProviderBuilder::new() + .add_network(config.id) + .unwrap_or_else(|error| panic!("{} is not in the catalog: {error:?}", config.id)); + assert_eq!( + (catalog.people, catalog.assethub, catalog.bulletin), + ( + config.people_genesis, + config.asset_hub_genesis, + config.bulletin_genesis + ), + "{} genesis hashes differ from the light-client catalog", + config.id + ); + } + } + /// Anchors the served set itself. Every other test that reads it iterates, so /// all of them pass on an empty set; this is what notices a dropped role, and it /// covers each preset rather than only the default. diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index c533a52ad..6b3cb3e4f 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -31,7 +31,7 @@ use truapi::platform::{ use truapi::v01; use crate::bulletin_lookup::{BitswapRpc, BulletinLookup}; -use crate::chain::WsChainProvider; +use crate::chain::CliChainProvider; use crate::terminal_ui::{ApprovalKind, SystemEvent, UiHandle}; static NEXT_STORAGE_TEMP_ID: AtomicU32 = AtomicU32::new(0); @@ -90,7 +90,7 @@ impl CliStoragePaths { /// Headless-host platform shared by both roles. pub struct CliPlatform { - chain: WsChainProvider, + chain: CliChainProvider, /// Chain roles this host serves, answered by `Features::supported_chains`. chains: truapi::platform::HostChainSet, product_storage: Mutex>>>, @@ -122,7 +122,10 @@ impl CliPlatform { /// rather than assume it. #[cfg(test)] pub fn routed_url(&self, genesis_hash: &[u8; 32]) -> &str { - self.chain.routed_url(genesis_hash) + let CliChainProvider::Rpc(rpc) = &self.chain else { + panic!("routing is only asserted on the RPC provider"); + }; + rpc.routed_url(genesis_hash) } /// Build a platform whose chain provider connects to the network's People @@ -180,7 +183,7 @@ impl CliPlatform { .unwrap_or_default(); Arc::new(Self { - chain: WsChainProvider::new(network.people_ws, network.live_chain_endpoints), + chain: CliChainProvider::new(network), chains: network.host_chain_set(), product_storage: Mutex::new(product_storage), core_storage: Mutex::new(core_storage), @@ -1018,6 +1021,7 @@ impl LocaleHost for CliPlatform { Box::pin(stream::once(async { Ok(api::HostLocaleSubscribeItem { language_tag: "en".to_string(), + time_zone: None, }) })) } diff --git a/rust/crates/truapi-host-cli/src/script_runner.rs b/rust/crates/truapi-host-cli/src/script_runner.rs index 7095a5b27..85788e2ce 100644 --- a/rust/crates/truapi-host-cli/src/script_runner.rs +++ b/rust/crates/truapi-host-cli/src/script_runner.rs @@ -539,33 +539,6 @@ console.log(JSON.stringify({ Ok(()) } - #[test] - fn managed_scripts_use_their_project_directory_when_selected_explicitly() -> Result<()> { - let project = tempfile::tempdir()?; - fs::write( - project.path().join("package.json"), - r#"{"truapiHost":{"script":"script.ts"}}"#, - )?; - let script = project.path().join("script.ts"); - fs::write(&script, "console.log('hello');\n")?; - - let command = command( - "ws://127.0.0.1:1234", - "example.dot", - &script, - ScriptHostRole::SigningHost, - )?; - - assert_eq!( - command - .as_std() - .get_envs() - .find_map(|(key, value)| { (key == "TRUAPI_SCRIPT_CWD").then_some(value) }), - Some(Some(project.path().as_os_str())) - ); - Ok(()) - } - #[test] fn editor_command_accepts_quoted_arguments_without_a_shell() -> Result<()> { let (program, arguments) = parse_editor("code --wait \"profile one\"")?; diff --git a/rust/crates/truapi-host-cli/src/terminal_ui.rs b/rust/crates/truapi-host-cli/src/terminal_ui.rs index efd73eb5e..7b368e90a 100644 --- a/rust/crates/truapi-host-cli/src/terminal_ui.rs +++ b/rust/crates/truapi-host-cli/src/terminal_ui.rs @@ -3858,10 +3858,11 @@ mod tests { delivered, "the installed sender was cleared by a foreign drop" ); - assert!(matches!( - receiver.try_recv(), - Ok(UiEvent::Log(line)) if line == "after a foreign drop" - )); + // Other tests can log through the installed UI without owning its slot. + assert!( + std::iter::from_fn(|| receiver.try_recv().ok()) + .any(|event| matches!(event, UiEvent::Log(line) if line == "after a foreign drop")) + ); } #[test] @@ -3894,9 +3895,15 @@ mod tests { }); *active_ui().lock().expect("unlock active test UI") = restore; - let UiEvent::Sso(event) = receiver.try_recv().expect("summary transcript event") else { - panic!("expected SSO transcript event"); - }; + // Concurrent tests may emit unrelated events through the installed UI. + let event = std::iter::from_fn(|| receiver.try_recv().ok()) + .find_map(|event| match event { + UiEvent::Sso(event) if event.statement_request_id.as_deref() == Some("req:1") => { + Some(event) + } + _ => None, + }) + .expect("summary transcript event"); assert_eq!(event.kind.as_deref(), Some("response_sent")); assert_eq!(event.request.as_deref(), Some("get_account_alias")); assert_eq!(event.elapsed_ms, Some(84)); diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/request_without_variant.rs b/rust/crates/truapi-macros/tests/ui/sso/fail/request_without_variant.rs deleted file mode 100644 index 84410dcb0..000000000 --- a/rust/crates/truapi-macros/tests/ui/sso/fail/request_without_variant.rs +++ /dev/null @@ -1,29 +0,0 @@ -include!("../support/wire.rs"); -include!("../support/runtime.rs"); - -use host_internal::sso_messages::*; -use runtime::sso_service::SsoRequestContext; - -struct Service; -struct MissingRequest; - -#[truapi_macros::sso_service] -impl Service { - async fn foo(&self, _: &SsoRequestContext, _request: Request) -> FooResponse { - Ok(1) - } - - async fn bar(&self, _: &SsoRequestContext, _request: BarRequest) -> BarResponse { - Ok(2) - } - - async fn baz(&self, _: &SsoRequestContext, _request: Box>) -> FooResponse { - Ok(3) - } - - async fn missing(&self, _: &SsoRequestContext, _request: MissingRequest) -> FooResponse { - Ok(4) - } -} - -fn main() {} diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/request_without_variant.stderr b/rust/crates/truapi-macros/tests/ui/sso/fail/request_without_variant.stderr deleted file mode 100644 index d6fb11524..000000000 --- a/rust/crates/truapi-macros/tests/ui/sso/fail/request_without_variant.stderr +++ /dev/null @@ -1,10 +0,0 @@ -error[E0599]: no variant, associated function, or constant named `MissingRequest` found for enum `sso_messages::v1::RemoteMessage` in the current scope - --> tests/ui/sso/fail/request_without_variant.rs:24:14 - | -24 | async fn missing(&self, _: &SsoRequestContext, _request: MissingRequest) -> FooResponse { - | ^^^^^^^ variant, associated function, or constant not found in `sso_messages::v1::RemoteMessage` - | - ::: tests/ui/sso/fail/../support/wire.rs - | - | pub enum RemoteMessage { - | ---------------------- variant, associated function, or constant `MissingRequest` not found for this enum diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.rs b/rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.rs deleted file mode 100644 index 2b0b0ad87..000000000 --- a/rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.rs +++ /dev/null @@ -1,25 +0,0 @@ -include!("../support/wire.rs"); -include!("../support/runtime.rs"); - -use host_internal::sso_messages::*; -use runtime::sso_service::SsoRequestContext; - -type MissingResponse = FooResponse; -struct Service; - -#[truapi_macros::sso_service] -impl Service { - async fn foo(&self, _: &SsoRequestContext, _request: Request) -> MissingResponse { - Ok(1) - } - - async fn bar(&self, _: &SsoRequestContext, _request: BarRequest) -> BarResponse { - Ok(2) - } - - async fn baz(&self, _: &SsoRequestContext, _request: Box>) -> FooResponse { - Ok(3) - } -} - -fn main() {} diff --git a/rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.stderr b/rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.stderr deleted file mode 100644 index b7a2eec10..000000000 --- a/rust/crates/truapi-macros/tests/ui/sso/fail/response_without_variant.stderr +++ /dev/null @@ -1,10 +0,0 @@ -error[E0599]: no variant, associated function, or constant named `MissingResponse` found for enum `sso_messages::v1::RemoteMessage` in the current scope - --> tests/ui/sso/fail/response_without_variant.rs:12:75 - | -12 | async fn foo(&self, _: &SsoRequestContext, _request: Request) -> MissingResponse { - | ^^^^^^^^^^^^^^^ variant, associated function, or constant not found in `sso_messages::v1::RemoteMessage` - | - ::: tests/ui/sso/fail/../support/wire.rs - | - | pub enum RemoteMessage { - | ---------------------- variant, associated function, or constant `MissingResponse` not found for this enum diff --git a/rust/crates/truapi-polkavm-host/Cargo.toml b/rust/crates/truapi-polkavm-host/Cargo.toml new file mode 100644 index 000000000..e75916bf8 --- /dev/null +++ b/rust/crates/truapi-polkavm-host/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "truapi-polkavm-host" +version = "0.2.0" +edition.workspace = true +description = "Optional composition of the TrUAPI server and PolkaVM host runtime" +license = "MIT" +repository = "https://github.com/paritytech/host-rust-core" +publish = false + +[lib] +crate-type = ["rlib"] + +[dependencies] +polkavm-host-runtime = { workspace = true, features = ["ffi", "native-gpu"] } +truapi = { path = "../truapi" } + +[lints] +workspace = true diff --git a/rust/crates/truapi-polkavm-host/src/lib.rs b/rust/crates/truapi-polkavm-host/src/lib.rs new file mode 100644 index 000000000..f23df0711 --- /dev/null +++ b/rust/crates/truapi-polkavm-host/src/lib.rs @@ -0,0 +1,16 @@ +//! Optional composition of the TrUAPI server and PolkaVM host runtime. +//! +//! The base [`truapi`] remains independent of PolkaVM. Native hosts that +//! need both surfaces link this crate, which pins one reviewed runtime revision. + +/// The pinned PolkaVM host runtime API. +pub use polkavm_host_runtime; +/// The PolkaVM-independent TrUAPI server API. +pub use truapi; + +/// Version of this optional composition crate. +pub const TRUAPI_POLKAVM_HOST_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Version of the pinned PolkaVM host runtime. +pub const POLKAVM_HOST_RUNTIME_VERSION: &str = "0.3.0"; +/// Immutable source revision of the pinned PolkaVM host runtime. +pub const POLKAVM_HOST_RUNTIME_SOURCE_REVISION: &str = "e60a6135be6e00c72c90ca4abe23d2002f4eb962"; diff --git a/rust/crates/truapi/Cargo.toml b/rust/crates/truapi/Cargo.toml index c9f0bdcfc..d33ac9fa8 100644 --- a/rust/crates/truapi/Cargo.toml +++ b/rust/crates/truapi/Cargo.toml @@ -6,7 +6,7 @@ license.workspace = true description = "TrUAPI trait and type definitions, and the runtime hosts embed" [lib] -crate-type = ["rlib", "cdylib", "staticlib"] +crate-type = ["rlib"] [package.metadata.wasm-pack.profile.release] wasm-opt = [ @@ -29,10 +29,19 @@ dwarf-debug-info = false [features] default = ["runtime", "wasm-signing-host"] +# Async API traits and their std-backed call and subscription primitives. +# Codegen enables this without compiling the host implementation. +host-api = [ + "dep:async-trait", + "dep:futures", + "derive_more/std", + "hex/std", + "parity-scale-codec/std", +] # The host runtime: dispatcher, frames, host logic, native and WASM surfaces. -# Without it the crate is the protocol definitions alone, which is what -# codegen reads and builds against. +# With no features the crate contains only no_std protocol payloads and envelopes. runtime = [ + "host-api", "dep:truapi-provider", "dep:futures-timer", "dep:scale-decode", @@ -88,11 +97,11 @@ mock = ["runtime"] [dependencies] truapi-macros = { path = "../truapi-macros" } -async-trait = { workspace = true } -derive_more = { workspace = true, features = ["debug", "display", "error", "from"] } -futures = { workspace = true } -hex = { workspace = true } -parity-scale-codec = { workspace = true, features = ["derive"] } +async-trait = { workspace = true, optional = true } +derive_more = { workspace = true, default-features = false, features = ["debug", "display", "error", "from"] } +futures = { workspace = true, optional = true } +hex = { workspace = true, default-features = false, features = ["alloc"] } +parity-scale-codec = { workspace = true, default-features = false, features = ["derive"] } truapi-provider = { path = "../truapi-provider", default-features = false, optional = true } futures-timer = { workspace = true, optional = true } diff --git a/rust/crates/truapi/README.md b/rust/crates/truapi/README.md index 09878a68c..b7eec66a1 100644 --- a/rust/crates/truapi/README.md +++ b/rust/crates/truapi/README.md @@ -16,6 +16,16 @@ It defines: The TypeScript client and the host dispatcher are both generated from this crate. +The default `runtime` feature includes the host implementation. `host-api` exposes +the async host traits and call primitives without the dispatcher, which lets +code generation bootstrap before generating runtime sources. Protocol payloads +and envelopes remain available without either feature for guest clients. + +Normal Rust builds emit an `rlib`, so a `no_std` guest can provide its own +allocator and panic handler. Native packaging targets request `cdylib` or +`staticlib` explicitly with `cargo rustc`; the browser build likewise requests +`cdylib` before running `wasm-bindgen` and `wasm-opt`. + ## Architecture The crate has two layers: diff --git a/rust/crates/truapi/src/api/locale.rs b/rust/crates/truapi/src/api/locale.rs index 045f19aa3..bf38ffd12 100644 --- a/rust/crates/truapi/src/api/locale.rs +++ b/rust/crates/truapi/src/api/locale.rs @@ -1,7 +1,9 @@ //! Unified [`Locale`] trait. use crate::versioned::locale::{ - HostLocaleSubscribeError, HostLocaleSubscribeItem, HostLocaleSubscribeRequest, + HostLocaleLocalizeTimestampsError, HostLocaleLocalizeTimestampsRequest, + HostLocaleLocalizeTimestampsResponse, HostLocaleSubscribeError, HostLocaleSubscribeItem, + HostLocaleSubscribeRequest, }; use crate::{CallContext, CallError, Subscription}; use crate::{wire, wire_trait}; @@ -28,4 +30,29 @@ pub trait Locale: Send + Sync { ) -> Subscription> { Subscription::interrupted(CallError::unavailable()) } + + /// Localize a bounded batch of UTC instants in a host locale snapshot. + /// + /// ```ts + /// import { firstValueFrom, from } from "rxjs"; + /// + /// const locale = await firstValueFrom(from(truapi.locale.subscribe())); + /// if (locale.timeZone !== undefined) { + /// const localized = await truapi.locale.localizeTimestamps({ + /// timestampsMs: [BigInt(Date.now())], + /// languageTag: locale.languageTag, + /// timeZone: locale.timeZone, + /// }); + /// if (localized.isOk()) console.log(localized.value.timestamps[0]?.dateTime); + /// } + /// ``` + #[wire(id = 1)] + async fn localize_timestamps( + &self, + _cx: &CallContext, + _request: HostLocaleLocalizeTimestampsRequest, + ) -> Result> + { + Err(CallError::unavailable()) + } } diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index bd807a77b..c28be5a07 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -28,7 +28,6 @@ use tracing::{instrument, warn}; use truapi::v01; use truapi::{CallContext, CancellationReason}; -use crate::truapi_core::TrUApiCore; use crate::frame::ProtocolMessage; use crate::host_internal::sso_messages::{RemoteMessage, SsoRequestOutcome}; use crate::host_logic::worker::WorkerLedger; @@ -42,6 +41,7 @@ use crate::runtime::{ }; use crate::subscription::{HostInitiatedSubscriptionManager, Spawner}; use crate::transport::Transport; +use crate::truapi_core::TrUApiCore; /// Outgoing frame sink owned by a host adapter. /// @@ -300,6 +300,22 @@ impl PairingHostRuntime { ) } + /// Build one execution with local adapters and shared authentication/services. + pub fn product_runtime_with( + &self, + product: ProductContext, + adapters: ConnectionAdapters, + sink: Arc, + ) -> ProductRuntime { + ProductRuntime::new( + self.services.clone(), + self.pairing_host.clone(), + product, + adapters, + sink, + ) + } + /// Build a product-scoped administration handle from this pairing host. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.product_admin"))] pub fn product_admin(&self, product: ProductContext) -> HostAdmin { @@ -572,22 +588,6 @@ impl SigningHostRuntime { self.signing_host.set_grant_allowances_unchecked(granted); } - /// The product's hard-subtree public key, derived from the active session - /// root, or `None` while no session is active. - /// - /// A signing host holds the root, so it answers this locally where a - /// pairing host has to ask the Account Holder. - pub fn product_subtree_public_key( - &self, - product_id: &str, - ) -> Result, v01::GenericError> { - self.signing_host - .derive_subtree_public_key(product_id) - .map_err(|err| v01::GenericError { - reason: err.to_string(), - }) - } - /// Answer these resource tags as refused, replacing any earlier set. /// /// For test hosts only, with the `test-host` feature enabled. @@ -751,9 +751,8 @@ impl SigningHostRuntime { ) } - /// Build one product connection with adapters scoped to one native - /// executable while sharing this runtime's authentication and services. - #[cfg(not(target_arch = "wasm32"))] + /// Build one product connection with execution-local adapters while + /// sharing this runtime's authentication and services. pub fn product_runtime_with( &self, product: ProductContext, @@ -858,6 +857,73 @@ impl SigningHostRuntime { .map_err(ring_vrf_admin_error) } + /// Read the active local session's X25519 chat identity private key. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.session_chat_identity_key"))] + pub fn session_chat_identity_key(&self) -> Option<[u8; 32]> { + self.signing_host + .session_state() + .current()? + .identity_chat_private_key + } + + /// Read this browser's X25519 encryption secret, generating and persisting + /// it on first read. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.device_encryption_key"))] + pub async fn device_encryption_key(&self) -> Result<[u8; 32], v01::GenericError> { + self.services + .device_encryption_secret() + .await + .map_err(|reason| v01::GenericError { reason }) + } + + /// Resolve `product_id`'s hard-subtree public key from the active local + /// signing session. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.product_subtree_public_key"))] + pub async fn product_subtree_public_key( + &self, + product_id: &str, + timeout_ms: Option, + ) -> Result, v01::GenericError> { + product_subtree_public_key(self.signing_host.as_ref(), product_id, timeout_ms).await + } + + /// Read a stored permission authorization status without prompting. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.permission_authorization_status", product_id = %product_id))] + pub async fn permission_authorization_status( + &self, + product_id: &str, + request: PermissionAuthorizationRequest, + ) -> Result { + self.product_admin(product_context(product_id)?) + .permission_authorization_status(request) + .await + } + + /// Read stored permission authorization statuses without prompting. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.permission_authorization_statuses", product_id = %product_id))] + pub async fn permission_authorization_statuses( + &self, + product_id: &str, + requests: Vec, + ) -> Result, v01::GenericError> { + self.product_admin(product_context(product_id)?) + .permission_authorization_statuses(requests) + .await + } + + /// Update one stored permission authorization status. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.set_permission_authorization_status", product_id = %product_id))] + pub async fn set_permission_authorization_status( + &self, + product_id: &str, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + self.product_admin(product_context(product_id)?) + .set_permission_authorization_status(request, status) + .await + } + /// Activate a wallet-local session from host-held secret material (raw /// BIP-39 entropy). #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.activate_local_session"))] @@ -886,6 +952,65 @@ impl SigningHostRuntime { }) } + /// Capture the local activation fence for a multi-step identity operation. + pub fn local_identity_context( + &self, + ) -> Result { + self.signing_host.local_identity_context() + } + + /// Sign a backend challenge as the active UID account, with the exact `{}` token body. + pub fn local_identity_auth_proof( + &self, + activation_id: &str, + challenge: &[u8], + ) -> Result, v01::GenericError> { + self.signing_host + .local_identity_auth_proof(activation_id, challenge) + } + + /// Build the backend registration JSON using native proofs and Asset Hub time. + pub async fn local_lite_registration_body( + &self, + activation_id: &str, + username_base: &str, + verifier: [u8; 32], + ) -> Result { + self.signing_host + .local_lite_registration_body(activation_id, username_base, verifier) + .await + } + + /// Refresh authoritative metadata, rejecting a result for a replaced activation. + pub async fn refresh_local_identity_for( + &self, + activation_id: &str, + ) -> Result { + self.signing_host + .refresh_local_identity(activation_id) + .await + } + + /// Resolve and install the active local UID account's on-chain identity. + pub async fn refresh_local_identity( + &self, + ) -> Result { + let context = self.local_identity_context()?; + self.refresh_local_identity_for(&context.activation_id) + .await + } + + /// Inspect current wallet allowances without allocating or mutating identity. + pub async fn get_wallet_allowance_snapshot( + &self, + activation_id: &str, + product_ids: Vec, + ) -> Result { + self.signing_host + .get_wallet_allowance_snapshot(activation_id, product_ids) + .await + } + /// Answer a pairing host's handshake deeplink and serve the resulting SSO /// session until it ends. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.respond_to_pairing"))] @@ -976,10 +1101,7 @@ impl SigningHostRuntime { /// even while that request is still being answered by another call, and a /// withdrawn request is answered `Ignored`. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.answer_sso_request"))] - pub async fn answer_sso_request( - &self, - message: RemoteMessage, - ) -> SsoRequestOutcome { + pub async fn answer_sso_request(&self, message: RemoteMessage) -> SsoRequestOutcome { let service = SigningHostSsoService::new(self.signing_host.clone()); match service.answer(message).await { Dispatch::Response(answer) => SsoRequestOutcome::Response { @@ -1091,8 +1213,8 @@ impl SigningHostRuntime { } /// Adapters scoped to one product connection: the platform serving its -/// syscalls, the optional native Chat adapter, and the connection's -/// host-fed action streams. Non-native connections use [`Self::from_services`]. +/// syscalls, optional capability adapters, and the connection's host-fed +/// action streams. Unscoped connections use [`Self::from_services`]. /// /// `pocket_platform` is the same kind of optional adapter for the card /// collection. @@ -1100,6 +1222,7 @@ impl SigningHostRuntime { pub struct ConnectionAdapters { pub platform: Arc, pub chat_platform: Option>, + pub contacts_platform: Option>, /// Live OS permission state for this connection. It travels here rather /// than on the host runtime because a native host builds one platform per /// product execution, so the object that reports OS state has to be the @@ -1118,6 +1241,7 @@ impl ConnectionAdapters { Self { platform: services.platform.clone(), chat_platform: services.chat_platform.clone(), + contacts_platform: services.contacts_platform(), permission_status: services.permission_status_host(), permission_grants: Arc::default(), chat: Arc::new(ActionChannel::chat()), diff --git a/rust/crates/truapi/src/host_internal/bulletin.rs b/rust/crates/truapi/src/host_internal/bulletin.rs index fdd2d118b..6e6ad4da4 100644 --- a/rust/crates/truapi/src/host_internal/bulletin.rs +++ b/rust/crates/truapi/src/host_internal/bulletin.rs @@ -23,7 +23,7 @@ pub const STORE_PALLET_NAME: &str = "TransactionStorage"; pub const STORE_CALL_NAME: &str = "store"; /// Mortality window for store transactions. -const MORTAL_PERIOD_BLOCKS: u64 = 64; +pub(crate) const MORTAL_PERIOD_BLOCKS: u64 = 64; /// Preimage key: blake2b-256 of the raw preimage bytes. pub fn preimage_key(value: &[u8]) -> [u8; 32] { @@ -55,18 +55,22 @@ pub fn preimage_cid(key: &[u8; 32]) -> String { cid } -/// Build and sign a `TransactionStorage.store { data }` transaction with the -/// Bulletin allowance signer against the client's block. Subxt chooses the -/// supported transaction version and injects the nonce and mortality anchor -/// from that same at-block client, so signing and dry-run stay aligned. +/// Build and sign `TransactionStorage.store` using the current state for the +/// nonce and runtime version, but a finalized block for the mortality anchor. +/// An unfinalized anchor can disappear on a fork after validation succeeds. pub async fn build_signed_store_transaction>( client: &ClientAtBlock, + finalized: &ClientAtBlock, signer: &Sr25519Signer, data: &[u8], ) -> Result, ExtrinsicError> { let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(data)); let params = DefaultExtrinsicParamsBuilder::::new() - .mortal(MORTAL_PERIOD_BLOCKS) + .mortal_from_unchecked( + MORTAL_PERIOD_BLOCKS, + finalized.block_number(), + finalized.block_hash(), + ) .build(); let mut tx = client.tx(); tx.create_signed(&payload, signer, params).await diff --git a/rust/crates/truapi/src/host_logic.rs b/rust/crates/truapi/src/host_logic.rs index dfb799dbe..8775416be 100644 --- a/rust/crates/truapi/src/host_logic.rs +++ b/rust/crates/truapi/src/host_logic.rs @@ -4,8 +4,8 @@ //! storage, URL handler, notification center). Everything else lives here so //! iOS, Android, and web hosts share one canonical implementation. -// Links `verifiable` directly, which the browser core loads on demand instead. -#[cfg(not(target_arch = "wasm32"))] +// Its `verifiable` steps are native-only; the browser core supplies them from +// the module it loads on demand. pub mod attestation; pub mod contact_substitution; pub mod device_key; diff --git a/rust/crates/truapi/src/host_logic/attestation.rs b/rust/crates/truapi/src/host_logic/attestation.rs index 25a8af870..9833bb107 100644 --- a/rust/crates/truapi/src/host_logic/attestation.rs +++ b/rust/crates/truapi/src/host_logic/attestation.rs @@ -1,4 +1,4 @@ -//! Lite-person username registration parameters (signing host, native only). +//! Lite-person username registration parameters for signing hosts. //! //! Builds the client-side proofs the identity backend needs to //! attest a lite username for an account: an sr25519 proof-of-ownership, a @@ -13,14 +13,19 @@ use parity_scale_codec::{Decode, Encode}; use thiserror::Error; -use verifiable::Error as VerifiableError; +// Native builds link `verifiable`; the browser core loads it on demand, so a +// browser caller supplies the ring-VRF steps to `build_lite_registration_with`. +#[cfg(not(target_arch = "wasm32"))] use verifiable::GenerateVerifiable; +#[cfg(not(target_arch = "wasm32"))] use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; use crate::host_logic::dotns_gateway::build_reservation_message; +#[cfg(not(target_arch = "wasm32"))] +use crate::host_logic::product_account::derive_lite_person_ring_vrf_entropy; use crate::host_logic::product_account::{ ProductAccountError, SR25519_SIGNING_CONTEXT, derive_identity_keypair, - derive_lite_person_ring_vrf_entropy, product_public_key_to_address, + product_public_key_to_address, }; use crate::host_logic::sso::pairing::{derive_identity_chat_private_key, x25519_public_key}; @@ -69,6 +74,57 @@ pub struct LiteRegistration { pub dotns_signature: [u8; 64], } +impl LiteRegistration { + /// Encode the identity backend's registration body, sharing CLI and browser wire bytes. + pub fn request_body( + &self, + username_base: &str, + reserved_username: Option<&str>, + signed_at: u64, + ) -> serde_json::Value { + let hex0x = |bytes: &[u8]| format!("0x{}", hex::encode(bytes)); + let mut dotns = serde_json::json!({ + "signature": hex0x(&self.dotns_signature), + "signedAt": signed_at, + }); + if let Some(reserved) = reserved_username { + dotns["reservedUsername"] = serde_json::json!(reserved); + } + serde_json::json!({ + "username": username_base, + "candidateAccountId": self.candidate_account_id, + "candidateSignature": hex0x(&self.candidate_signature), + "ringVrfKey": hex0x(&self.ring_vrf_key), + "proofOfOwnership": hex0x(&self.proof_of_ownership), + "identifierKey": hex0x(&self.identifier_key), + "consumerRegistrationSignature": hex0x(&self.consumer_registration_signature), + "dotns": dotns, + }) + } +} + +/// Sign the backend auth challenge as the network's UID account, covering the exact body. +pub fn sign_backend_challenge( + entropy: &[u8], + network_suffix: &str, + challenge: &[u8], + body: &[u8], +) -> Result<([u8; 32], [u8; 64]), ProductAccountError> { + use sha2::{Digest as _, Sha256}; + let keypair = derive_identity_keypair(entropy, network_suffix)?; + let client_id = keypair.public.to_bytes(); + let mut hasher = Sha256::new(); + hasher.update(challenge); + hasher.update(client_id); + hasher.update(Sha256::digest(body)); + let message: [u8; 32] = hasher.finalize().into(); + let proof = keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) + .to_bytes(); + Ok((client_id, proof)) +} + /// Error while building lite-person registration parameters. #[derive(Debug, Error)] pub enum LiteRegistrationError { @@ -76,8 +132,8 @@ pub enum LiteRegistrationError { #[error("uid identity derivation failed: {0}")] CandidateDerivation(#[from] ProductAccountError), /// Ring-VRF proof-of-ownership failed. - #[error("ring-VRF proof-of-ownership failed: {0:?}")] - ProofOfOwnership(VerifiableError), + #[error("ring-VRF proof-of-ownership failed: {0}")] + ProofOfOwnership(String), } /// Build the lite-person registration parameters for `username_base` @@ -90,6 +146,7 @@ pub enum LiteRegistrationError { /// claim on dotNS. `dotns_signed_at_secs` must be Asset Hub chain time, meaning /// `Timestamp.Now` in seconds. The local wall clock will not do: the gateway /// rejects signatures more than 30 seconds in the chain's future. +#[cfg(not(target_arch = "wasm32"))] pub fn build_lite_registration( entropy: &[u8], network_suffix: &str, @@ -97,16 +154,46 @@ pub fn build_lite_registration( username_base: &str, reserved_username: Option<&str>, dotns_signed_at_secs: u64, +) -> Result { + let vrf_secret = BandersnatchVrfVerifiable::new_secret(derive_lite_person_ring_vrf_entropy( + entropy, + network_suffix, + )); + build_lite_registration_with( + entropy, + network_suffix, + verifier_account_id, + username_base, + reserved_username, + dotns_signed_at_secs, + BandersnatchVrfVerifiable::member_from_secret(&vrf_secret), + |message| { + BandersnatchVrfVerifiable::sign(&vrf_secret, message).map_err(|err| format!("{err:?}")) + }, + ) +} + +/// [`build_lite_registration`] with the two ring-VRF steps supplied by the +/// caller, for a target that does not link `verifiable`. `ring_vrf_key` is the +/// member key of the lite-person ring-VRF entropy for this entropy and suffix +/// (`product_account::derive_lite_person_ring_vrf_entropy`), and +/// `sign_proof_of_ownership` signs a message with that same key. +#[allow(clippy::too_many_arguments)] +pub fn build_lite_registration_with( + entropy: &[u8], + network_suffix: &str, + verifier_account_id: [u8; 32], + username_base: &str, + reserved_username: Option<&str>, + dotns_signed_at_secs: u64, + ring_vrf_key: [u8; 32], + sign_proof_of_ownership: impl FnOnce(&[u8]) -> Result<[u8; 64], String>, ) -> Result { // Registration, local activation, and the SSO responder all use the // RFC-0022 `uid.` default product account. let candidate = derive_identity_keypair(entropy, network_suffix)?; let candidate_public_key = candidate.public.to_bytes(); - let vrf_entropy = derive_lite_person_ring_vrf_entropy(entropy, network_suffix); - 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); @@ -116,8 +203,8 @@ pub fn build_lite_registration( .secret .sign_simple(SR25519_SIGNING_CONTEXT, &proof_message, &candidate.public) .to_bytes(); - let proof_of_ownership = BandersnatchVrfVerifiable::sign(&vrf_secret, &proof_message) - .map_err(LiteRegistrationError::ProofOfOwnership)?; + let proof_of_ownership = + sign_proof_of_ownership(&proof_message).map_err(LiteRegistrationError::ProofOfOwnership)?; let identifier_key = derive_identifier_key(entropy); diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 6988e5ef2..c71122958 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -1,3 +1,4 @@ +#![cfg_attr(not(feature = "host-api"), no_std)] #![allow( clippy::double_must_use, reason = "async-trait generates must_use futures for async trait methods" @@ -11,9 +12,9 @@ //! //! Concrete wire types live in per-version modules. Versioned envelopes are in //! [`versioned`]. -//! Async API traits use the `async_trait` macro so their concise `async fn` methods -//! still guarantee `Send` futures. Implementations must annotate their impl -//! blocks with `#[truapi::async_trait]`. +//! Async API traits (feature `host-api`) use the `async_trait` macro so their +//! concise `async fn` methods still guarantee `Send` futures. Implementations +//! must annotate their impl blocks with `#[truapi::async_trait]`. //! //! The runtime: hosts instantiate a role runtime around a `platform::Platform` //! implementation, then create product-scoped `ProductRuntime` endpoints that @@ -31,21 +32,35 @@ // `truapi`, the way code outside it does. extern crate self as truapi; +extern crate alloc; + +use alloc::string::String; +#[cfg(feature = "host-api")] +use alloc::{boxed::Box, format, vec::Vec}; use core::convert::Infallible; +#[cfg(feature = "host-api")] use core::fmt; +#[cfg(feature = "host-api")] use core::future::Future; +#[cfg(feature = "host-api")] use core::mem; +#[cfg(feature = "host-api")] use core::pin::Pin; +#[cfg(feature = "host-api")] use core::task::{Context, Poll, Waker}; +#[cfg(feature = "host-api")] use core::time::Duration; -use std::sync::Arc; -use std::sync::Mutex; +#[cfg(feature = "host-api")] +use std::sync::{Arc, Mutex}; +#[cfg(feature = "host-api")] use futures::Stream; use parity_scale_codec::{Decode, Encode}; +#[cfg(feature = "host-api")] pub use async_trait::async_trait; +#[cfg(feature = "host-api")] pub mod api; pub mod v01; pub mod v02; @@ -176,6 +191,13 @@ pub mod latest { LatestOf; /// Locale the host currently presents its interface in. pub type HostLocaleSubscribeItem = LatestOf; + /// Batched host-local calendar conversion request. + pub type HostLocaleLocalizeTimestampsRequest = + LatestOf; + /// Batched host-local calendar conversion result. + pub type HostLocaleLocalizeTimestampsResponse = + LatestOf; + pub use crate::v02::HostLocaleLocalizedTimestamp; /// Navigation request error. pub type HostNavigateToError = LatestOf; /// The calling product's Pocket cards. @@ -300,17 +322,20 @@ pub type FrameworkOnlyError = CallError; /// when a runtime explicitly cancels it, or when an attached timeout elapses. /// Subscription runtimes can cancel this token when the peer sends `_stop` or /// disconnects. +#[cfg(feature = "host-api")] #[derive(Clone, Default)] pub struct CancellationToken { inner: Arc, } #[derive(Default)] +#[cfg(feature = "host-api")] struct CancellationInner { state: Mutex, } #[derive(Default)] +#[cfg(feature = "host-api")] struct CancellationState { reason: Option, next_id: u64, @@ -318,6 +343,7 @@ struct CancellationState { } /// Cause attached to a cancelled call. +#[cfg(feature = "host-api")] #[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] pub enum CancellationReason { /// The caller or runtime explicitly cancelled the call. @@ -332,6 +358,7 @@ pub enum CancellationReason { } /// Render a timeout as whole seconds when possible, milliseconds otherwise. +#[cfg(feature = "host-api")] fn format_timeout(timeout: &Duration) -> String { if timeout.subsec_millis() == 0 { format!("{}s", timeout.as_secs()) @@ -341,11 +368,13 @@ fn format_timeout(timeout: &Duration) -> String { } /// Future resolved when a [`CancellationToken`] is cancelled. +#[cfg(feature = "host-api")] pub struct CancellationFuture { inner: Arc, id: Option, } +#[cfg(feature = "host-api")] impl fmt::Debug for CancellationToken { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("CancellationToken") @@ -354,6 +383,7 @@ impl fmt::Debug for CancellationToken { } } +#[cfg(feature = "host-api")] impl CancellationToken { /// Mark the token as cancelled. pub fn cancel(&self) { @@ -399,6 +429,7 @@ impl CancellationToken { } } +#[cfg(feature = "host-api")] impl Future for CancellationFuture { type Output = CancellationReason; @@ -430,6 +461,7 @@ impl Future for CancellationFuture { } } +#[cfg(feature = "host-api")] impl Drop for CancellationFuture { fn drop(&mut self) { let Some(id) = self.id.take() else { @@ -444,6 +476,7 @@ impl Drop for CancellationFuture { } /// Ambient context passed to every trait method. +#[cfg(feature = "host-api")] #[derive(Clone, Default)] pub struct CallContext { request_id: String, @@ -451,6 +484,7 @@ pub struct CallContext { timeout: Option, } +#[cfg(feature = "host-api")] impl CallContext { /// Construct a context bound to the given `request_id` with a fresh cancellation token. pub fn with_request_id(request_id: String) -> Self { @@ -498,10 +532,12 @@ impl CallContext { /// ends it: the runtime encodes that value as the `_interrupt` payload and /// polls no further. A stream that ends without an `Err` interrupts with /// `Ok(())`, which the peer reads as a normal completion. +#[cfg(feature = "host-api")] pub struct Subscription { inner: Pin> + Send>>, } +#[cfg(feature = "host-api")] impl Stream for Subscription { type Item = Result; @@ -510,6 +546,7 @@ impl Stream for Subscription { } } +#[cfg(feature = "host-api")] impl Subscription { /// Creates a subscription from a stream of items and at most one /// terminating interrupt. @@ -608,6 +645,7 @@ runtime_items! { AnnouncedPairing, DevicePairingObserver, MAX_PAIRING_METADATA_CHARS, PairedSsoPeer, PairingProposal, PairingProposalMetadata, ResponderExit, }; + pub use runtime::{LocalIdentity, LocalIdentityContext, WalletAllowanceSnapshot}; #[cfg(not(target_arch = "wasm32"))] pub use native::{ @@ -625,7 +663,7 @@ runtime_items! { }; } -#[cfg(test)] +#[cfg(all(test, feature = "host-api"))] mod tests { use super::*; diff --git a/rust/crates/truapi/src/native/callbacks.rs b/rust/crates/truapi/src/native/callbacks.rs index 976400d9d..649196396 100644 --- a/rust/crates/truapi/src/native/callbacks.rs +++ b/rust/crates/truapi/src/native/callbacks.rs @@ -135,7 +135,13 @@ pub trait HostCallbacks: Send + Sync { /// Locale the host currently presents its interface in. The native shim /// emits this as the current item in its subscription stream. - fn current_locale(&self) -> Result; + fn current_locale(&self) -> Result; + + /// Convert UTC timestamps with the host's calendar and time-zone database. + async fn localize_timestamps( + &self, + request: crate::latest::HostLocaleLocalizeTimestampsRequest, + ) -> Result; /// Answer a feature-support query. async fn feature_supported( diff --git a/rust/crates/truapi/src/native/events.rs b/rust/crates/truapi/src/native/events.rs index 007d8faaa..f43f8ea67 100644 --- a/rust/crates/truapi/src/native/events.rs +++ b/rust/crates/truapi/src/native/events.rs @@ -11,7 +11,7 @@ pub struct NativeEventBus { theme_changes: Mutex>>>, locale_changes: - Mutex>>>, + Mutex>>>, preimage_changes: Mutex>, storage_changes: Mutex>, chain_events: Mutex, @@ -62,8 +62,8 @@ impl NativeEventBus { /// Stream the locale: `current` first, then every host change. pub fn subscribe_locale( &self, - current: Result, - ) -> BoxStream<'static, Result> { + current: Result, + ) -> BoxStream<'static, Result> { let (tx, rx) = mpsc::unbounded(); self.locale_changes .lock() @@ -73,7 +73,7 @@ impl NativeEventBus { } /// Deliver a host locale change to every live locale subscriber. - pub fn notify_locale_changed(&self, locale: v01::HostLocaleSubscribeItem) { + pub fn notify_locale_changed(&self, locale: crate::latest::HostLocaleSubscribeItem) { self.locale_changes .lock() .expect("native locale subscribers mutex poisoned") diff --git a/rust/crates/truapi/src/native/platform.rs b/rust/crates/truapi/src/native/platform.rs index 871775d3e..302e07613 100644 --- a/rust/crates/truapi/src/native/platform.rs +++ b/rust/crates/truapi/src/native/platform.rs @@ -458,16 +458,24 @@ impl ThemeHost for CallbackPlatform { } } +#[async_trait] impl LocaleHost for CallbackPlatform { fn subscribe_locale( &self, - ) -> BoxStream<'static, Result> { + ) -> BoxStream<'static, Result> { let current = self .callbacks .current_locale() .map_err(v01::GenericError::from); self.events.subscribe_locale(current) } + + async fn localize_timestamps( + &self, + request: crate::latest::HostLocaleLocalizeTimestampsRequest, + ) -> Result { + self.callbacks.localize_timestamps(request).await.map_err(Into::into) + } } impl PreimageHost for CallbackPlatform { diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index 5d62ff1a5..f0e272a5b 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -32,15 +32,15 @@ use super::config::{ ProductExecutionConfig, }; use super::errors::{HostRejection, NativeCoreDatabaseError}; -use super::executor::shared_native_executor; use super::events::NativeEventBus; +use super::executor::shared_native_executor; +#[cfg(doc)] +use super::parse_pairing_deeplink; use super::platform::{ CallbackPlatform, ChatCallbackPlatform, ContactsCallbackPlatform, PocketCallbackPlatform, }; #[cfg(doc)] use crate::WorkerTransition; -#[cfg(doc)] -use super::parse_pairing_deeplink; /// Process-owned native TrUAPI runtime shared by all executable connections. #[derive(uniffi::Object)] @@ -630,6 +630,7 @@ impl NativeProductExecution { crate::host_core::ConnectionAdapters { platform: self.platform.clone(), chat_platform: self.chat.clone(), + contacts_platform: None, permission_status: Some(self.permission_status.clone()), permission_grants: self.permission_grants.clone(), chat: self.chat_connection.clone(), @@ -779,7 +780,7 @@ impl NativeProductExecution { } /// Push a host locale replacement to this execution's subscriptions. - pub fn notify_locale_changed(&self, locale: v01::HostLocaleSubscribeItem) { + pub fn notify_locale_changed(&self, locale: crate::latest::HostLocaleSubscribeItem) { self.events.notify_locale_changed(locale); } diff --git a/rust/crates/truapi/src/native/tests.rs b/rust/crates/truapi/src/native/tests.rs index a9c2b0c6b..41bf4907a 100644 --- a/rust/crates/truapi/src/native/tests.rs +++ b/rust/crates/truapi/src/native/tests.rs @@ -188,7 +188,7 @@ pub struct EventCallbacks { pub pocket_cards: Mutex>, pub pocket_removed: Mutex>, pub theme: Mutex, - pub locale: Mutex, + pub locale: Mutex, pub preimages: Mutex, pub auth_states: Mutex>, pub chain_id: Mutex>, @@ -240,7 +240,8 @@ impl EventCallbacks { name: v01::ThemeName::Default, variant: v01::ThemeVariant::Light, }), - locale: Mutex::new(v01::HostLocaleSubscribeItem { + locale: Mutex::new(crate::latest::HostLocaleSubscribeItem { + time_zone: None, language_tag: "en".to_string(), }), preimages: Mutex::new(Vec::new()), @@ -396,9 +397,15 @@ impl HostCallbacks for EventCallbacks { fn current_theme(&self) -> Result { Ok(self.theme.lock().expect("theme mutex poisoned").clone()) } - fn current_locale(&self) -> Result { + fn current_locale(&self) -> Result { Ok(self.locale.lock().expect("locale mutex poisoned").clone()) } + async fn localize_timestamps( + &self, + _request: crate::latest::HostLocaleLocalizeTimestampsRequest, + ) -> Result { + Err(HostRejection::Rejected { reason: "Local time conversion is unavailable".into() }) + } async fn feature_supported( &self, _request: v01::HostFeatureSupportedRequest, @@ -1138,7 +1145,8 @@ fn native_auth_presenter_forwards_states_across_the_ffi_mirror() { fn native_locale_subscription_emits_current_then_notified_changes() { let (callbacks, events, platform) = event_platform(); let mut stream = platform.subscribe_locale(); - let switched = v01::HostLocaleSubscribeItem { + let switched = crate::latest::HostLocaleSubscribeItem { + time_zone: Some("Asia/Shanghai".to_string()), language_tag: "zh-Hans".to_string(), }; @@ -1149,7 +1157,8 @@ fn native_locale_subscription_emits_current_then_notified_changes() { assert_eq!( first.unwrap(), - v01::HostLocaleSubscribeItem { + crate::latest::HostLocaleSubscribeItem { + time_zone: None, language_tag: "en".to_string(), } ); @@ -2004,11 +2013,18 @@ fn start_ws_bridge_twice_returns_already_running() { variant: v01::ThemeVariant::Light, }) } - fn current_locale(&self) -> Result { - Ok(v01::HostLocaleSubscribeItem { + fn current_locale(&self) -> Result { + Ok(crate::latest::HostLocaleSubscribeItem { + time_zone: None, language_tag: "en".to_string(), }) } + async fn localize_timestamps( + &self, + _request: crate::latest::HostLocaleLocalizeTimestampsRequest, + ) -> Result { + Err(HostRejection::Rejected { reason: "Local time conversion is unavailable".into() }) + } async fn feature_supported( &self, _request: v01::HostFeatureSupportedRequest, @@ -2186,11 +2202,18 @@ fn pending_permission_decision_does_not_stall_bridge() { variant: v01::ThemeVariant::Light, }) } - fn current_locale(&self) -> Result { - Ok(v01::HostLocaleSubscribeItem { + fn current_locale(&self) -> Result { + Ok(crate::latest::HostLocaleSubscribeItem { + time_zone: None, language_tag: "en".to_string(), }) } + async fn localize_timestamps( + &self, + _request: crate::latest::HostLocaleLocalizeTimestampsRequest, + ) -> Result { + Err(HostRejection::Rejected { reason: "Local time conversion is unavailable".into() }) + } async fn feature_supported( &self, _request: v01::HostFeatureSupportedRequest, diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index 50a335f55..1fa600fa8 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -3176,10 +3176,19 @@ pub trait ThemeHost: Send + Sync { } /// Host locale source. +#[async_trait] pub trait LocaleHost: Send + Sync { /// Emits the currently selected locale immediately, then future changes. fn subscribe_locale(&self) -> BoxStream<'static, Result>; + + /// Convert a bounded UTC batch using the supplied host locale snapshot. + async fn localize_timestamps( + &self, + _request: crate::latest::HostLocaleLocalizeTimestampsRequest, + ) -> Result { + Err(GenericError { reason: "Local time conversion is unavailable".into() }) + } } /// Host preimage backend. The core builds, signs, and submits the Bulletin diff --git a/rust/crates/truapi/src/platform/mock.rs b/rust/crates/truapi/src/platform/mock.rs index 74a24ade1..41897dc72 100644 --- a/rust/crates/truapi/src/platform/mock.rs +++ b/rust/crates/truapi/src/platform/mock.rs @@ -1261,6 +1261,7 @@ impl LocaleHost for MockPlatform { ) -> BoxStream<'static, Result> { let item = latest::HostLocaleSubscribeItem { language_tag: self.config.language_tag.clone(), + time_zone: None, }; Box::pin( stream::once(async move { diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index eeef29013..862d80aaf 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -75,6 +75,7 @@ pub use signing_host::{ establish_pairing, notify_pairing_allowance_allocation, notify_pairing_failed, respond_to_pairing, resume_pairing, }; +pub use signing_host::{LocalIdentity, LocalIdentityContext, WalletAllowanceSnapshot}; #[cfg(all(target_arch = "wasm32", feature = "test-host"))] pub use vrf::ring_vrf_member; // `TrackedStatementRenewalTarget` is only read back by the native renewal @@ -296,6 +297,7 @@ pub struct ProductRuntimeHost { services: Arc, platform: Arc, chat_platform: Option>, + contacts_platform: Option>, /// Live OS permission state for this connection, when the host serves it. permission_status: Option>, /// Permission requests and consuming operations can arrive on different connections. @@ -345,6 +347,7 @@ impl ProductRuntimeHost { services, platform: adapters.platform, chat_platform: adapters.chat_platform, + contacts_platform: adapters.contacts_platform, permission_status: adapters.permission_status, temporary_permissions: adapters.permission_grants, authority, @@ -474,6 +477,7 @@ impl ProductRuntimeHost { services, platform, chat_platform: None, + contacts_platform: None, permission_status: None, temporary_permissions: Arc::default(), authority: pairing_host.clone(), @@ -1346,8 +1350,9 @@ impl ProductRuntimeHost { // A capability the host does not serve is a framework answer; a // missing session is one the product handles. let platform = self - .services - .contacts_platform() + .contacts_platform + .clone() + .or_else(|| self.services.contacts_platform()) .ok_or(CallError::Unsupported)?; let session = self .authority diff --git a/rust/crates/truapi/src/runtime/bulletin_rpc.rs b/rust/crates/truapi/src/runtime/bulletin_rpc.rs index 8d42fd6b2..8a5b2fa93 100644 --- a/rust/crates/truapi/src/runtime/bulletin_rpc.rs +++ b/rust/crates/truapi/src/runtime/bulletin_rpc.rs @@ -1,9 +1,9 @@ //! In-core Bulletin preimage submission over the shared Subxt client. //! //! One submission at a time: build + sign the `TransactionStorage.store` -//! extrinsic against the current best block (Subxt resolves metadata, nonce, -//! and the mortality anchor), dry-run it via Subxt's typed transaction -//! validation, then submit through Subxt's transaction watch and classify the +//! extrinsic using current best-block metadata and nonce with a finalized +//! mortality anchor, dry-run it via Subxt's typed transaction validation, then +//! submit through Subxt's transaction watch and classify the //! dispatch outcome from the inclusion block's events. use std::sync::Mutex as StdMutex; @@ -14,7 +14,8 @@ use web_time::{Duration, Instant}; use crate::chain_runtime::{ChainRuntime, RuntimeFailure}; use crate::host_internal::bulletin::{ - STORE_PALLET_NAME, allowance_signer, build_signed_store_transaction, preimage_key, + MORTAL_PERIOD_BLOCKS, STORE_PALLET_NAME, allowance_signer, build_signed_store_transaction, + preimage_key, }; use crate::host_internal::extrinsic::Sr25519Signer; use crate::runtime::BulletinAllowanceKey; @@ -126,6 +127,9 @@ pub enum BulletinSubmitError { /// The runtime could not determine transaction validity. #[display("unknown transaction validity: {_0:?}")] UnknownTransaction(#[error(not(source))] TransactionUnknown), + /// Finality has fallen outside the fixed mortality window. + #[display("Bulletin finalized checkpoint {finalized} is too old for best block {best}")] + MortalityCheckpointTooOld { finalized: u64, best: u64 }, /// The allowance account was rejected; refresh + one retry may help. #[display("allowance rejected: {phase}")] AllowanceRejected { phase: AllowanceRejectionPhase }, @@ -244,6 +248,11 @@ impl BulletinRpc { } } + /// The chain used by the allowance reader and submission service. + pub(crate) fn genesis_hash(&self) -> [u8; 32] { + self.genesis_hash + } + /// Open a raw RPC client over the configured Bulletin chain. pub async fn client( &self, @@ -423,7 +432,25 @@ impl BulletinRpc { .at() .await .map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; - let signed = build_signed_store_transaction(&at_block, signer, value) + let finalized = at_block + .online_client() + .at_current_block() + .await + .map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; + // Finality can advance while the previously selected best block + // is being read. Never sign against state older than the anchor. + let signing_state = if finalized.block_number() >= at_block.block_number() { + &finalized + } else { + &at_block + }; + if signing_state.block_number() - finalized.block_number() >= MORTAL_PERIOD_BLOCKS { + return Err(BulletinSubmitError::MortalityCheckpointTooOld { + finalized: finalized.block_number(), + best: signing_state.block_number(), + }); + } + let signed = build_signed_store_transaction(signing_state, &finalized, signer, value) .await .map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; @@ -921,7 +948,10 @@ mod tests { mod orchestration { use super::*; use crate::chain_runtime::{RuntimeChainProvider, RuntimeFailure}; - use crate::host_internal::extrinsic::tests::{bulletin_runtime_call, system_events}; + use crate::host_internal::extrinsic::tests::{ + OfflineChainState, bulletin_chain_state, bulletin_runtime_call, split_v4, system_events, + }; + use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; use crate::platform::JsonRpcConnection; use crate::subscription::thread_per_subscription_spawner; use async_trait::async_trait; @@ -929,9 +959,14 @@ mod tests { use futures::channel::mpsc; use futures::stream::BoxStream; use parity_scale_codec::{Compact, Encode}; + use parking_lot::Mutex; + use schnorrkel::{PublicKey, Signature}; use serde_json::{Value as JsonValue, json}; use std::collections::VecDeque; - use std::sync::{Arc, Mutex}; + use std::sync::Arc; + use subxt::config::DefaultExtrinsicParamsBuilder; + use subxt::ext::scale_value::Value as ScaleValue; + use subxt::utils::H256; const FOLLOW_ID: &str = "bulletin-follow"; const BLOCK_HASH: &str = @@ -963,6 +998,7 @@ mod tests { next_transaction: usize, next_best_block: usize, current_best_hash: String, + best_block_number: Option, advance_best_block_after_rejection: bool, stall_headers: bool, last_transaction: Option, @@ -995,6 +1031,7 @@ mod tests { next_transaction: 0, next_best_block: 0, current_best_hash: BLOCK_HASH.to_string(), + best_block_number: None, advance_best_block_after_rejection: false, stall_headers, last_transaction: None, @@ -1012,13 +1049,23 @@ mod tests { self } + fn with_best_block(self, number: u32) -> Self { + { + let mut state = self.state.lock(); + state.best_block_number = Some(number); + state.next_best_block = 1; + state.current_best_hash = scripted_block_hash(1); + } + self + } + fn with_validation_outcomes( self, outcomes: impl IntoIterator, advance_best_block_after_rejection: bool, ) -> Self { { - let mut state = self.state.lock().unwrap(); + let mut state = self.state.lock(); state.validation_outcomes = outcomes.into_iter().collect(); state.advance_best_block_after_rejection = advance_best_block_after_rejection; } @@ -1026,9 +1073,7 @@ mod tests { } fn method_count(&self, method: &str) -> usize { - self.sent - .lock() - .unwrap() + self.sent.lock() .iter() .filter(|request| { serde_json::from_str::(request) @@ -1046,9 +1091,7 @@ mod tests { } fn submitted_transactions(&self) -> Vec { - self.sent - .lock() - .unwrap() + self.sent.lock() .iter() .filter_map(|request| { let value: JsonValue = serde_json::from_str(request).ok()?; @@ -1061,9 +1104,7 @@ mod tests { } fn runtime_call_count(&self, runtime_method: &str) -> usize { - self.sent - .lock() - .unwrap() + self.sent.lock() .iter() .filter(|request| { serde_json::from_str::(request) @@ -1097,10 +1138,10 @@ mod tests { impl JsonRpcConnection for BulletinScriptedConnection { fn send(&self, request: String) { - self.sent.lock().unwrap().push(request.clone()); + self.sent.lock().push(request.clone()); let frames = - scripted_frames(&request, &self.events, &mut self.state.lock().unwrap()); - if let Some(sender) = self.sender.lock().unwrap().as_ref() { + scripted_frames(&request, &self.events, &mut self.state.lock()); + if let Some(sender) = self.sender.lock().as_ref() { for frame in frames { sender.unbounded_send(frame).unwrap(); } @@ -1108,16 +1149,14 @@ mod tests { } fn responses(&self) -> BoxStream<'static, String> { - self.receiver - .lock() - .unwrap() + self.receiver.lock() .take() .expect("responses called once") .boxed() } fn close(&self) { - self.sender.lock().unwrap().take(); + self.sender.lock().take(); } } @@ -1131,7 +1170,7 @@ mod tests { state: self.state.clone(), sent: self.sent.clone(), sender: self.sender.clone(), - receiver: Mutex::new(self.receiver.lock().unwrap().take()), + receiver: Mutex::new(self.receiver.lock().take()), events: self.events.clone(), })) } @@ -1154,16 +1193,38 @@ mod tests { }; match method { - "chainHead_v1_follow" => vec![ - response(json!(FOLLOW_ID)), - follow_event(json!({ - "event": "initialized", - "finalizedBlockHashes": [BLOCK_HASH], - "finalizedBlockRuntime": null - })), - ], + "chainHead_v1_follow" => { + let mut frames = vec![ + response(json!(FOLLOW_ID)), + follow_event(json!({ + "event": "initialized", + "finalizedBlockHashes": [BLOCK_HASH], + "finalizedBlockRuntime": null + })), + ]; + if state.best_block_number.is_some() { + frames.push(follow_event(json!({ + "event": "newBlock", + "blockHash": state.current_best_hash, + "parentBlockHash": BLOCK_HASH, + "newRuntime": null + }))); + frames.push(follow_event(json!({ + "event": "bestBlockChanged", + "bestBlockHash": state.current_best_hash + }))); + } + frames + } "chainHead_v1_header" if state.stall_headers => Vec::new(), - "chainHead_v1_header" => vec![response(json!(encoded_header()))], + "chainHead_v1_header" => { + let number = if request["params"][1] == BLOCK_HASH { + 1 + } else { + state.best_block_number.unwrap_or(1) + }; + vec![response(json!(encoded_header(number)))] + } "chainHead_v1_call" => { state.next_operation += 1; let operation_id = format!("call-{}", state.next_operation); @@ -1177,7 +1238,14 @@ mod tests { } else { ValidationOutcome::Valid }; - let output = runtime_call_output(runtime_method, validation_outcome); + let output = if runtime_method == "AccountNonceApi_account_nonce" + && state.best_block_number.is_some() + && request["params"][1] == state.current_best_hash + { + 7u32.encode() + } else { + runtime_call_output(runtime_method, validation_outcome) + }; let mut frames = vec![ response(json!({"result": "started", "operationId": operation_id})), follow_event(json!({ @@ -1312,10 +1380,10 @@ mod tests { } } - fn encoded_header() -> String { + fn encoded_header(number: u32) -> String { let mut bytes = Vec::new(); [0u8; 32].encode_to(&mut bytes); - Compact(1u32).encode_to(&mut bytes); + Compact(number).encode_to(&mut bytes); [0u8; 32].encode_to(&mut bytes); [0u8; 32].encode_to(&mut bytes); Vec::::new().encode_to(&mut bytes); @@ -1405,6 +1473,78 @@ mod tests { assert_eq!(provider.method_count("chainHead_v1_storage"), 1); } + #[test] + fn submit_uses_best_nonce_with_finalized_mortality_checkpoint() { + let provider = Arc::new( + BulletinScriptedProvider::new([TransactionOutcome::Included]).with_best_block(2), + ); + let value = b"survives an abandoned best-block checkpoint"; + let key = futures::executor::block_on(rpc(provider.clone()).submit_preimage( + &CallContext::default(), + Instant::now() + Duration::from_secs(2), + &allowance_fixture(), + value, + )) + .unwrap(); + assert_eq!(key, preimage_key(value)); + + let transactions = provider.submitted_transactions(); + assert_eq!(transactions.len(), 1); + let encoded = hex::decode(transactions[0].trim_start_matches("0x")).unwrap(); + let (account, signature, _) = split_v4(&encoded); + let state = OfflineChainState { + genesis_hash: [0x42; 32], + spec_version: 1, + transaction_version: 1, + ..bulletin_chain_state() + }; + let client = state.client_at(2).unwrap(); + let params = DefaultExtrinsicParamsBuilder::::new() + .nonce(7) + .mortal_from_unchecked(MORTAL_PERIOD_BLOCKS, 1, H256([0x11; 32])) + .build(); + let payload = subxt::dynamic::tx( + STORE_PALLET_NAME, + "store", + vec![ScaleValue::from_bytes(value)], + ); + let expected = client + .tx() + .create_v4_signable_offline(&payload, params) + .unwrap() + .signer_payload() + .unwrap(); + // This signature cannot verify with the stale finalized nonce (0) + // or the unfinalized best hash, even though dry-run accepted both. + PublicKey::from_bytes(&account) + .unwrap() + .verify_simple( + SR25519_SIGNING_CONTEXT, + &expected, + &Signature::from_bytes(&signature).unwrap(), + ) + .unwrap(); + } + + #[test] + fn submit_refuses_a_checkpoint_outside_the_mortality_window() { + let provider = Arc::new(BulletinScriptedProvider::new([]).with_best_block(65)); + let result = futures::executor::block_on(rpc(provider.clone()).submit_preimage( + &CallContext::default(), + Instant::now() + Duration::from_secs(2), + &allowance_fixture(), + b"expired checkpoint", + )); + assert!(matches!( + result, + Err(BulletinSubmitError::MortalityCheckpointTooOld { + finalized: 1, + best: 65 + }) + )); + assert!(provider.submitted_transactions().is_empty()); + } + #[test] fn submit_preimage_rebuilds_once_after_node_rejection() { let provider = Arc::new(BulletinScriptedProvider::new([ diff --git a/rust/crates/truapi/src/runtime/capabilities/account.rs b/rust/crates/truapi/src/runtime/capabilities/account.rs index 642634a99..bd5f08a87 100644 --- a/rust/crates/truapi/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi/src/runtime/capabilities/account.rs @@ -29,11 +29,12 @@ use truapi::{CallContext, CallError, Subscription, latest, v01}; use crate::host_internal::product_manifest::Granted; use crate::host_internal::sso_messages::ProductRequest; +use crate::runtime::authority::AuthorityError; use crate::runtime::{ ProductRuntimeHost, account_access_authorization, account_get_authority_error, remote_authority_call, remote_authority_context, ring_vrf_alias_error, ring_vrf_list_error, - ring_vrf_proof_error, ring_vrf_register_error, ring_vrf_sign_error, until_cancelled, - validate_vrf_transcript, vrf_call_error, + ring_vrf_proof_error, ring_vrf_register_error, ring_vrf_sign_error, validate_vrf_transcript, + vrf_call_error, }; #[truapi::async_trait] @@ -81,32 +82,48 @@ impl Account for ProductRuntimeHost { }); } } - } else if self - .authority - .subtree_resolution_reaches_account_holder( - &session, - &product_account_id.dot_ns_identifier, - ) - .await - { - // Own-account resolution has no access review, so a cold subtree - // that must reach the Account Holder is the one point a host can - // surface and reject before the SSO call. - let approved = until_cancelled( - cx, - self.confirm_product_action(UserConfirmationReview::ProductSubtree( - ProductSubtreeReview { - product_id: product_account_id.dot_ns_identifier.clone(), - }, - )), - ) + } else { + // Own-account resolution walks two host callbacks before the + // bounded SSO call: a persisted subtree read, then a confirmation. + // Neither had a deadline, so a host that never answered its own + // storage parked the request forever, with no response and no + // error frame (host-rust-core#954). Both are bounded by the + // caller's context, falling back to the same default the SSO call + // uses, so an unresponsive host surfaces a typed error instead. + let authority_cx = remote_authority_context(cx); + let reaches_account_holder = remote_authority_call(&authority_cx, async { + Ok::<_, AuthorityError>( + self.authority + .subtree_resolution_reaches_account_holder( + &session, + &product_account_id.dot_ns_identifier, + ) + .await, + ) + }) .await - .map_err(account_get_authority_error)? - .map_err(|err| CallError::HostFailure { reason: err.reason })?; - if !approved { - return Err(CallError::Domain(HostAccountGetError::V1( - v01::HostAccountGetError::Rejected, - ))); + .map_err(account_get_authority_error)?; + + if reaches_account_holder { + // Own-account resolution has no access review, so a cold + // subtree that must reach the Account Holder is the one point + // a host can surface and reject before the SSO call. + let approved = remote_authority_call(&authority_cx, async { + self.confirm_product_action(UserConfirmationReview::ProductSubtree( + ProductSubtreeReview { + product_id: product_account_id.dot_ns_identifier.clone(), + }, + )) + .await + .map_err(|err| AuthorityError::Unavailable { reason: err.reason }) + }) + .await + .map_err(account_get_authority_error)?; + if !approved { + return Err(CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::Rejected, + ))); + } } } diff --git a/rust/crates/truapi/src/runtime/capabilities/platform.rs b/rust/crates/truapi/src/runtime/capabilities/platform.rs index f7e2d2346..40b19e3e8 100644 --- a/rust/crates/truapi/src/runtime/capabilities/platform.rs +++ b/rust/crates/truapi/src/runtime/capabilities/platform.rs @@ -13,6 +13,8 @@ use truapi::versioned::local_storage::{ }; use truapi::versioned::locale::{ HostLocaleSubscribeError, HostLocaleSubscribeItem, HostLocaleSubscribeRequest, + HostLocaleLocalizeTimestampsError, HostLocaleLocalizeTimestampsRequest, + HostLocaleLocalizeTimestampsResponse, }; use truapi::versioned::notifications::{ HostPushNotificationCancelError, HostPushNotificationCancelRequest, @@ -400,7 +402,7 @@ impl Locale for ProductRuntimeHost { _request: HostLocaleSubscribeRequest, ) -> Subscription> { let stream = self.platform.subscribe_locale().map(|item| match item { - Ok(item) => Ok(HostLocaleSubscribeItem::V1(item)), + Ok(item) => Ok(HostLocaleSubscribeItem::V2(item)), Err(error) => { warn!(reason = %error.reason, "locale platform stream failed"); Err(CallError::HostFailure { @@ -410,6 +412,34 @@ impl Locale for ProductRuntimeHost { }); Subscription::new(stream) } + + #[instrument(skip_all, fields(runtime.method = "locale.localize_timestamps"))] + async fn localize_timestamps( + &self, + _cx: &CallContext, + request: HostLocaleLocalizeTimestampsRequest, + ) -> Result> { + let request = request.into_latest(); + if request.timestamps_ms.len() > 128 + || request.timestamps_ms.iter().any(|timestamp| *timestamp > 253_402_300_799_999) + || request.language_tag.is_empty() + || request.time_zone.is_empty() + { + return Err(CallError::Domain(HostLocaleLocalizeTimestampsError::V1( + truapi::latest::GenericError { reason: "Invalid local time conversion request".into() }, + ))); + } + let count = request.timestamps_ms.len(); + let response = self.platform.localize_timestamps(request).await.map_err(|error| { + CallError::Domain(HostLocaleLocalizeTimestampsError::V1(error)) + })?; + if response.timestamps.len() != count { + return Err(CallError::HostFailure { + reason: "Host returned an incomplete local time conversion".into(), + }); + } + Ok(HostLocaleLocalizeTimestampsResponse::V1(response)) + } } // `Notifications` delegates to the platform so hosts can own scheduling and diff --git a/rust/crates/truapi/src/runtime/identity.rs b/rust/crates/truapi/src/runtime/identity.rs index a986337d1..4e667be55 100644 --- a/rust/crates/truapi/src/runtime/identity.rs +++ b/rust/crates/truapi/src/runtime/identity.rs @@ -16,7 +16,7 @@ use web_time::Duration; use crate::chain_runtime::ChainRuntime; use crate::host_logic::dotns_gateway::{ - DotnsIdentity, classify_labels, discover_pop_controller, resolve_labels, + DotnsIdentity, DotnsTransport, classify_labels, discover_pop_controller, resolve_labels, }; use crate::host_logic::session::SessionInfo; use crate::runtime::dotns_lookup::DotnsLookup; @@ -175,6 +175,76 @@ async fn lookup_dotns_identity( lookup.await } +/// Resolve the account's Lite identity, requiring gateway ownership as well as PoP provenance. +pub(super) async fn lookup_local_identity( + chain: &ChainRuntime, + genesis: [u8; 32], + account: [u8; 32], +) -> Result { + let operation = async { + let mut lookup = DotnsLookup::pinned_to_best_block( + chain, + genesis, + &format!("local-identity:{}", hex::encode(account)), + ) + .await?; + let controller = discover_pop_controller(&mut lookup) + .await? + .ok_or("dotNS gateway is not deployed on the configured Asset Hub")?; + let labels = resolve_labels(&mut lookup, &controller, &account).await?; + let mut owned = Vec::new(); + for label in labels { + if !crate::host_logic::dotns_gateway::is_dotted_lite_username(&label) { + continue; + } + let owner = lookup + .storage(crate::host_logic::dotns_gateway::lite_label_owner_key( + label.as_bytes(), + )) + .await?; + let Some(owner) = owner else { continue }; + let owner: [u8; 32] = owner + .as_slice() + .try_into() + .map_err(|_| "DotnsGateway.LiteLabelOwner is not a 32-byte account")?; + if owner == account { + owned.push(label); + } + } + classify_labels(&mut lookup, &controller, owned).await + } + .fuse(); + let timeout = futures_timer::Delay::new(LOOKUP_BUDGET).fuse(); + pin_mut!(operation, timeout); + futures::select! { + result = operation => result, + () = timeout => Err("dotNS identity lookup timed out".to_string()), + } +} + +/// Read the reservation timestamp from Asset Hub rather than the browser clock. +pub(super) async fn registration_timestamp( + chain: &ChainRuntime, + genesis: [u8; 32], + account: [u8; 32], +) -> Result { + let mut lookup = DotnsLookup::pinned_to_best_block( + chain, + genesis, + &format!("registration:{}", hex::encode(account)), + ) + .await?; + let value = lookup + .storage(crate::host_logic::dotns_gateway::timestamp_now_key()) + .await? + .ok_or("Timestamp.Now is unset")?; + let millis: [u8; 8] = value + .as_slice() + .try_into() + .map_err(|_| "Timestamp.Now is not a u64")?; + Ok(u64::from_le_bytes(millis) / 1000) +} + #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { //! The in-core lookup drives every dotNS read over one `chainHead_v1` diff --git a/rust/crates/truapi/src/runtime/services.rs b/rust/crates/truapi/src/runtime/services.rs index 000d56b3a..fd3f9f847 100644 --- a/rust/crates/truapi/src/runtime/services.rs +++ b/rust/crates/truapi/src/runtime/services.rs @@ -69,8 +69,7 @@ pub struct RuntimeServices { pub statement_store: StatementStoreRpc, /// In-core Bulletin submission over the configured Bulletin chain. pub bulletin: BulletinRpc, - /// Runtime metadata and chain state shared by the native allowance - /// paths, per chain. + /// Runtime metadata and chain state shared by allowance paths, per chain. pub chain_context: crate::runtime::statement_allowance::ChainContextCache, /// Values from confirmed in-core submissions, served to `lookup_subscribe` /// until the host's content backend has them. Byte-bounded, oldest-first. diff --git a/rust/crates/truapi/src/runtime/signing_host.rs b/rust/crates/truapi/src/runtime/signing_host.rs index a4a8f196f..b0a2e9fa3 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -10,17 +10,16 @@ //! signing, v4 transaction construction (payload fields and extensions arrive //! pre-encoded, so no chain metadata is needed), RFC-0007 product entropy, //! bandersnatch ring-VRF aliases and membership proofs, and product-scoped -//! Statement Store and Bulletin allowance keys (native only). - -// Allocation uses `track`; the renewal loop around it is driven by native -// entry points only, so on wasm the rest of the module is not reached yet. -#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +//! Statement Store allowance keys (native and browser), and Bulletin allowance +//! keys (native only). mod allowance_renewal; mod local_activation; +mod local_identity; pub mod ring_vrf; mod sso_replay; mod sso_responder; mod sso_service; +mod wallet_allowances; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; @@ -30,10 +29,12 @@ use truapi::latest::{ HostAccountRingVrfSignRequest, ProductAccountId, RingLocation, RingLocationJunction, }; +pub use crate::runtime::statement_allowance::inspection::WalletAllowanceSnapshot; pub use allowance_renewal::StatementRenewalTarget; #[cfg(not(target_arch = "wasm32"))] pub use allowance_renewal::TrackedStatementRenewalTarget; pub use local_activation::LocalActivation; +pub use local_identity::{LocalIdentity, LocalIdentityContext}; pub use sso_responder::{ AnnouncedPairing, DevicePairingObserver, MAX_PAIRING_METADATA_CHARS, PairedSsoPeer, PairingProposal, PairingProposalMetadata, ResponderExit, @@ -480,31 +481,6 @@ impl SigningHost { Ok(()) } - /// The product's hard-subtree public key, derived from the active session - /// root. - /// - /// A signing host holds the root, so it derives this rather than asking an - /// Account Holder for it the way a pairing host must, and answers the - /// `ProductAuthority` request of the same name from the same derivation. - /// `None` when no session is active: there is no root to derive from. - pub fn derive_subtree_public_key( - &self, - product_id: &str, - ) -> Result, AuthorityError> { - let product_id = normalize_product_identifier(product_id).map_err(|err| { - AuthorityError::Unavailable { - reason: err.to_string(), - } - })?; - let Ok(entropy) = self.root_entropy() else { - return Ok(None); - }; - let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; - let subtree = - derive_product_subtree_keypair(&root, &product_id).map_err(product_authority_error)?; - Ok(Some(subtree.public.to_bytes())) - } - /// Derive the product-account keypair for `account` from the root entropy. /// /// The root keypair is recomputed per call (PBKDF2, 2048 rounds, via @@ -962,6 +938,17 @@ impl ProductAuthority for SigningHost { self.current_local_session() } + async fn refresh_session_identity(&self) -> Option { + let context = self.local_identity_context().ok()?; + if let Err(error) = self.refresh_local_identity(&context.activation_id).await { + tracing::warn!(reason = %error.reason, "local dotNS identity refresh failed"); + } + if self.local_identity_context().ok()?.activation_id != context.activation_id { + return None; + } + self.current_local_session() + } + fn session_state(&self) -> Arc { SigningHost::session_state(self) } diff --git a/rust/crates/truapi/src/runtime/signing_host/allowance_renewal.rs b/rust/crates/truapi/src/runtime/signing_host/allowance_renewal.rs index 754a2e33e..b841f371e 100644 --- a/rust/crates/truapi/src/runtime/signing_host/allowance_renewal.rs +++ b/rust/crates/truapi/src/runtime/signing_host/allowance_renewal.rs @@ -6,32 +6,50 @@ //! the active session and runs the chain-pure pass in //! `statement_allowance::renewal`, either once (`renew_now`) or on a periodic //! tick (`start_renewal_loop`). +//! +//! All signing hosts record the ledger during allocation. The resident renewal +//! driver belongs to the native host API; browser hosts currently allocate on +//! demand without starting that driver. +#[cfg(not(target_arch = "wasm32"))] use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(not(target_arch = "wasm32"))] use std::time::Duration; use crate::platform::{CoreStorage, CoreStorageKey, normalize_product_identifier}; use futures::lock::Mutex; use parity_scale_codec::{Decode, Encode}; -use tracing::{debug, info, warn}; +#[cfg(not(target_arch = "wasm32"))] +use tracing::debug; +#[cfg(any(test, not(target_arch = "wasm32")))] +use tracing::info; +use tracing::warn; use super::SigningHost; +#[cfg(not(target_arch = "wasm32"))] use super::sso_responder::current_unix_secs; -use crate::host_logic::product_account::{ - derive_identity_keypair, derive_root_keypair_from_entropy, derive_sr25519_hard_path, -}; +use crate::host_logic::product_account::derive_root_keypair_from_entropy; +use crate::host_logic::product_account::{derive_identity_keypair, derive_sr25519_hard_path}; +#[cfg(not(target_arch = "wasm32"))] use crate::runtime::RuntimeServices; +#[cfg(not(target_arch = "wasm32"))] use crate::runtime::authority::ProductAuthority; +use crate::runtime::statement_allowance::renewal::ResolvedRenewalTarget; +#[cfg(any(test, not(target_arch = "wasm32")))] +use crate::runtime::statement_allowance::renewal::StatementRenewalReport; +#[cfg(not(target_arch = "wasm32"))] use crate::runtime::statement_allowance::renewal::{ - RenewalChainContext, ResolvedRenewalTarget, StatementRenewalReport, next_tick_delay, - renew_targets, + RenewalChainContext, next_tick_delay, renew_targets, }; +#[cfg(not(target_arch = "wasm32"))] use crate::runtime::statement_allowance::{ self, fetch_chain_state, fetch_metadata, find_including_rings, }; /// Fallback tick delay when the system clock is unusable. +#[cfg(not(target_arch = "wasm32"))] const CLOCK_FAILURE_TICK_DELAY: Duration = Duration::from_secs(3_600); /// A statement-store account the signing host promised to keep renewed. @@ -111,6 +129,7 @@ impl TrackedStatementRenewalTarget { } /// Whether this entry belongs to the identity rooted at `owner`. + #[cfg(any(test, not(target_arch = "wasm32")))] fn is_owned_by(&self, owner: [u8; 32]) -> bool { self.owner.is_none_or(|recorded| recorded == owner) } @@ -133,12 +152,14 @@ pub struct RenewalState { /// Serializes read-modify-write cycles on the ledger so a concurrent /// allocation cannot drop another's entry. ledger_lock: Mutex<()>, + #[cfg(not(target_arch = "wasm32"))] loop_started: AtomicBool, /// The most recent pass, so a host that drives the in-process loop can read /// what it achieved. The loop computes a report on every tick and has no /// caller to hand it to, and exhaustion is the outcome a host most needs to /// act on. A blocking lock rather than an async one: every use is a clone or /// a store with no await in between. + #[cfg(any(test, not(target_arch = "wasm32")))] last_report: std::sync::Mutex>, } @@ -152,6 +173,7 @@ impl RenewalState { } /// Record the pass a host has not seen the return value of. + #[cfg(any(test, not(target_arch = "wasm32")))] fn record_report(&self, report: &StatementRenewalReport) { if let Ok(mut last) = self.last_report.lock() { *last = Some(report.clone()); @@ -160,6 +182,7 @@ impl RenewalState { /// The most recent pass the loop ran. `None` until one has run, which a host /// should read as "not yet" rather than as healthy. + #[cfg(any(test, not(target_arch = "wasm32")))] pub fn last_report(&self) -> Option { self.last_report.lock().ok().and_then(|last| last.clone()) } @@ -218,6 +241,7 @@ async fn track_targets( /// /// Takes the ledger lock so a listing taken while a track or a prune is running /// reports the settled ledger rather than the state it is replacing. +#[cfg(any(test, not(target_arch = "wasm32")))] async fn list_entries( storage: &(impl CoreStorage + ?Sized), ledger_lock: &Mutex<()>, @@ -226,6 +250,7 @@ async fn list_entries( read_entries(storage).await } +#[cfg(any(test, not(target_arch = "wasm32")))] async fn untrack_account( storage: &(impl CoreStorage + ?Sized), ledger_lock: &Mutex<()>, @@ -318,6 +343,38 @@ fn resolve_target( } } +/// Resolve attribution without refreshing, pruning, or rewriting the ledger. +pub async fn inspection_labels( + signing_host: &SigningHost, + entropy: &[u8], + owner: [u8; 32], +) -> Result { + let _guard = signing_host.renewal.ledger_lock().lock().await; + let bytes = signing_host + .platform + .read_core_storage(CoreStorageKey::StatementRenewalTargets) + .await + .map_err(|err| format!("renewal ledger read failed: {}", err.reason))?; + let entries = bytes + .as_deref() + .map(decode_entries) + .transpose()? + .unwrap_or_default(); + let mut labels = crate::runtime::statement_allowance::inspection::AccountLabels::new(); + for entry in entries { + if entry.owner.is_some_and(|recorded| recorded != owner) { + continue; + } + let resolved = resolve_target(entropy, &signing_host.network_suffix, &entry.target)?; + let product = match entry.target { + StatementRenewalTarget::ProductStatementAllowance { product_id } => Some(product_id), + _ => None, + }; + labels.insert(resolved.account_id, (product, resolved.label)); + } + Ok(labels) +} + /// Record `targets` in the ledger under the active identity. pub async fn track( signing_host: &SigningHost, @@ -342,6 +399,7 @@ pub async fn track( /// Reads storage alone. A host that has not unlocked an identity still gets /// the list, which is the case a scheduled task runs in: it wakes, asks what /// its finite slots are spent on, and decides whether to renew at all. +#[cfg(any(test, not(target_arch = "wasm32")))] pub async fn list( signing_host: &SigningHost, ) -> Result, String> { @@ -357,12 +415,14 @@ pub async fn list( /// Pairs with [`list`], which reports each entry's owner as stored: comparing /// the two is how a host tells the entries it will actually renew from the ones /// a pass will prune. +#[cfg(any(test, not(target_arch = "wasm32")))] pub fn active_owner_key(signing_host: &SigningHost) -> Result<[u8; 32], String> { let entropy = signing_host.root_entropy().map_err(|err| err.to_string())?; owner_key(&entropy) } /// Stop renewing one fixed statement account for the active identity. +#[cfg(not(target_arch = "wasm32"))] pub async fn untrack_account_for_signing_host( signing_host: &SigningHost, account_id: &[u8; 32], @@ -382,6 +442,7 @@ pub async fn untrack_account_for_signing_host( /// /// A target is skipped rather than failing the pass: one unusable entry must /// not stop every other target from being renewed. +#[cfg(any(test, not(target_arch = "wasm32")))] fn resolve_targets( entropy: &[u8], network_suffix: &str, @@ -416,6 +477,7 @@ fn resolve_targets( /// The labels are logged here rather than only returned. Every step between this /// and the report can fail, and `run_tick` does not read the report at all, so /// the log is the one place a prune is recorded unconditionally. +#[cfg(any(test, not(target_arch = "wasm32")))] async fn owned_targets( storage: &(impl CoreStorage + ?Sized), ledger_lock: &Mutex<()>, @@ -445,6 +507,7 @@ async fn owned_targets( /// One renewal pass: resolve the ledger against the active session and renew /// every target for the current period. +#[cfg(not(target_arch = "wasm32"))] pub async fn renew_now( services: &Arc, signing_host: &SigningHost, @@ -523,6 +586,7 @@ pub async fn renew_now( /// Spawn the periodic renewal loop; repeated calls are no-ops. The loop holds /// only weak references, so it exits when the owning runtime is dropped. +#[cfg(not(target_arch = "wasm32"))] pub fn start_renewal_loop(services: &Arc, signing_host: &Arc) { if signing_host .renewal @@ -553,6 +617,7 @@ pub fn start_renewal_loop(services: &Arc, signing_host: &Arc, signing_host: &SigningHost) { if signing_host.root_entropy().is_err() { debug!("skipping statement-store renewal tick; no active session"); @@ -569,6 +634,7 @@ async fn run_tick(services: &Arc, signing_host: &SigningHost) { /// Split out from [`run_tick`] so the recording is reachable without a runtime: a /// loop that logged but forgot to record would leave a host unable to see /// exhaustion, and that is exactly the wiring worth a test. +#[cfg(any(test, not(target_arch = "wasm32")))] fn absorb_tick(state: &RenewalState, result: Result) { match result { Ok(report) => { diff --git a/rust/crates/truapi/src/runtime/signing_host/local_identity.rs b/rust/crates/truapi/src/runtime/signing_host/local_identity.rs new file mode 100644 index 000000000..f2906473a --- /dev/null +++ b/rust/crates/truapi/src/runtime/signing_host/local_identity.rs @@ -0,0 +1,202 @@ +//! Wallet-local identity proofs and authoritative dotNS metadata refresh. + +use super::SigningHost; +use crate::host_logic::product_account::derive_lite_person_ring_vrf_entropy; +use crate::host_logic::{attestation, dotns_gateway, features}; +use crate::runtime::{connected_session_ui_info, identity}; +use serde::Serialize; +use truapi::latest::{ChainIdentifier, GenericError}; + +/// Verified metadata for the active network's UID account. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalIdentity { + /// Canonical lowercase 0x-prefixed 32-byte account identifier. + pub identity_account_id: String, + /// Lite username confirmed by direct on-chain account lookup. + #[serde(skip_serializing_if = "Option::is_none")] + pub lite_username: Option, +} + +/// Opaque activation fence used throughout an identity backend handshake. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalIdentityContext { + /// Generation token; not an account identifier or secret. + pub activation_id: String, + /// Canonical account identifier that every result must belong to. + pub identity_account_id: String, +} + +fn error(reason: impl ToString) -> GenericError { + GenericError { + reason: reason.to_string(), + } +} + +impl SigningHost { + /// Capture the current UID account and local activation generation atomically. + pub fn local_identity_context(&self) -> Result { + let state = self + .local_grants + .lock() + .expect("local AutoSigning grant mutex poisoned"); + let account = self + .session_state + .current() + .and_then(|session| session.identity_account_id) + .ok_or_else(|| error("no active local identity"))?; + Ok(LocalIdentityContext { + activation_id: state.activation_generation.to_string(), + identity_account_id: format!("0x{}", hex::encode(account)), + }) + } + + /// Reject identity work from a disconnected or replaced activation. + pub fn check_identity_activation( + &self, + activation_id: &str, + ) -> Result<(), GenericError> { + let state = self + .local_grants + .lock() + .expect("local AutoSigning grant mutex poisoned"); + if activation_id.parse::().ok() != Some(state.activation_generation) + || self.session_state.current().is_none() + { + return Err(error("local identity activation changed")); + } + Ok(()) + } + + async fn identity_asset_hub(&self) -> Result<[u8; 32], GenericError> { + let chains = features::supported_chains(self.platform.as_ref()).await?; + features::genesis_for(&chains, ChainIdentifier::AssetHub) + .ok_or_else(|| error("Asset Hub is not configured")) + } + + /// Sign the backend token challenge without releasing UID secret material. + pub fn local_identity_auth_proof( + &self, + activation_id: &str, + challenge: &[u8], + ) -> Result, GenericError> { + let state = self + .local_grants + .lock() + .expect("local AutoSigning grant mutex poisoned"); + if activation_id.parse::().ok() != Some(state.activation_generation) { + return Err(error("local identity activation changed")); + } + let entropy = self.root_entropy().map_err(error)?; + let (client_id, proof) = + attestation::sign_backend_challenge(&entropy, self.network_suffix(), challenge, b"{}") + .map_err(error)?; + let mut bytes = Vec::with_capacity(96); + bytes.extend_from_slice(&client_id); + bytes.extend_from_slice(&proof); + Ok(bytes) + } + + /// Build shared registration JSON with proofs timestamped at the configured Asset Hub. + pub async fn local_lite_registration_body( + &self, + activation_id: &str, + username_base: &str, + verifier: [u8; 32], + ) -> Result { + if !dotns_gateway::is_registrable_full_label(username_base) + || username_base.len() + 3 > dotns_gateway::MAX_BASE_LABEL_LEN + { + return Err(error( + "Lite username base must contain 6 to 29 lowercase ASCII letters", + )); + } + self.check_identity_activation(activation_id)?; + let genesis = self.identity_asset_hub().await?; + self.check_identity_activation(activation_id)?; + let account = self + .session_state + .current() + .and_then(|session| session.identity_account_id) + .ok_or_else(|| error("no active local identity"))?; + let signed_at = identity::registration_timestamp(&self.services.chain, genesis, account) + .await + .map_err(error)?; + // Loaded before the activation lock: the browser fetches `verifiable` + // on first use, and its operations below are then synchronous. + let vrf = crate::runtime::vrf::load().await.map_err(error)?; + let state = self + .local_grants + .lock() + .expect("local AutoSigning grant mutex poisoned"); + if activation_id.parse::().ok() != Some(state.activation_generation) { + return Err(error("local identity activation changed")); + } + let entropy = self.root_entropy().map_err(error)?; + let vrf_entropy = derive_lite_person_ring_vrf_entropy(&entropy, self.network_suffix()); + let registration = attestation::build_lite_registration_with( + &entropy, + self.network_suffix(), + verifier, + username_base, + None, + signed_at, + vrf.member(&vrf_entropy).map_err(error)?, + |message| { + let signature = vrf + .sign(&vrf_entropy, message) + .map_err(|err| err.to_string())?; + <[u8; 64]>::try_from(signature.as_slice()) + .map_err(|_| format!("ring-VRF signature is {} bytes", signature.len())) + }, + ) + .map_err(error)?; + Ok(registration + .request_body(username_base, None, signed_at) + .to_string()) + } + + /// Install chain-verified Lite metadata only if the local activation remains current. + pub async fn refresh_local_identity( + &self, + activation_id: &str, + ) -> Result { + self.check_identity_activation(activation_id)?; + let genesis = self.identity_asset_hub().await?; + self.check_identity_activation(activation_id)?; + let account = self + .session_state + .current() + .and_then(|session| session.identity_account_id) + .ok_or_else(|| error("no active local identity"))?; + let resolved = identity::lookup_local_identity(&self.services.chain, genesis, account) + .await + .map_err(error)?; + let state = self + .local_grants + .lock() + .expect("local AutoSigning grant mutex poisoned"); + if activation_id.parse::().ok() != Some(state.activation_generation) { + return Err(error("local identity activation changed")); + } + let mut session = self + .session_state + .current() + .ok_or_else(|| error("no active local identity"))?; + if session.identity_account_id != Some(account) { + return Err(error("local identity account changed")); + } + session.lite_username = resolved.lite_username; + session.full_username = resolved.full_username; + let result = LocalIdentity { + identity_account_id: format!("0x{}", hex::encode(account)), + lite_username: session.lite_username.clone(), + }; + let ui_info = connected_session_ui_info(&session); + self.session_state.set_session(session); + drop(state); + self.auth_state.connected(&ui_info); + Ok(result) + } +} diff --git a/rust/crates/truapi/src/runtime/signing_host/wallet_allowances.rs b/rust/crates/truapi/src/runtime/signing_host/wallet_allowances.rs new file mode 100644 index 000000000..3bfd1bd4c --- /dev/null +++ b/rust/crates/truapi/src/runtime/signing_host/wallet_allowances.rs @@ -0,0 +1,273 @@ +//! Trusted wallet-admin inspection; intentionally outside the product protocol. + +use std::collections::HashSet; + +use crate::platform::normalize_product_identifier; +use truapi::latest::{ChainIdentifier, GenericError}; + +use super::{SigningHost, allowance_renewal}; +use crate::host_logic::{ + features, + product_account::{ + derive_product_keypair, derive_root_keypair_from_entropy, derive_sr25519_hard_path, + index_bytes, + }, +}; +use crate::runtime::statement_allowance::{ + inspection::{ + AccountLabels, ActivationGuard, AllowanceSection, ProductAccounts, ReadOnlyChain, + WalletAllowanceSnapshot, + }, + rpc::RpcClient, +}; + +const MAX_PRODUCT_IDS: usize = 32; + +fn error(reason: impl ToString) -> GenericError { + GenericError { + reason: reason.to_string(), + } +} + +fn validate_products(product_ids: &[String]) -> Result<(), GenericError> { + if product_ids.len() > MAX_PRODUCT_IDS { + return Err(error( + "wallet allowance inspection accepts at most 32 product IDs", + )); + } + let mut seen = HashSet::with_capacity(product_ids.len()); + for product in product_ids { + let normalized = normalize_product_identifier(product).map_err(error)?; + if normalized != *product || !seen.insert(product) { + return Err(error( + "wallet allowance product IDs must be unique and canonical", + )); + } + } + Ok(()) +} + +impl SigningHost { + /// Read real capacity and current occupancy, without refresh, proof, claims, + /// registration, renewal, signing, or a write to local storage. + pub async fn get_wallet_allowance_snapshot( + &self, + activation_id: &str, + product_ids: Vec, + ) -> Result { + self.check_identity_activation(activation_id)?; + validate_products(&product_ids)?; + let context = self.local_identity_context()?; + let session = self + .current_local_session() + .ok_or_else(|| error("no active local identity"))?; + let candidates = self + .reserved_person_collection_candidates(&session) + .map_err(error)?; + let entropy = self.root_entropy().map_err(error)?; + let root = derive_root_keypair_from_entropy(&entropy).map_err(error)?; + let owner = root.public.to_bytes(); + if owner != session.public_key { + return Err(error("local identity activation changed")); + } + let mut known_labels = AccountLabels::new(); + let identity = self.identity_keypair().map_err(error)?.public.to_bytes(); + known_labels.insert(identity, (None, "Wallet identity / SSO".to_string())); + let mut products = Vec::with_capacity(product_ids.len()); + for product_id in &product_ids { + let pgas = derive_product_keypair(&root, product_id, index_bytes(0)) + .map_err(error)? + .public + .to_bytes(); + let bulletin = + derive_sr25519_hard_path(&entropy, &["allowance", "bulletin", product_id]) + .map_err(error)? + .public + .to_bytes(); + let statement = + derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id]) + .map_err(error)? + .public + .to_bytes(); + known_labels.insert( + statement, + (Some(product_id.clone()), format!("Product: {product_id}")), + ); + products.push(ProductAccounts { + product_id: product_id.clone(), + pgas, + bulletin, + }); + } + drop(root); + self.check_identity_activation(activation_id)?; + let guard = || { + self.check_identity_activation(activation_id) + .map_err(|error| error.reason) + }; + // The ledger is read strictly. A broken ledger cannot be silently + // represented as no known device/product attribution. + guard().map_err(error)?; + let labels = allowance_renewal::inspection_labels(self, &entropy, owner).await; + guard().map_err(error)?; + drop(entropy); + let labels = labels.map(|mut labels| { + labels.extend(known_labels); + labels + }); + + // Each chain pins one independent finalized block and fetches metadata, + // policies, storage and clock from that block. No shared best-head cache. + let (people, asset_hub, bulletin) = futures::join!( + self.allowance_people(&guard), + self.allowance_asset_hub(&guard), + self.allowance_bulletin(&guard), + ); + guard().map_err(error)?; + let memberships = match &people { + Ok(chain) => chain.memberships(&candidates).await, + Err(reason) => Err(reason.clone()), + }; + guard().map_err(error)?; + let suffix = self.network_suffix.as_bytes(); + let (statement_store, pgas_claims, pgas_balances, bulletin_claims, bulletin_quotas) = futures::join!( + async { + match (&people, &memberships, &labels) { + (Ok(chain), Ok(memberships), Ok(labels)) => chain.section( + chain + .statement(&candidates, memberships, suffix, labels) + .await, + ), + (Err(reason), _, _) | (_, Err(reason), _) | (_, _, Err(reason)) => { + AllowanceSection::unavailable(reason) + } + } + }, + async { + match (&asset_hub, &people, &memberships) { + (Ok(chain), Ok(people), Ok(memberships)) => chain.section( + chain + .pgas_claims(&candidates, memberships, &people.observation, suffix) + .await, + ), + (Err(reason), _, _) | (_, Err(reason), _) | (_, _, Err(reason)) => { + AllowanceSection::unavailable(reason) + } + } + }, + async { + match &asset_hub { + Ok(chain) => chain.section(chain.pgas_balances(&products).await), + Err(reason) => AllowanceSection::unavailable(reason), + } + }, + async { + match (&people, &memberships) { + (Ok(chain), Ok(memberships)) => chain.section( + chain + .bulletin_claims(&candidates, memberships, suffix) + .await, + ), + (Err(reason), _) | (_, Err(reason)) => AllowanceSection::unavailable(reason), + } + }, + async { + match &bulletin { + Ok(chain) => chain.section(chain.bulletin_quotas(&products).await), + Err(reason) => AllowanceSection::unavailable(reason), + } + }, + ); + // A stale activation is a failed request, never a partly-unavailable + // response which might still reveal the previous identity's sections. + guard().map_err(error)?; + Ok(WalletAllowanceSnapshot { + schema_version: 1, + identity_account_id: context.identity_account_id, + network_suffix: self.network_suffix.clone(), + product_ids, + statement_store, + pgas_claims, + pgas_balances, + bulletin_claims, + bulletin_quotas, + }) + } + + async fn allowance_people<'a>( + &self, + guard: ActivationGuard<'a>, + ) -> Result, String> { + guard()?; + let client = self + .services + .statement_store + .chain_client("wallet allowance inspection") + .await; + guard()?; + ReadOnlyChain::open_client(client.map_err(|error| error.to_string())?, guard).await + } + + async fn allowance_asset_hub<'a>( + &self, + guard: ActivationGuard<'a>, + ) -> Result, String> { + guard()?; + let chains = features::supported_chains(self.platform.as_ref()).await; + guard()?; + let genesis = features::genesis_for( + &chains.map_err(|error| error.reason)?, + ChainIdentifier::AssetHub, + ) + .ok_or_else(|| "Asset Hub is not configured".to_string())?; + let client = self + .services + .chain + .rpc_client("wallet allowance inspection", &genesis) + .await; + guard()?; + let rpc = RpcClient::new(subxt_rpcs::RpcClient::new( + client.map_err(|error| error.to_string())?, + )); + ReadOnlyChain::open(rpc, genesis, guard).await + } + + async fn allowance_bulletin<'a>( + &self, + guard: ActivationGuard<'a>, + ) -> Result, String> { + guard()?; + let genesis = self.services.bulletin.genesis_hash(); + if genesis == [0; 32] { + return Err("Bulletin is not configured".to_string()); + } + let client = self + .services + .bulletin + .client("wallet allowance inspection") + .await; + guard()?; + ReadOnlyChain::open( + RpcClient::new(client.map_err(|error| error.to_string())?), + genesis, + guard, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trusted_hints_reject_duplicates_noncanonical_and_unbounded_input() { + assert!(validate_products(&["app.dot".to_string(), "app.dot".to_string()]).is_err()); + assert!(validate_products(&[" App.DOT ".to_string()]).is_err()); + assert!(validate_products(&["".to_string()]).is_err()); + let too_many = (0..=MAX_PRODUCT_IDS) + .map(|index| format!("app{index}.dot")) + .collect::>(); + assert!(validate_products(&too_many).is_err()); + } +} diff --git a/rust/crates/truapi/src/runtime/statement_allowance.rs b/rust/crates/truapi/src/runtime/statement_allowance.rs index 876e453b2..55e003a9d 100644 --- a/rust/crates/truapi/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi/src/runtime/statement_allowance.rs @@ -1,14 +1,19 @@ //! 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 -//! personhood ring membership with the caller's registry-selected ring-VRF key, -//! and submit the resulting unsigned General (v5) extrinsic. Native only -//! (needs the `verifiable` prover and live chain reads). +//! Mirrors how iOS and browser account holders obtain statement-store +//! allowance from the real People chain: build the +//! `Resources.set_statement_store_account` call, prove personhood ring +//! membership with the caller's registry-selected ring-VRF key, and submit the +//! resulting unsigned General (v5) extrinsic. +//! +//! These helpers accept host-backed RPC clients on native and browser targets; +//! the host supplies the chain connections and controls which claims it offers. +//! Opening a direct RPC URL is only available on native targets. pub mod collection; pub mod extension; pub mod extrinsic; +pub mod inspection; mod key_hash; pub mod pgas; pub mod proof; @@ -28,8 +33,6 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; #[cfg(not(target_arch = "wasm32"))] use std::time::Instant; -#[cfg(target_arch = "wasm32")] -use web_time::Instant; use futures::FutureExt; use parity_scale_codec::{Decode, Encode}; @@ -37,6 +40,8 @@ use serde_json::{Value, json}; use sp_crypto_hashing::twox_128; use thiserror::Error; use tracing::{debug, warn}; +#[cfg(target_arch = "wasm32")] +use web_time::Instant; use collection::PersonhoodCollection; use extension::{ChainState, Metadata, MetadataError}; diff --git a/rust/crates/truapi/src/runtime/statement_allowance/extension.rs b/rust/crates/truapi/src/runtime/statement_allowance/extension.rs index d033dc41d..d4e674c65 100644 --- a/rust/crates/truapi/src/runtime/statement_allowance/extension.rs +++ b/rust/crates/truapi/src/runtime/statement_allowance/extension.rs @@ -201,7 +201,7 @@ pub struct Metadata { extension_version: u8, registry: PortableRegistry, storage_values: HashMap<(String, String), u32>, - constants: HashMap<(String, String), Vec>, + constants: HashMap<(String, String), (Vec, u32)>, calls: HashMap, view_functions: HashMap<(String, String), ViewFunctionDef>, view_values: Mutex>, @@ -283,7 +283,7 @@ macro_rules! collect_pallets { for constant in &pallet.constants { constants.insert( (pallet.name.clone(), constant.name.clone()), - constant.value.clone(), + (constant.value.clone(), constant.ty.id), ); } let Some(storage) = &pallet.storage else { @@ -455,7 +455,14 @@ impl Metadata { pub fn constant(&self, pallet: &str, name: &str) -> Option<&[u8]> { self.constants .get(&(pallet.to_string(), name.to_string())) - .map(Vec::as_slice) + .map(|(bytes, _)| bytes.as_slice()) + } + + /// Declared constant type, for strict metadata-aware inspection decoding. + pub(super) fn constant_type(&self, pallet: &str, name: &str) -> Option { + self.constants + .get(&(pallet.to_string(), name.to_string())) + .map(|(_, type_id)| *type_id) } /// Resolve `pallet::call` by name to its `[pallet_index, call_index]` diff --git a/rust/crates/truapi/src/runtime/statement_allowance/inspection.rs b/rust/crates/truapi/src/runtime/statement_allowance/inspection.rs new file mode 100644 index 000000000..997e2d4c0 --- /dev/null +++ b/rust/crates/truapi/src/runtime/statement_allowance/inspection.rs @@ -0,0 +1,1210 @@ +//! Read-only, finalized wallet allowance observations. No allocation or proof submission. + +use std::collections::HashMap; + +use parity_scale_codec::{DecodeAll, Encode}; +use scale_decode::DecodeAsType; +use serde::Serialize; +use serde_json::{Value, json}; +use sp_crypto_hashing::twox_128; + +use super::{ + CollectionCandidate, collection::PersonhoodCollection, extension::Metadata, pgas, proof, ring, + rpc::RpcClient, slot, view, +}; + +const STORAGE_BATCH: usize = 32; +// Bound work even if a runtime advertises an unexpectedly large policy. +const MAX_INSPECTION_SLOTS: u32 = 4096; +const JS_MAX_INTEGER: u64 = 9_007_199_254_740_991; + +/// Finalized chain state used for one observation, not an atomic cross-chain time. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AllowanceObservation { + /// Lowercase hex genesis hash identifying the configured chain. + pub genesis_hash: String, + /// Lowercase hex hash pinning metadata, storage, and runtime calls. + pub block_hash: String, + /// Height of the pinned finalized block. + pub block_number: u32, + /// Runtime version used to decode the pinned state. + pub spec_version: u32, + /// Pinned chain time in Unix seconds, never the browser clock. + pub chain_timestamp: u64, +} + +/// Independent resource result; unavailable data never implies zero capacity. +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "lowercase")] +pub enum AllowanceSection { + /// A complete, decoded observation from the stated chain block. + Available { + /// Provenance for this section's chain-local values. + observation: AllowanceObservation, + /// Resource-specific quantities and policy. + value: T, + }, + /// No capacity can be established for this section. + Unavailable { + /// Diagnostic explaining the failed observation. + reason: String, + }, +} + +impl AllowanceSection { + pub(crate) fn unavailable(reason: impl ToString) -> Self { + Self::Unavailable { + reason: reason.to_string(), + } + } +} + +/// One occupied period slot; recipient attribution may not exist on chain. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AllowanceSlot { + /// Index in this collection's allowance-slot namespace. + pub index: u32, + /// Public recipient when the storage record exposes one. + #[serde(skip_serializing_if = "Option::is_none")] + pub account_id: Option, + /// Product identifier only when native derivation verifies the mapping. + #[serde(skip_serializing_if = "Option::is_none")] + pub product_id: Option, + /// Trusted local device or allocation description, if known. + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + /// On-chain assignment time in Unix seconds, where available. + #[serde(skip_serializing_if = "Option::is_none")] + pub since: Option, +} + +/// Collection-specific occupancy and usable capacity under native allocation policy. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AllowancePool { + /// Personhood collection: `People` or `LitePeople`. + pub collection: String, + /// Finalized membership result: `verified` or `not-found`. + pub membership: &'static str, + /// Whether the current native allocator selects this collection. + pub selected: bool, + /// Runtime policy limit, retained even for a nonmember. + pub limit: u32, + /// Observed occupied slots, including allocations surviving lost membership. + pub used: u32, + /// Saturated unused capacity; zero when membership is absent. + pub remaining: u32, + /// Occupied entries only; missing recipient fields do not mean unused slots. + pub slots: Vec, +} + +/// Period boundaries and distinct collection budgets; pools are not implicitly pooled. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AllowanceClaims { + /// Runtime allowance period containing the pinned chain time. + pub period: u32, + /// Next period boundary in Unix seconds. + pub resets_at: u64, + /// Separately observed collection budgets and allocator selection. + pub pools: Vec, +} + +/// Statement publishing assignments, not message counts or stored-byte usage. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StatementAllowanceSnapshot { + /// Collection budgets and occupied publishing slots for this period. + #[serde(flatten)] + pub claims: AllowanceClaims, + /// Runtime grace window in seconds. + pub grace_seconds: u32, + /// Runtime delay before an existing assignment may be replaced. + pub replacement_cooldown_seconds: u32, +} + +/// Asset Hub claim opportunities, separate from any recipient's PGAS balance. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PgasClaimsSnapshot { + /// Asset Hub period and claim-slot occupancy. + #[serde(flatten)] + pub claims: AllowanceClaims, + /// Runtime-configured PGAS asset identifier as a decimal integer. + pub asset_id: String, + /// Amount per claim in exact decimal base units. + pub claim_amount: String, + /// Separate finalized People block used to verify collection membership. + pub membership_observation: AllowanceObservation, +} + +/// Total asset balance for one explicitly scoped product account. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PgasBalance { + /// Canonical trusted-shell product hint. + pub product_id: String, + /// Native-derived public product account in lowercase hex. + pub account_id: String, + /// Product derivation index; the first inspector supports only zero. + pub derivation_index: u32, + /// Exact decimal base units, or unknown on a failed read; not spendability. + pub balance: Option, + /// Per-account failure explaining an unknown balance. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// PGAS balances and metadata read at the same finalized Asset Hub block. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PgasBalancesSnapshot { + /// Runtime-configured asset identifier as a decimal integer. + pub asset_id: String, + /// Asset metadata precision, absent when metadata cannot be established. + pub decimals: Option, + /// Asset metadata symbol, absent rather than guessed. + pub symbol: Option, + /// One result per requested product; other derivations are outside scope. + pub accounts: Vec, +} + +/// Bulletin authorization for a dedicated product storage key. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BulletinQuota { + /// Canonical trusted-shell product hint. + pub product_id: String, + /// Dedicated Bulletin allowance account, not the PGAS product account. + pub account_id: String, + /// `active`, `expired`, `missing`, or `unavailable` at the source block. + pub status: &'static str, + /// Raw charged-byte counter in exact decimal units; may exceed the quota. + #[serde(skip_serializing_if = "Option::is_none")] + pub bytes_used: Option, + /// Granted byte allowance in exact decimal units. + #[serde(skip_serializing_if = "Option::is_none")] + pub bytes_limit: Option, + /// Saturated byte remainder; not usable after authorization expiry. + #[serde(skip_serializing_if = "Option::is_none")] + pub bytes_remaining: Option, + /// Raw submission counter, which can exceed a soft quota. + #[serde(skip_serializing_if = "Option::is_none")] + pub transactions_used: Option, + /// Granted submission allowance. + #[serde(skip_serializing_if = "Option::is_none")] + pub transactions_limit: Option, + /// Saturated submission remainder; not usable after expiry. + #[serde(skip_serializing_if = "Option::is_none")] + pub transactions_remaining: Option, + /// First Bulletin block at which the authorization is expired. + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at_block: Option, + /// Per-account read or decode failure, distinct from missing authorization. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Independently decoded Bulletin authorizations for the requested products. +#[derive(Debug, Serialize)] +pub struct BulletinQuotasSnapshot { + /// Complete requested scope, including missing or unavailable accounts. + pub accounts: Vec, +} + +/// Public, read-only wallet inspection bound to one native identity activation. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WalletAllowanceSnapshot { + /// DTO schema version, currently one. + pub schema_version: u32, + /// Native-derived identity account in lowercase hex; never key material. + pub identity_account_id: String, + /// Native network derivation suffix, not a display label. + pub network_suffix: String, + /// Validated canonical product hints defining balance and quota scope. + pub product_ids: Vec, + /// People-chain publishing-slot occupancy and policy. + pub statement_store: AllowanceSection, + /// Asset Hub claims with separately pinned People membership provenance. + pub pgas_claims: AllowanceSection, + /// Current product Index(0) balances, independent of claim availability. + pub pgas_balances: AllowanceSection, + /// People-chain storage-claim opportunities under native collection policy. + pub bulletin_claims: AllowanceSection, + /// Bulletin storage authorizations, byte/submission counters, and expiry. + pub bulletin_quotas: AllowanceSection, +} + +pub(crate) type ActivationGuard<'a> = &'a (dyn Fn() -> Result<(), String> + Sync); +pub(crate) type AccountLabels = HashMap<[u8; 32], (Option, String)>; + +/// Only public account identifiers, derived before any network request. +pub(crate) struct ProductAccounts { + pub product_id: String, + pub pgas: [u8; 32], + pub bulletin: [u8; 32], +} + +pub(crate) struct ReadOnlyChain<'a> { + rpc: RpcClient, + metadata: Metadata, + pub observation: AllowanceObservation, + guard: ActivationGuard<'a>, +} + +fn reason(error: impl ToString) -> String { + error.to_string() +} + +async fn checked_call( + rpc: &RpcClient, + guard: ActivationGuard<'_>, + method: &str, + params: Value, +) -> Result { + guard()?; + let result = rpc.call(method, params).await; + guard()?; + result.map_err(reason) +} + +fn hex_bytes(value: &Value) -> Result, String> { + let text = value + .as_str() + .and_then(|s| s.strip_prefix("0x")) + .ok_or_else(|| "RPC result is not 0x-prefixed hex".to_string())?; + hex::decode(text).map_err(reason) +} + +fn hash(value: &Value) -> Result { + let bytes = hex_bytes(value)?; + if bytes.len() != 32 { + return Err("RPC block hash is not 32 bytes".to_string()); + } + Ok(format!("0x{}", hex::encode(bytes))) +} + +fn decode_all(bytes: &[u8], context: &str) -> Result { + T::decode_all(&mut &bytes[..]).map_err(|error| format!("{context}: {error}")) +} + +fn decode_metadata(bytes: &[u8]) -> Result { + // Metadata::decode is also used by allocators. Keep its compatibility + // behaviour there, but inspection must reject trailing/incomplete data. + let _: frame_metadata::RuntimeMetadataPrefixed = decode_all(bytes, "metadata")?; + Metadata::decode(bytes).map_err(reason) +} + +impl<'a> ReadOnlyChain<'a> { + pub(crate) async fn open_client( + client: super::ChainClient, + guard: ActivationGuard<'a>, + ) -> Result { + Self::open(client.rpc, client.configured_genesis_hash, guard).await + } + + pub(crate) async fn open( + rpc: RpcClient, + expected_genesis: [u8; 32], + guard: ActivationGuard<'a>, + ) -> Result { + let genesis = hash(&checked_call(&rpc, guard, "chain_getBlockHash", json!([0])).await?)?; + if genesis != format!("0x{}", hex::encode(expected_genesis)) { + return Err("RPC genesis differs from the configured chain".to_string()); + } + let at = hash(&checked_call(&rpc, guard, "chain_getFinalizedHead", json!([])).await?)?; + let header = checked_call(&rpc, guard, "chain_getHeader", json!([at])).await?; + let number = header + .get("number") + .and_then(Value::as_str) + .and_then(|s| s.strip_prefix("0x")) + .ok_or_else(|| "finalized header has no hex block number".to_string())?; + let block_number = u32::from_str_radix(number, 16).map_err(reason)?; + let runtime = checked_call(&rpc, guard, "state_getRuntimeVersion", json!([at])).await?; + let spec_version = runtime + .get("specVersion") + .and_then(Value::as_u64) + .and_then(|n| u32::try_from(n).ok()) + .ok_or_else(|| "runtime has no valid specVersion".to_string())?; + let versioned = checked_call( + &rpc, + guard, + "state_call", + json!([ + "Metadata_metadata_at_version", + format!("0x{}", hex::encode(16u32.encode())), + at + ]), + ) + .await; + let metadata = match versioned { + Ok(value) => { + let bytes = hex_bytes(&value)?; + match decode_all::>>(&bytes, "Metadata_metadata_at_version")? { + Some(bytes) => decode_metadata(&bytes)?, + None => Self::legacy_metadata(&rpc, guard, &at).await?, + } + } + // Older runtimes do not implement the metadata runtime API. The + // fallback still names this exact block; malformed success is never + // accepted or reinterpreted as absence. + Err(_) => Self::legacy_metadata(&rpc, guard, &at).await?, + }; + let mut chain = Self { + rpc, + metadata, + guard, + observation: AllowanceObservation { + genesis_hash: genesis, + block_hash: at, + block_number, + spec_version, + chain_timestamp: 0, + }, + }; + let bytes = chain + .storage(slot::timestamp_now_key()) + .await? + .ok_or_else(|| "Timestamp.Now is absent at the finalized block".to_string())?; + chain.observation.chain_timestamp = + chain.decode_storage::("Timestamp", "Now", &bytes)? / 1000; + if chain.observation.chain_timestamp > JS_MAX_INTEGER { + return Err("chain timestamp exceeds the snapshot integer range".to_string()); + } + Ok(chain) + } + + async fn legacy_metadata( + rpc: &RpcClient, + guard: ActivationGuard<'_>, + at: &str, + ) -> Result { + let bytes = hex_bytes(&checked_call(rpc, guard, "state_getMetadata", json!([at])).await?)?; + if bytes.starts_with(b"meta") { + decode_metadata(&bytes) + } else { + decode_metadata(&decode_all::>(&bytes, "opaque metadata")?) + } + } + + pub(crate) fn section(&self, result: Result) -> AllowanceSection { + match result { + Ok(value) => AllowanceSection::Available { + observation: self.observation.clone(), + value, + }, + Err(reason) => AllowanceSection::Unavailable { reason }, + } + } + + async fn storage(&self, key: Vec) -> Result>, String> { + Ok(self.storage_many(&[key]).await?.remove(0)) + } + + async fn storage_many(&self, keys: &[Vec]) -> Result>>, String> { + let mut values = Vec::with_capacity(keys.len()); + for chunk in keys.chunks(STORAGE_BATCH) { + (self.guard)()?; + let result = self + .rpc + .get_storage_many_at(chunk, &self.observation.block_hash) + .await; + (self.guard)()?; + values.extend(result.map_err(reason)?); + } + Ok(values) + } + + fn storage_type(&self, pallet: &str, entry: &str) -> Result { + self.metadata + .storage_value_type(pallet, entry) + .ok_or_else(|| format!("runtime does not expose {pallet}.{entry}")) + } + + fn decode_storage( + &self, + pallet: &str, + entry: &str, + bytes: &[u8], + ) -> Result { + let mut input = bytes; + let value = T::decode_as_type( + &mut input, + self.storage_type(pallet, entry)?, + self.metadata.registry(), + ) + .map_err(|error| format!("{pallet}.{entry}: {error}"))?; + if !input.is_empty() { + return Err(format!("{pallet}.{entry}: trailing storage bytes")); + } + Ok(value) + } + + fn constant(&self, pallet: &str, name: &str) -> Result { + let bytes = self + .metadata + .constant(pallet, name) + .ok_or_else(|| format!("runtime does not expose {pallet}.{name}"))?; + let type_id = self + .metadata + .constant_type(pallet, name) + .ok_or_else(|| format!("runtime does not expose the type of {pallet}.{name}"))?; + let mut input = bytes; + let value = T::decode_as_type(&mut input, type_id, self.metadata.registry()) + .map_err(|error| format!("{pallet}.{name}: {error}"))?; + if !input.is_empty() { + return Err(format!("{pallet}.{name}: trailing constant bytes")); + } + Ok(value) + } + + async fn policy(&self, name: &'static str) -> Result { + (self.guard)()?; + let result = view::read_resource_u32_at( + &self.rpc, + &self.metadata, + name, + &self.observation.block_hash, + ) + .await; + (self.guard)()?; + result.map_err(reason) + } + + async fn require_suffix(&self, expected: &[u8]) -> Result<(), String> { + let key = [ + twox_128(b"NetworkSuffix").as_slice(), + twox_128(b"NetworkSuffix").as_slice(), + ] + .concat(); + let bytes = self + .storage(key) + .await? + .ok_or_else(|| "NetworkSuffix is absent".to_string())?; + let actual: Vec = decode_all(&bytes, "NetworkSuffix")?; + if actual != expected { + return Err("chain NetworkSuffix differs from the active wallet".to_string()); + } + Ok(()) + } + + /// Match the allocator's newest-to-oldest baked-ring search. A member's + /// current record can point at an unbaked ring while an older proof works. + pub(crate) async fn memberships( + &self, + candidates: &[CollectionCandidate], + ) -> Result, String> { + self.storage_type("Members", "Collections")?; + self.storage_type("Members", "CurrentRingIndex")?; + self.storage_type("Members", "RingKeysStatus")?; + self.storage_type("Members", "RingKeys")?; + let mut result = Vec::with_capacity(candidates.len()); + for candidate in candidates { + let values = self + .storage_many(&[ + ring::collections_key(candidate.collection), + ring::current_ring_index_key(candidate.collection), + ]) + .await?; + let Some(info) = &values[0] else { + result.push(false); + continue; + }; + let info: ring::CollectionInfo = self.decode_storage("Members", "Collections", info)?; + let exponent = info.ring_size.exponent(); + proof::domain_for_ring_exponent(exponent).map_err(reason)?; + let current = values[1] + .as_deref() + .map(|bytes| self.decode_storage("Members", "CurrentRingIndex", bytes)) + .transpose()? + .unwrap_or(0); + let member = proof::member_key(candidate.entropy).await.map_err(reason)?; + result.push( + self.has_including_ring(candidate.collection, &member, exponent, current) + .await?, + ); + } + Ok(result) + } + + async fn has_including_ring( + &self, + collection: PersonhoodCollection, + member: &[u8; 32], + exponent: u8, + current: u32, + ) -> Result { + let capacity = 1u32 << exponent; + for ring_index in (0..=current).rev() { + let status = self + .storage(ring::ring_keys_status_key(collection, ring_index)) + .await? + .map(|bytes| self.decode_storage::("Members", "RingKeysStatus", &bytes)) + .transpose()?; + if status + .as_ref() + .is_some_and(|status| status.included > status.total || status.total > capacity) + { + return Err("ring included/total exceeds its domain".to_string()); + } + // The allocator treats a missing status as an entirely baked ring. + let total = status.as_ref().map(|status| status.total); + let included = status.as_ref().map_or(capacity, |status| status.included); + let mut count = 0u32; + let mut found = false; + for page in 0..=capacity { + if total == Some(count) { + break; + } + let value = self + .storage(ring::ring_keys_key(collection, ring_index, page)) + .await?; + let Some(bytes) = value else { break }; + let members: Vec<[u8; 32]> = decode_all(&bytes, "Members.RingKeys")?; + if members.is_empty() { + break; + } + for key in members { + if count >= total.unwrap_or(capacity) { + return Err("ring key pages exceed total".to_string()); + } + found |= count < included && &key == member; + count += 1; + } + } + if total.is_some_and(|total| count != total) { + return Err("ring key pages are incomplete".to_string()); + } + if found { + return Ok(true); + } + } + Ok(false) + } + + pub(crate) async fn statement( + &self, + candidates: &[CollectionCandidate], + memberships: &[bool], + suffix: &[u8], + labels: &AccountLabels, + ) -> Result { + self.require_suffix(suffix).await?; + self.storage_type("Resources", "StatementStoreAllowances")?; + let (period, resets_at) = period( + self.observation.chain_timestamp, + slot::STATEMENT_STORE_PERIOD_SECONDS, + )?; + let grace_seconds = self.policy("get_stmt_store_grace_window").await?; + let replacement_cooldown_seconds = + self.policy("get_stmt_store_replacement_cooldown").await?; + let mut pools = Vec::with_capacity(candidates.len()); + for (candidate, &member) in candidates.iter().zip(memberships) { + let limit = bounded_limit( + self.policy(candidate.collection.slots_per_period_view()) + .await?, + )?; + let mut slots = Vec::new(); + for start in (0..limit).step_by(STORAGE_BATCH) { + (self.guard)()?; + let end = (start + STORAGE_BATCH as u32).min(limit); + let mut keys = Vec::with_capacity((end - start) as usize); + for index in start..end { + let alias = slot::slot_alias(candidate.entropy, suffix, period, index) + .await + .map_err(reason)?; + keys.push(slot::statement_store_allowance_key(period, &alias)); + } + for (index, bytes) in (start..end).zip(self.storage_many(&keys).await?) { + let Some(bytes) = bytes else { + continue; + }; + let entry: slot::StatementStoreAllowanceEntry = + self.decode_storage("Resources", "StatementStoreAllowances", &bytes)?; + if entry.seq != index || entry.since > self.observation.chain_timestamp { + return Err("statement slot index or timestamp contradicts its snapshot" + .to_string()); + } + let label = labels.get(&entry.account_id); + slots.push(AllowanceSlot { + index, + account_id: Some(account_hex(&entry.account_id)), + since: Some(entry.since), + product_id: label.and_then(|label| label.0.clone()), + label: label.map(|label| label.1.clone()), + }); + } + } + pools.push(pool(candidate.collection, member, member, limit, slots)?); + } + Ok(StatementAllowanceSnapshot { + claims: AllowanceClaims { + period, + resets_at, + pools, + }, + grace_seconds, + replacement_cooldown_seconds, + }) + } + + async fn claimed_slots( + &self, + candidate: &CollectionCandidate, + suffix: &[u8], + period: u32, + limit: u32, + bulletin: bool, + ) -> Result, String> { + let limit = bounded_limit(limit)?; + let (pallet, entry) = if bulletin { + ("Resources", "SpentLongTermStorageAliases") + } else { + ("Pgas", "ClaimedGasAliases") + }; + self.storage_type(pallet, entry)?; + let mut slots = Vec::new(); + for start in (0..limit).step_by(STORAGE_BATCH) { + (self.guard)()?; + let end = (start + STORAGE_BATCH as u32).min(limit); + let mut keys = Vec::with_capacity((end - start) as usize); + for index in start..end { + keys.push(if bulletin { + let counter = u8::try_from(index).map_err(reason)?; + let alias = + slot::long_term_storage_alias(candidate.entropy, suffix, period, counter) + .await + .map_err(reason)?; + slot::spent_long_term_storage_alias_key(period, &alias) + } else { + let alias = slot::pgas_alias(candidate.entropy, suffix, period, index) + .await + .map_err(reason)?; + slot::claimed_gas_alias_key(period, &alias) + }); + } + for (index, bytes) in (start..end).zip(self.storage_many(&keys).await?) { + if let Some(bytes) = bytes { + self.decode_storage::<()>(pallet, entry, &bytes)?; + slots.push(AllowanceSlot { + index, + account_id: None, + product_id: None, + label: None, + since: None, + }); + } + } + } + Ok(slots) + } + + pub(crate) async fn pgas_claims( + &self, + candidates: &[CollectionCandidate], + memberships: &[bool], + membership_observation: &AllowanceObservation, + suffix: &[u8], + ) -> Result { + self.require_suffix(suffix).await?; + let asset_id: u32 = self.constant("Pgas", "PgasAssetId")?; + let claim_amount: u128 = self.constant("Pgas", "PgasClaimAmount")?; + let (period, resets_at) = period( + self.observation.chain_timestamp, + slot::STATEMENT_STORE_PERIOD_SECONDS, + )?; + let selected = memberships.iter().position(|member| *member); + let mut pools = Vec::with_capacity(candidates.len()); + for (index, (candidate, &member)) in candidates.iter().zip(memberships).enumerate() { + let limit: u32 = self.constant( + "Pgas", + candidate.collection.pgas_claims_per_period_constant(), + )?; + let slots = self + .claimed_slots(candidate, suffix, period, limit, false) + .await?; + pools.push(pool( + candidate.collection, + member, + selected == Some(index), + limit, + slots, + )?); + } + Ok(PgasClaimsSnapshot { + claims: AllowanceClaims { + period, + resets_at, + pools, + }, + asset_id: asset_id.to_string(), + claim_amount: claim_amount.to_string(), + membership_observation: membership_observation.clone(), + }) + } + + pub(crate) async fn bulletin_claims( + &self, + candidates: &[CollectionCandidate], + memberships: &[bool], + suffix: &[u8], + ) -> Result { + self.require_suffix(suffix).await?; + let duration: u32 = self.constant("Resources", "LongTermStoragePeriodDuration")?; + let (period, resets_at) = period(self.observation.chain_timestamp, u64::from(duration))?; + let limit = self + .policy("get_long_term_storage_claims_per_period") + .await?; + u8::try_from(limit) + .map_err(|_| "Bulletin claim limit exceeds the native counter width".to_string())?; + let selected = candidates + .iter() + .zip(memberships) + .position(|(c, m)| *m && c.collection == PersonhoodCollection::LitePeople) + .or_else(|| memberships.iter().position(|member| *member)); + let mut pools = Vec::with_capacity(candidates.len()); + for (index, (candidate, &member)) in candidates.iter().zip(memberships).enumerate() { + let slots = self + .claimed_slots(candidate, suffix, period, limit, true) + .await?; + pools.push(pool( + candidate.collection, + member, + selected == Some(index), + limit, + slots, + )?); + } + Ok(AllowanceClaims { + period, + resets_at, + pools, + }) + } + + pub(crate) async fn pgas_balances( + &self, + products: &[ProductAccounts], + ) -> Result { + let asset_id: u32 = self.constant("Pgas", "PgasAssetId")?; + self.storage_type("Assets", "Metadata")?; + self.storage_type("Assets", "Account")?; + let key = [ + twox_128(b"Assets").as_slice(), + twox_128(b"Metadata").as_slice(), + &super::key_hash::blake2_128_concat(&asset_id.to_le_bytes()), + ] + .concat(); + let metadata = self.storage(key).await?; + let (decimals, symbol) = match metadata { + Some(bytes) => { + let value: AssetMetadata = self.decode_storage("Assets", "Metadata", &bytes)?; + ( + Some(value.decimals), + String::from_utf8(value.symbol) + .ok() + .filter(|symbol| !symbol.is_empty()), + ) + } + None => (None, None), + }; + let keys = products + .iter() + .map(|p| pgas::pgas_balance_key(asset_id, &p.pgas)) + .collect::>(); + let values = self.storage_many(&keys).await?; + let accounts = products + .iter() + .zip(values) + .map(|(product, value)| { + let balance = match value { + Some(bytes) => self + .decode_storage::("Assets", "Account", &bytes) + .map(|a| a.balance.to_string()), + None => Ok("0".to_string()), + }; + let (balance, error) = match balance { + Ok(balance) => (Some(balance), None), + Err(error) => (None, Some(error)), + }; + PgasBalance { + product_id: product.product_id.clone(), + account_id: account_hex(&product.pgas), + derivation_index: 0, + balance, + error, + } + }) + .collect(); + Ok(PgasBalancesSnapshot { + asset_id: asset_id.to_string(), + decimals, + symbol, + accounts, + }) + } + + pub(crate) async fn bulletin_quotas( + &self, + products: &[ProductAccounts], + ) -> Result { + self.storage_type("TransactionStorage", "Authorizations")?; + let keys = products + .iter() + .map(|p| super::bulletin_authorization_key(&p.bulletin)) + .collect::>(); + let values = self.storage_many(&keys).await?; + let accounts = products + .iter() + .zip(values) + .map(|(product, bytes)| { + let mut quota = BulletinQuota { + product_id: product.product_id.clone(), + account_id: account_hex(&product.bulletin), + status: "missing", + bytes_used: None, + bytes_limit: None, + bytes_remaining: None, + transactions_used: None, + transactions_limit: None, + transactions_remaining: None, + expires_at_block: None, + error: None, + }; + if let Some(bytes) = bytes { + let result = self + .decode_storage::( + "TransactionStorage", + "Authorizations", + &bytes, + ) + .map(|value| value.apply(&mut quota, self.observation.block_number)); + if let Err(error) = result { + quota.status = "unavailable"; + quota.error = Some(error); + } + } + quota + }) + .collect(); + Ok(BulletinQuotasSnapshot { accounts }) + } +} + +#[derive(DecodeAsType)] +struct RingStatus { + total: u32, + included: u32, +} +#[derive(DecodeAsType)] +struct AssetMetadata { + decimals: u8, + symbol: Vec, +} +#[derive(DecodeAsType)] +struct AssetAccount { + balance: u128, +} +#[derive(DecodeAsType)] +struct BulletinAuthorization { + extent: BulletinExtent, + expiration: u32, +} + +#[derive(DecodeAsType)] +struct BulletinExtent { + transactions: u32, + transactions_allowance: u32, + bytes: u64, + bytes_allowance: u64, +} + +impl BulletinAuthorization { + fn apply(self, quota: &mut BulletinQuota, at: u32) { + // These counters are soft consumption signals, not hard validity caps. + // Preserve over-limit usage; the native allocator saturates remaining. + let extent = self.extent; + quota.status = if at >= self.expiration { + "expired" + } else { + "active" + }; + quota.bytes_used = Some(extent.bytes.to_string()); + quota.bytes_limit = Some(extent.bytes_allowance.to_string()); + quota.bytes_remaining = Some( + extent + .bytes_allowance + .saturating_sub(extent.bytes) + .to_string(), + ); + quota.transactions_used = Some(extent.transactions); + quota.transactions_limit = Some(extent.transactions_allowance); + quota.transactions_remaining = Some( + extent + .transactions_allowance + .saturating_sub(extent.transactions), + ); + quota.expires_at_block = Some(self.expiration); + } +} + +pub(crate) fn account_hex(account: &[u8; 32]) -> String { + format!("0x{}", hex::encode(account)) +} + +fn period(now: u64, duration: u64) -> Result<(u32, u64), String> { + if duration == 0 { + return Err("allowance period duration is zero".to_string()); + } + let period = u32::try_from(now / duration).map_err(reason)?; + let resets_at = (u64::from(period) + 1) + .checked_mul(duration) + .filter(|value| *value <= JS_MAX_INTEGER) + .ok_or_else(|| "allowance reset timestamp overflow".to_string())?; + Ok((period, resets_at)) +} + +fn bounded_limit(limit: u32) -> Result { + if limit > MAX_INSPECTION_SLOTS { + return Err(format!( + "runtime allowance limit {limit} exceeds the bounded inspection limit" + )); + } + Ok(limit) +} + +fn pool( + collection: PersonhoodCollection, + member: bool, + selected: bool, + limit: u32, + slots: Vec, +) -> Result { + let used = u32::try_from(slots.len()).map_err(reason)?; + let remaining = limit + .checked_sub(used) + .ok_or_else(|| "occupied slots exceed the runtime limit".to_string())?; + Ok(AllowancePool { + collection: collection.metadata_variant().to_string(), + membership: if member { "verified" } else { "not-found" }, + selected: member && selected, + limit, + used, + remaining: if member { remaining } else { 0 }, + slots, + }) +} + +#[cfg(test)] +mod tests { + use super::super::rpc::testing::ScriptedRpc; + use super::*; + use subxt_rpcs::RpcClient as HostRpcClient; + + fn empty_quota() -> BulletinQuota { + BulletinQuota { + product_id: "app.dot".to_string(), + account_id: account_hex(&[1; 32]), + status: "missing", + bytes_used: None, + bytes_limit: None, + bytes_remaining: None, + transactions_used: None, + transactions_limit: None, + transactions_remaining: None, + expires_at_block: None, + error: None, + } + } + + #[test] + fn quota_expiry_is_the_block_boundary_and_soft_overuse_is_preserved() { + let authorization = || BulletinAuthorization { + extent: BulletinExtent { + transactions: 3, + transactions_allowance: 5, + bytes: 10, + bytes_allowance: 15, + }, + expiration: 20, + }; + let mut quota = empty_quota(); + authorization().apply(&mut quota, 19); + assert_eq!(quota.status, "active"); + assert_eq!(quota.bytes_remaining.as_deref(), Some("5")); + assert_eq!(quota.transactions_remaining, Some(2)); + authorization().apply(&mut quota, 20); + assert_eq!(quota.status, "expired"); + let mut excessive = authorization(); + excessive.extent.bytes = 16; + excessive.extent.transactions = 6; + excessive.apply(&mut quota, 19); + assert_eq!(quota.bytes_used.as_deref(), Some("16")); + assert_eq!(quota.bytes_remaining.as_deref(), Some("0")); + assert_eq!(quota.transactions_used, Some(6)); + assert_eq!(quota.transactions_remaining, Some(0)); + } + + #[test] + fn bulletin_storage_uses_its_metadata_extent_and_rejects_truncation() { + fn active() -> Result<(), String> { + Ok(()) + } + let fixture = include_bytes!("../../../tests/fixtures/bulletin_paseo_metadata.scale"); + let chain = ReadOnlyChain { + rpc: RpcClient::new(HostRpcClient::new(ScriptedRpc::new( + std::iter::empty::<&str>(), + ))), + metadata: decode_metadata(fixture).unwrap(), + observation: AllowanceObservation { + genesis_hash: account_hex(&[0; 32]), + block_hash: account_hex(&[1; 32]), + block_number: 19, + spec_version: 1, + chain_timestamp: 0, + }, + guard: &active, + }; + let bytes = (3u32, 5u32, 10u64, 0u64, 15u64, 20u32).encode(); + let authorization: BulletinAuthorization = chain + .decode_storage("TransactionStorage", "Authorizations", &bytes) + .unwrap(); + let mut quota = empty_quota(); + authorization.apply(&mut quota, 19); + assert_eq!(quota.bytes_remaining.as_deref(), Some("5")); + assert_eq!(quota.transactions_remaining, Some(2)); + assert!( + chain + .decode_storage::( + "TransactionStorage", + "Authorizations", + &bytes[..bytes.len() - 1] + ) + .is_err() + ); + let mut trailing = bytes; + trailing.push(0); + assert!( + chain + .decode_storage::( + "TransactionStorage", + "Authorizations", + &trailing + ) + .is_err() + ); + } + + #[test] + fn a_lost_membership_preserves_occupancy_but_never_advertises_capacity() { + let slot = AllowanceSlot { + index: 0, + account_id: Some(account_hex(&[1; 32])), + product_id: None, + label: None, + since: Some(7), + }; + let result = pool(PersonhoodCollection::People, false, true, 10, vec![slot]).unwrap(); + assert_eq!( + (result.limit, result.used, result.remaining, result.selected), + (10, 1, 0, false) + ); + } + + #[test] + fn periods_reject_zero_duration_and_unrepresentable_resets() { + assert_eq!(period(86_399, 86_400).unwrap(), (0, 86_400)); + assert_eq!(period(86_400, 86_400).unwrap(), (1, 172_800)); + assert!(period(1, 0).is_err()); + assert!(period(u64::MAX, u64::MAX).is_err()); + } + + #[test] + fn stale_activation_is_rejected_after_a_successful_rpc_read() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let checks = AtomicUsize::new(0); + let guard = || { + if checks.fetch_add(1, Ordering::SeqCst) == 0 { + Ok(()) + } else { + Err("activation changed".to_string()) + } + }; + let scripted = ScriptedRpc::new(["null"]); + let rpc = RpcClient::new(HostRpcClient::new(scripted)); + let result = + futures::executor::block_on(checked_call(&rpc, &guard, "chain_getHeader", json!([]))); + assert_eq!(result.unwrap_err(), "activation changed"); + } + + #[test] + fn membership_can_use_an_older_baked_ring_when_the_current_ring_is_unbaked() { + fn active() -> Result<(), String> { + Ok(()) + } + let collection = PersonhoodCollection::LitePeople; + let member = futures::executor::block_on(proof::member_key([7; 32])).unwrap(); + let block = account_hex(&[1; 32]); + let entries = [ + ( + ring::ring_keys_status_key(collection, 1), + (1u32, 0u32, None::).encode(), + ), + (ring::ring_keys_key(collection, 1, 0), vec![member].encode()), + ( + ring::ring_keys_status_key(collection, 0), + (1u32, 1u32, None::).encode(), + ), + (ring::ring_keys_key(collection, 0, 0), vec![member].encode()), + ]; + let replies: Vec = entries + .iter() + .map(|(_, bytes)| json!(format!("0x{}", hex::encode(bytes))).to_string()) + .collect(); + let scripted = ScriptedRpc::new(replies.iter().map(String::as_str)); + let chain = ReadOnlyChain { + rpc: RpcClient::new(HostRpcClient::new(scripted.clone())), + metadata: decode_metadata(include_bytes!( + "../../../tests/fixtures/paseo-next-v2-metadata-v16.scale" + )) + .unwrap(), + observation: AllowanceObservation { + genesis_hash: account_hex(&[0; 32]), + block_hash: block, + block_number: 19, + spec_version: 1, + chain_timestamp: 0, + }, + guard: &active, + }; + assert!( + futures::executor::block_on(chain.has_including_ring(collection, &member, 12, 1)) + .unwrap() + ); + assert_eq!( + scripted.calls(), + entries + .iter() + .map(|(key, _)| ( + "state_queryStorageAt".to_string(), + json!([ + [format!("0x{}", hex::encode(key))], + &chain.observation.block_hash + ]) + .to_string(), + )) + .collect::>(), + ); + } + + #[test] + fn metadata_decoding_rejects_trailing_bytes() { + let fixture = include_bytes!("../../../tests/fixtures/bulletin_paseo_metadata.scale"); + let mut corrupted = fixture.to_vec(); + corrupted.push(0); + assert!(decode_metadata(&corrupted).is_err()); + } +} diff --git a/rust/crates/truapi/src/runtime/statement_allowance/pgas.rs b/rust/crates/truapi/src/runtime/statement_allowance/pgas.rs index ef6cc32e4..d770436fb 100644 --- a/rust/crates/truapi/src/runtime/statement_allowance/pgas.rs +++ b/rust/crates/truapi/src/runtime/statement_allowance/pgas.rs @@ -15,13 +15,13 @@ use std::time::Duration; #[cfg(not(target_arch = "wasm32"))] use std::time::Instant; -#[cfg(target_arch = "wasm32")] -use web_time::Instant; use parity_scale_codec::{Decode, DecodeAll}; use scale_decode::DecodeAsType; use sp_crypto_hashing::twox_128; use thiserror::Error; +#[cfg(target_arch = "wasm32")] +use web_time::Instant; use super::collection::PersonhoodCollection; use super::extension::{AS_PGAS, Metadata, MetadataError}; @@ -165,7 +165,7 @@ pub struct PgasClaim<'a> { } /// `Assets.Account[(asset_id, who)]` storage key on Asset Hub. -fn pgas_balance_key(asset_id: u32, who: &[u8; 32]) -> Vec { +pub(super) fn pgas_balance_key(asset_id: u32, who: &[u8; 32]) -> Vec { [ twox_128(b"Assets").as_slice(), twox_128(b"Account").as_slice(), diff --git a/rust/crates/truapi/src/runtime/statement_allowance/ring.rs b/rust/crates/truapi/src/runtime/statement_allowance/ring.rs index 658f52121..fac466cec 100644 --- a/rust/crates/truapi/src/runtime/statement_allowance/ring.rs +++ b/rust/crates/truapi/src/runtime/statement_allowance/ring.rs @@ -68,13 +68,13 @@ const MEMBER_LEN: usize = 32; /// Fields read from `Members.Collections`. #[derive(Debug, PartialEq, Eq, DecodeAsType)] -struct CollectionInfo { - ring_size: RingExponent, +pub(super) struct CollectionInfo { + pub(super) ring_size: RingExponent, } /// Supported LitePeople ring domain sizes. #[derive(Debug, PartialEq, Eq, DecodeAsType)] -enum RingExponent { +pub(super) enum RingExponent { R2e9, R2e10, R2e14, @@ -82,7 +82,7 @@ enum RingExponent { impl RingExponent { /// Return the exponent represented by the runtime enum variant. - fn exponent(self) -> u8 { + pub(super) fn exponent(self) -> u8 { match self { Self::R2e9 => 9, Self::R2e10 => 10, @@ -118,7 +118,7 @@ pub struct RingParams { } /// `Members.CurrentRingIndex[id]` storage key. -fn current_ring_index_key(collection: PersonhoodCollection) -> Vec { +pub(super) fn current_ring_index_key(collection: PersonhoodCollection) -> Vec { [ twox_128(b"Members").as_slice(), twox_128(b"CurrentRingIndex").as_slice(), @@ -128,7 +128,7 @@ fn current_ring_index_key(collection: PersonhoodCollection) -> Vec { } /// `Members.Collections[id]` storage key. -fn collections_key(collection: PersonhoodCollection) -> Vec { +pub(super) fn collections_key(collection: PersonhoodCollection) -> Vec { [ twox_128(b"Members").as_slice(), twox_128(b"Collections").as_slice(), @@ -138,7 +138,7 @@ fn collections_key(collection: PersonhoodCollection) -> Vec { } /// `Members.RingKeysStatus[(id, ring_index)]` storage key. -fn ring_keys_status_key(collection: PersonhoodCollection, ring_index: u32) -> Vec { +pub(super) fn ring_keys_status_key(collection: PersonhoodCollection, ring_index: u32) -> Vec { [ twox_128(b"Members").as_slice(), twox_128(b"RingKeysStatus").as_slice(), @@ -160,7 +160,11 @@ fn ring_root_key(collection: PersonhoodCollection, ring_index: u32) -> Vec { } /// `Members.RingKeys[(id, ring_index, page)]` storage key. -fn ring_keys_key(collection: PersonhoodCollection, ring_index: u32, page: u32) -> Vec { +pub(super) fn ring_keys_key( + collection: PersonhoodCollection, + ring_index: u32, + page: u32, +) -> Vec { [ twox_128(b"Members").as_slice(), twox_128(b"RingKeys").as_slice(), diff --git a/rust/crates/truapi/src/runtime/statement_allowance/rpc.rs b/rust/crates/truapi/src/runtime/statement_allowance/rpc.rs index b3d507ba1..b0cfc11a3 100644 --- a/rust/crates/truapi/src/runtime/statement_allowance/rpc.rs +++ b/rust/crates/truapi/src/runtime/statement_allowance/rpc.rs @@ -16,10 +16,11 @@ use super::StatementAllowanceError; /// Timeout for an allowance registration extrinsic to reach a block. const SUBMIT_TIMEOUT: Duration = Duration::from_secs(120); -/// Error from the native JSON-RPC surface used by allowance allocation. +/// Error from the host-backed JSON-RPC surface used by allowance allocation. #[derive(Debug, Error)] pub enum RpcError { /// Opening a direct RPC URL failed. + #[cfg(not(target_arch = "wasm32"))] #[error("connect {url}: {source}")] Connect { /// RPC URL. @@ -49,6 +50,9 @@ pub enum RpcError { /// `chain_getFinalizedHead` did not return a hash string. #[error("chain_getFinalizedHead returned non-string")] FinalizedHeadNotString, + /// A read-only snapshot response was incomplete or malformed. + #[error("invalid snapshot RPC response: {0}")] + InvalidSnapshot(String), /// Extrinsic status subscription ended before inclusion. #[error("author_submitAndWatchExtrinsic subscription ended")] SubmitSubscriptionEnded, @@ -127,6 +131,28 @@ impl RpcClient { self.get_storage_maybe_at(key, Some(at)).await } + /// Read a complete storage batch at one block, rejecting ambiguous absence. + /// + /// Unlike the allocator's best-effort reader, every requested key must be + /// explicitly present (with a value or null) exactly once in the response. + pub async fn get_storage_many_at( + &self, + keys: &[Vec], + at: &str, + ) -> Result>>, StatementAllowanceError> { + if keys.is_empty() { + return Ok(Vec::new()); + } + let hex_keys: Vec = keys + .iter() + .map(|key| format!("0x{}", hex::encode(key))) + .collect(); + let response = self + .call("state_queryStorageAt", json!([hex_keys, at])) + .await?; + decode_storage_batch_at(&hex_keys, at, response) + } + async fn get_storage_maybe_at( &self, key: &[u8], @@ -260,19 +286,23 @@ enum ExtrinsicStatus { Pending, } +/// Classify one legacy `author_extrinsicUpdate` payload. +/// +/// `TransactionStatus` serializes its unit variants (`future`, `ready`, +/// `dropped`, `invalid`) as bare strings and the rest as single-key objects. fn extrinsic_status(status: &Value) -> ExtrinsicStatus { + if let Some(unit) = status.as_str() { + return match unit { + "dropped" | "invalid" => ExtrinsicStatus::Rejected(unit.to_string()), + _ => ExtrinsicStatus::Pending, + }; + } for key in ["finalized", "inBlock"] { if let Some(hash) = status.get(key).and_then(Value::as_str) { return ExtrinsicStatus::Included(hash.to_string()); } } - for key in [ - "invalid", - "dropped", - "usurped", - "retracted", - "finalityTimeout", - ] { + for key in ["usurped", "retracted", "finalityTimeout"] { if status.get(key).is_some() { return ExtrinsicStatus::Rejected(key.to_string()); } @@ -296,6 +326,52 @@ fn decode_hex(value: &str) -> Result, StatementAllowanceError> { .map_err(|err| RpcError::StorageHex(err).into()) } +fn decode_storage_batch_at( + keys: &[String], + at: &str, + response: Value, +) -> Result>>, StatementAllowanceError> { + let invalid = |reason: &str| RpcError::InvalidSnapshot(reason.to_string()); + let blocks = response + .as_array() + .ok_or_else(|| invalid("expected change sets"))?; + if blocks.len() != 1 || blocks[0].get("block").and_then(Value::as_str) != Some(at) { + return Err(invalid("change set does not name the requested block").into()); + } + let changes = blocks[0] + .get("changes") + .and_then(Value::as_array) + .ok_or_else(|| invalid("missing storage changes"))?; + let mut found = HashMap::with_capacity(keys.len()); + for change in changes { + let pair = change + .as_array() + .ok_or_else(|| invalid("invalid storage change"))?; + if pair.len() != 2 { + return Err(invalid("invalid storage change length").into()); + } + let key = pair[0] + .as_str() + .ok_or_else(|| invalid("invalid storage key"))?; + if !keys.iter().any(|expected| expected == key) || found.contains_key(key) { + return Err(invalid("unexpected or duplicate storage key").into()); + } + let value = match &pair[1] { + Value::Null => None, + Value::String(value) if value.starts_with("0x") => Some(decode_hex(value)?), + _ => return Err(invalid("storage value is neither hex nor null").into()), + }; + found.insert(key, value); + } + keys.iter() + .map(|key| { + found + .remove(key.as_str()) + .ok_or_else(|| invalid("incomplete storage batch").into()) + }) + .collect() +} + #[cfg(test)] pub mod testing { //! Scripted JSON-RPC transport for exercising request shapes in tests. @@ -355,6 +431,10 @@ pub mod testing { let params: serde_json::Value = serde_json::from_str(params).expect("batched read params are JSON"); let keys = params[0].as_array().expect("batched read names its keys"); + let block = params + .get(1) + .and_then(serde_json::Value::as_str) + .unwrap_or("0xscripted"); assert!( responses.len() >= keys.len(), "unscripted batched read of {} keys", @@ -368,7 +448,7 @@ pub mod testing { serde_json::json!([key, value]) }) .collect(); - serde_json::json!([{ "block": "0xscripted", "changes": changes }]).to_string() + serde_json::json!([{ "block": block, "changes": changes }]).to_string() } fn params_json(params: Option>) -> String { @@ -444,7 +524,30 @@ mod tests { use serde_json::json; use super::testing::ScriptedRpc; - use super::{ExtrinsicStatus, HostRpcClient, RpcClient, extrinsic_status}; + use super::{ + ExtrinsicStatus, HostRpcClient, RpcClient, decode_storage_batch_at, extrinsic_status, + }; + + #[test] + fn snapshot_batches_distinguish_explicit_absence_from_incomplete_responses() { + let keys = vec!["0x01".to_string(), "0x02".to_string()]; + let complete = json!([{ "block": "0xat", "changes": [["0x02", "0x"], ["0x01", null]] }]); + assert_eq!( + decode_storage_batch_at(&keys, "0xat", complete).unwrap(), + vec![None, Some(Vec::new())], + ); + for response in [ + json!([]), + json!([{ "block": "0xat", "changes": [["0x01", null]] }]), + json!([{ "block": "0xother", "changes": [["0x01", null], ["0x02", null]] }]), + json!([{ "block": "0xat", "changes": [["0x01", null], ["0x01", null], ["0x02", null]] }]), + json!([{ "block": "0xat", "changes": [["0x01", false], ["0x02", null]] }]), + json!([{ "block": "0xat", "changes": [["0x01", "0xzz"], ["0x02", null]] }]), + json!([{ "block": "0xat", "changes": [["0x01", null], ["0x02", null], ["0x03", null]] }]), + ] { + assert!(decode_storage_batch_at(&keys, "0xat", response).is_err()); + } + } #[test] fn in_block_status_completes_submission() { @@ -463,14 +566,14 @@ mod tests { #[test] fn terminal_pool_statuses_reject_the_submission() { let statuses: Vec = [ - "invalid", - "dropped", - "usurped", - "retracted", - "finalityTimeout", + json!("invalid"), + json!("dropped"), + json!({"usurped": "0x1234"}), + json!({"retracted": "0x1234"}), + json!({"finalityTimeout": "0x1234"}), ] - .into_iter() - .map(|key| extrinsic_status(&json!({key: "0x1234"}))) + .iter() + .map(extrinsic_status) .collect(); assert_eq!( @@ -485,6 +588,21 @@ mod tests { ); } + #[test] + fn progress_statuses_keep_waiting() { + for status in [ + json!("future"), + json!("ready"), + json!({"broadcast": ["12D3KooW"]}), + ] { + assert_eq!( + extrinsic_status(&status), + ExtrinsicStatus::Pending, + "{status}" + ); + } + } + #[test] fn get_storage_at_pins_the_read_to_a_block() { let scripted = ScriptedRpc::new([r#""0x0102""#]); diff --git a/rust/crates/truapi/src/runtime/statement_allowance/slot.rs b/rust/crates/truapi/src/runtime/statement_allowance/slot.rs index 12d3e6dc4..054d556f5 100644 --- a/rust/crates/truapi/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi/src/runtime/statement_allowance/slot.rs @@ -130,11 +130,11 @@ pub enum SlotError { }, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] -struct StatementStoreAllowanceEntry { - account_id: [u8; 32], - seq: u32, - since: u64, +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, scale_decode::DecodeAsType)] +pub(super) struct StatementStoreAllowanceEntry { + pub(super) account_id: [u8; 32], + pub(super) seq: u32, + pub(super) since: u64, } /// A slot that is occupied, as observed by a scan. @@ -256,7 +256,7 @@ async fn alias_in_context( /// `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 { +pub(super) fn statement_store_allowance_key(period: u32, alias: &[u8; 32]) -> Vec { [ twox_128(b"Resources").as_slice(), twox_128(b"StatementStoreAllowances").as_slice(), @@ -269,7 +269,7 @@ fn statement_store_allowance_key(period: u32, alias: &[u8; 32]) -> Vec { /// `Pgas.ClaimedGasAliases[day][alias]` storage key on Asset Hub. /// key1 = Identity(u32be day); key2 = Blake2_128Concat(alias). Presence alone /// marks the slot spent; the value is unit. -fn claimed_gas_alias_key(day: u32, alias: &[u8; 32]) -> Vec { +pub(super) fn claimed_gas_alias_key(day: u32, alias: &[u8; 32]) -> Vec { [ twox_128(b"Pgas").as_slice(), twox_128(b"ClaimedGasAliases").as_slice(), @@ -281,7 +281,7 @@ fn claimed_gas_alias_key(day: u32, alias: &[u8; 32]) -> Vec { /// `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 { +pub(super) 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(), @@ -362,7 +362,7 @@ pub fn replaceable_slot( } /// `Timestamp.Now` storage key. -fn timestamp_now_key() -> Vec { +pub(super) fn timestamp_now_key() -> Vec { [ twox_128(b"Timestamp").as_slice(), twox_128(b"Now").as_slice(), diff --git a/rust/crates/truapi/src/runtime/statement_allowance/view.rs b/rust/crates/truapi/src/runtime/statement_allowance/view.rs index 201f4dffc..f20ace599 100644 --- a/rust/crates/truapi/src/runtime/statement_allowance/view.rs +++ b/rust/crates/truapi/src/runtime/statement_allowance/view.rs @@ -2,7 +2,7 @@ use parity_scale_codec::{Decode, DecodeAll, Encode}; use scale_info::{TypeDef, TypeDefPrimitive}; -use serde_json::{Value, json}; +use serde_json::Value; use thiserror::Error; use super::StatementAllowanceError; @@ -95,6 +95,7 @@ async fn read_u32( metadata: &Metadata, pallet: &'static str, function: &'static str, + at: Option<&str>, ) -> Result { let definition = metadata .view_function(pallet, function) @@ -107,11 +108,13 @@ async fn read_u32( })); } let id = definition.id; - if let Some(value) = metadata.cached_view_u32(&id) { + if at.is_none() + && let Some(value) = metadata.cached_view_u32(&id) + { return Ok(value); } - let output = execute_no_args(rpc, pallet, function, id).await?; + let output = execute_no_args(rpc, pallet, function, id, at).await?; let primitive = metadata .registry() .resolve(definition.output_type) @@ -150,7 +153,9 @@ async fn read_u32( type_id: definition.output_type, })), }?; - metadata.cache_view_u32(id, value); + if at.is_none() { + metadata.cache_view_u32(id, value); + } Ok(value) } @@ -159,7 +164,17 @@ pub async fn read_resource_u32( metadata: &Metadata, function: &'static str, ) -> Result { - read_u32(rpc, metadata, "Resources", function).await + read_u32(rpc, metadata, "Resources", function, None).await +} + +/// Resolve a runtime policy against the same block as an inspection's state. +pub(super) async fn read_resource_u32_at( + rpc: &RpcClient, + metadata: &Metadata, + function: &'static str, + at: &str, +) -> Result { + read_u32(rpc, metadata, "Resources", function, Some(at)).await } pub fn supports_resource_u32(metadata: &Metadata, function: &'static str) -> bool { @@ -183,18 +198,18 @@ async fn execute_no_args( pallet: &'static str, function: &'static str, id: [u8; 32], + at: Option<&str>, ) -> Result, StatementAllowanceError> { let mut arguments = id.to_vec(); Vec::::new().encode_to(&mut arguments); - let response = rpc - .call( - "state_call", - json!([ - EXECUTE_VIEW_FUNCTION, - format!("0x{}", hex::encode(arguments)) - ]), - ) - .await?; + let mut params = vec![ + Value::String(EXECUTE_VIEW_FUNCTION.to_string()), + Value::String(format!("0x{}", hex::encode(arguments))), + ]; + if let Some(at) = at { + params.push(Value::String(at.to_string())); + } + let response = rpc.call("state_call", Value::Array(params)).await?; decode_response(pallet, function, response) } @@ -269,6 +284,7 @@ mod tests { "Resources", "get_lite_stmt_store_slots_per_period", id, + None, )) .unwrap(); @@ -317,6 +333,7 @@ mod tests { &metadata, "Resources", "get_stmt_store_slots_per_period", + None, )) .unwrap(); let second = futures::executor::block_on(read_u32( @@ -324,6 +341,7 @@ mod tests { &metadata, "Resources", "get_stmt_store_slots_per_period", + None, )) .unwrap(); @@ -353,6 +371,7 @@ mod tests { &metadata, "Resources", "get_stmt_store_replacement_cooldown", + None, )) .is_err() ); @@ -361,6 +380,7 @@ mod tests { &metadata, "Resources", "get_stmt_store_replacement_cooldown", + None, )) .unwrap(); diff --git a/rust/crates/truapi/src/runtime/statement_store_rpc.rs b/rust/crates/truapi/src/runtime/statement_store_rpc.rs index 7ad9682a1..f83dc9702 100644 --- a/rust/crates/truapi/src/runtime/statement_store_rpc.rs +++ b/rust/crates/truapi/src/runtime/statement_store_rpc.rs @@ -67,7 +67,7 @@ impl StatementStoreRpc { } /// Open a People-chain RPC client already scoped to its genesis hash, for - /// the native allowance paths that key the chain-context cache by it. + /// allowance paths that key the chain-context cache by it. pub async fn chain_client( &self, label: &'static str, diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 5b5ef84ef..892a92dc8 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -3295,6 +3295,51 @@ fn get_account_derives_rfc0022_product_key() { ); } +#[test] +fn get_account_answers_when_host_storage_never_returns() { + // A host whose storage never answers must not be able to park the request. + // Resolving an own-product account reads the persisted subtree first, and + // that read is a host callback: without a deadline the call never settles, + // so the product waits forever with no response and no error frame + // (paritytech/dotli-community#271). + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + core_storage_pending: true, + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(sso_session_info()); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: account_id("myapp.dot", 0), + }); + // The caller's deadline is what bounds it; a short one keeps the test fast + // and proves the bound is the context's rather than a fixed sleep. + let mut cx = CallContext::default(); + cx.set_timeout(std::time::Duration::from_millis(200)); + + let outcome = futures::executor::block_on(async { + use futures::FutureExt; + use futures::pin_mut; + let call = host.get_account(&cx, request).fuse(); + let guard = futures_timer::Delay::new(std::time::Duration::from_secs(10)).fuse(); + pin_mut!(call, guard); + futures::select! { + result = call => Some(result), + () = guard => None, + } + }); + + let Some(result) = outcome else { + panic!("get_account never answered a host whose storage does not return"); + }; + assert!( + result.is_err(), + "a host that cannot answer its own storage must not resolve an account" + ); +} + #[test] fn get_account_own_product_prompts_and_rejects_on_a_cold_subtree() { let host = ProductRuntimeHost::new( diff --git a/rust/crates/truapi/src/test_support.rs b/rust/crates/truapi/src/test_support.rs index f0229655a..21102d715 100644 --- a/rust/crates/truapi/src/test_support.rs +++ b/rust/crates/truapi/src/test_support.rs @@ -1972,9 +1972,10 @@ impl ThemeHost for StubPlatform { impl LocaleHost for StubPlatform { fn subscribe_locale( &self, - ) -> BoxStream<'static, Result> { + ) -> BoxStream<'static, Result> { Box::pin(stream::once(async { - Ok(v01::HostLocaleSubscribeItem { + Ok(crate::latest::HostLocaleSubscribeItem { + time_zone: None, language_tag: "zh-Hans".to_string(), }) })) diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index a25fba9d5..d16aa467a 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -1,3 +1,4 @@ +use alloc::{string::String, vec::Vec}; use parity_scale_codec::{Decode, Encode}; /// Account selector within a product subtree. Encodes as diff --git a/rust/crates/truapi/src/v01/chain.rs b/rust/crates/truapi/src/v01/chain.rs index 6c0d15d46..ebd2d327e 100644 --- a/rust/crates/truapi/src/v01/chain.rs +++ b/rust/crates/truapi/src/v01/chain.rs @@ -1,3 +1,4 @@ +use alloc::{string::String, vec::Vec}; use parity_scale_codec::{Decode, Encode}; use super::common::GenericError; diff --git a/rust/crates/truapi/src/v01/chat.rs b/rust/crates/truapi/src/v01/chat.rs index 7354561a1..54f9cf1b7 100644 --- a/rust/crates/truapi/src/v01/chat.rs +++ b/rust/crates/truapi/src/v01/chat.rs @@ -1,3 +1,5 @@ +use alloc::{string::String, vec::Vec}; + use parity_scale_codec::{Decode, Encode}; /// Request to create a chat room. diff --git a/rust/crates/truapi/src/v01/coin_payment.rs b/rust/crates/truapi/src/v01/coin_payment.rs index c46e452b7..5c1dd8b97 100644 --- a/rust/crates/truapi/src/v01/coin_payment.rs +++ b/rust/crates/truapi/src/v01/coin_payment.rs @@ -1,3 +1,4 @@ +use alloc::{string::String, vec::Vec}; use parity_scale_codec::{Decode, Encode}; /// Well-known ordinary user-owned CoinPayment purse. diff --git a/rust/crates/truapi/src/v01/common.rs b/rust/crates/truapi/src/v01/common.rs index 49cbe1228..e06f31633 100644 --- a/rust/crates/truapi/src/v01/common.rs +++ b/rust/crates/truapi/src/v01/common.rs @@ -1,3 +1,4 @@ +use alloc::string::String; use parity_scale_codec::{Decode, Encode}; /// Generic error payload carrying a human-readable reason string. Used by many diff --git a/rust/crates/truapi/src/v01/contacts.rs b/rust/crates/truapi/src/v01/contacts.rs index af2951122..dd0cdde42 100644 --- a/rust/crates/truapi/src/v01/contacts.rs +++ b/rust/crates/truapi/src/v01/contacts.rs @@ -1,3 +1,4 @@ +use alloc::string::String; use parity_scale_codec::{Decode, Encode}; use crate::Bytes32; diff --git a/rust/crates/truapi/src/v01/entropy.rs b/rust/crates/truapi/src/v01/entropy.rs index 7a56e4524..1039a4a6d 100644 --- a/rust/crates/truapi/src/v01/entropy.rs +++ b/rust/crates/truapi/src/v01/entropy.rs @@ -1,3 +1,4 @@ +use alloc::{string::String, vec::Vec}; use parity_scale_codec::{Decode, Encode}; /// Error from [`crate::api::Entropy::derive`] (RFC 0007). diff --git a/rust/crates/truapi/src/v01/local_storage.rs b/rust/crates/truapi/src/v01/local_storage.rs index af8147343..7cd93dcf9 100644 --- a/rust/crates/truapi/src/v01/local_storage.rs +++ b/rust/crates/truapi/src/v01/local_storage.rs @@ -1,3 +1,4 @@ +use alloc::{string::String, vec::Vec}; use derive_more::Display; use parity_scale_codec::{Decode, Encode}; diff --git a/rust/crates/truapi/src/v01/locale.rs b/rust/crates/truapi/src/v01/locale.rs index 2a72172fa..1cbea5b0d 100644 --- a/rust/crates/truapi/src/v01/locale.rs +++ b/rust/crates/truapi/src/v01/locale.rs @@ -1,11 +1,8 @@ +use alloc::string::String; use parity_scale_codec::{Decode, Encode}; /// Locale the host currently presents its interface in, pushed to subscribers. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -#[cfg_attr( - all(feature = "runtime", not(target_arch = "wasm32")), - derive(uniffi::Record) -)] pub struct HostLocaleSubscribeItem { /// BCP 47 language tag, such as `en`, `pt-BR` or `zh-Hans`. The set is /// open: a product that does not ship the tag chooses its own fallback. diff --git a/rust/crates/truapi/src/v01/notifications.rs b/rust/crates/truapi/src/v01/notifications.rs index 99592abbb..edb17600c 100644 --- a/rust/crates/truapi/src/v01/notifications.rs +++ b/rust/crates/truapi/src/v01/notifications.rs @@ -1,3 +1,4 @@ +use alloc::string::String; use parity_scale_codec::{Decode, Encode}; /// Push notification payload. diff --git a/rust/crates/truapi/src/v01/payment.rs b/rust/crates/truapi/src/v01/payment.rs index 5acd98765..3b50f4c99 100644 --- a/rust/crates/truapi/src/v01/payment.rs +++ b/rust/crates/truapi/src/v01/payment.rs @@ -1,3 +1,4 @@ +use alloc::{string::String, vec::Vec}; use parity_scale_codec::{Decode, Encode}; use super::account::DerivationIndex; diff --git a/rust/crates/truapi/src/v01/permissions.rs b/rust/crates/truapi/src/v01/permissions.rs index 5ee1cf5a0..098c36e6a 100644 --- a/rust/crates/truapi/src/v01/permissions.rs +++ b/rust/crates/truapi/src/v01/permissions.rs @@ -1,3 +1,4 @@ +use alloc::{string::String, vec::Vec}; use derive_more::Display; use parity_scale_codec::{Decode, Encode}; diff --git a/rust/crates/truapi/src/v01/pocket.rs b/rust/crates/truapi/src/v01/pocket.rs index b8a64a70e..13bd393d4 100644 --- a/rust/crates/truapi/src/v01/pocket.rs +++ b/rust/crates/truapi/src/v01/pocket.rs @@ -1,3 +1,5 @@ +use alloc::{string::String, vec::Vec}; + use parity_scale_codec::{Decode, Encode}; /// One of the calling product's Pocket cards. diff --git a/rust/crates/truapi/src/v01/preimage.rs b/rust/crates/truapi/src/v01/preimage.rs index 808066749..4ec9654dc 100644 --- a/rust/crates/truapi/src/v01/preimage.rs +++ b/rust/crates/truapi/src/v01/preimage.rs @@ -1,3 +1,4 @@ +use alloc::{string::String, vec::Vec}; use parity_scale_codec::{Decode, Encode}; /// Preimage submission error. diff --git a/rust/crates/truapi/src/v01/renderer.rs b/rust/crates/truapi/src/v01/renderer.rs index 8731ddf17..8e1f8b9e7 100644 --- a/rust/crates/truapi/src/v01/renderer.rs +++ b/rust/crates/truapi/src/v01/renderer.rs @@ -1,5 +1,6 @@ //! Product-rendered body trees and the contexts that name them. +use alloc::{string::String, vec::Vec}; use parity_scale_codec::{Compact, Decode, Encode, OptionBool}; /// A size in logical pixels, SCALE-encoded as `Compact`. @@ -602,6 +603,7 @@ pub struct HostRendererActionSubscribeItem { #[cfg(test)] mod tests { use super::*; + use alloc::{string::ToString, vec}; #[derive(Encode)] struct RendererWireComponent

{ diff --git a/rust/crates/truapi/src/v01/resource_allocation.rs b/rust/crates/truapi/src/v01/resource_allocation.rs index 9c2343caf..8d03ea8e8 100644 --- a/rust/crates/truapi/src/v01/resource_allocation.rs +++ b/rust/crates/truapi/src/v01/resource_allocation.rs @@ -1,3 +1,4 @@ +use alloc::{string::String, vec::Vec}; use parity_scale_codec::{Decode, Encode}; use super::account::DerivationIndex; diff --git a/rust/crates/truapi/src/v01/signing.rs b/rust/crates/truapi/src/v01/signing.rs index 1a3e87e76..d43e3721e 100644 --- a/rust/crates/truapi/src/v01/signing.rs +++ b/rust/crates/truapi/src/v01/signing.rs @@ -1,3 +1,4 @@ +use alloc::{string::String, vec::Vec}; use parity_scale_codec::{Decode, Encode, OptionBool}; use super::ProductAccountId; diff --git a/rust/crates/truapi/src/v01/statement_store.rs b/rust/crates/truapi/src/v01/statement_store.rs index 917d52bdc..dc94e8c47 100644 --- a/rust/crates/truapi/src/v01/statement_store.rs +++ b/rust/crates/truapi/src/v01/statement_store.rs @@ -1,3 +1,4 @@ +use alloc::{string::String, vec::Vec}; use parity_scale_codec::{Decode, Encode}; use super::ProductAccountId; diff --git a/rust/crates/truapi/src/v01/system.rs b/rust/crates/truapi/src/v01/system.rs index e17675fb8..407e7bfd0 100644 --- a/rust/crates/truapi/src/v01/system.rs +++ b/rust/crates/truapi/src/v01/system.rs @@ -1,3 +1,4 @@ +use alloc::{string::String, vec::Vec}; use derive_more::Display; use parity_scale_codec::{Decode, Encode}; diff --git a/rust/crates/truapi/src/v01/theme.rs b/rust/crates/truapi/src/v01/theme.rs index 171b39219..e16a83f34 100644 --- a/rust/crates/truapi/src/v01/theme.rs +++ b/rust/crates/truapi/src/v01/theme.rs @@ -1,3 +1,4 @@ +use alloc::string::String; use parity_scale_codec::{Decode, Encode}; /// Identifies a named theme. diff --git a/rust/crates/truapi/src/v01/transaction.rs b/rust/crates/truapi/src/v01/transaction.rs index 76281f580..d30bec170 100644 --- a/rust/crates/truapi/src/v01/transaction.rs +++ b/rust/crates/truapi/src/v01/transaction.rs @@ -1,3 +1,4 @@ +use alloc::{string::String, vec::Vec}; use parity_scale_codec::{Decode, Encode}; use crate::v01::contacts::ContactHandle; diff --git a/rust/crates/truapi/src/v01/worker.rs b/rust/crates/truapi/src/v01/worker.rs index 1e2cc39d0..cf0cec6f7 100644 --- a/rust/crates/truapi/src/v01/worker.rs +++ b/rust/crates/truapi/src/v01/worker.rs @@ -1,3 +1,5 @@ +use alloc::string::String; + use derive_more::Display; use parity_scale_codec::{Decode, Encode}; diff --git a/rust/crates/truapi/src/v02.rs b/rust/crates/truapi/src/v02.rs index d021e70af..761c79b44 100644 --- a/rust/crates/truapi/src/v02.rs +++ b/rust/crates/truapi/src/v02.rs @@ -5,5 +5,7 @@ //! envelope, so this module stays a delta rather than a copy of the protocol. mod local_storage; +mod locale; pub use local_storage::*; +pub use locale::*; diff --git a/rust/crates/truapi/src/v02/local_storage.rs b/rust/crates/truapi/src/v02/local_storage.rs index b608e0f30..65f808a28 100644 --- a/rust/crates/truapi/src/v02/local_storage.rs +++ b/rust/crates/truapi/src/v02/local_storage.rs @@ -1,3 +1,4 @@ +use alloc::string::String; use derive_more::Display; use parity_scale_codec::{Decode, Encode}; diff --git a/rust/crates/truapi/src/v02/locale.rs b/rust/crates/truapi/src/v02/locale.rs new file mode 100644 index 000000000..6f3e5082f --- /dev/null +++ b/rust/crates/truapi/src/v02/locale.rs @@ -0,0 +1,58 @@ +use alloc::{string::String, vec::Vec}; +use parity_scale_codec::{Decode, Encode}; + +/// Host language and local time zone, replaced together when either changes. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct HostLocaleSubscribeItem { + /// BCP 47 language tag selected by the host. + pub language_tag: String, + /// IANA time zone, or absent when the host cannot supply local time. + pub time_zone: Option, +} + +/// Convert UTC instants using a snapshot of the host's locale subscription. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct HostLocaleLocalizeTimestampsRequest { + /// At most 128 Unix millisecond instants, no later than year 9999. + pub timestamps_ms: Vec, + /// Language tag from the locale subscription, not a guessed language. + pub language_tag: String, + /// Time zone from the locale subscription; evaluated separately at each instant. + pub time_zone: String, +} + +/// One timestamp's calendar identity and presentation in the requested context. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct HostLocaleLocalizedTimestamp { + /// Gregorian YYYY-MM-DD local date, independent of display language/calendar. + pub local_date: String, + /// Localized short time, including the host language's hour-cycle convention. + pub time: String, + /// Localized date label. + pub date: String, + /// Localized date and time with a time-zone indication for detail views. + pub date_time: String, +} + +/// Local timestamps in exactly the request's order. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct HostLocaleLocalizeTimestampsResponse { + /// One result per requested timestamp; partial success is not returned. + pub timestamps: Vec, +} diff --git a/rust/crates/truapi/src/versioned/account.rs b/rust/crates/truapi/src/versioned/account.rs index 709dc3b17..2120b2860 100644 --- a/rust/crates/truapi/src/versioned/account.rs +++ b/rust/crates/truapi/src/versioned/account.rs @@ -1,4 +1,5 @@ //! Versioned wrappers for [`Account`](crate::api::Account) methods. +use alloc::vec::Vec; use crate::v01; diff --git a/rust/crates/truapi/src/versioned/chat.rs b/rust/crates/truapi/src/versioned/chat.rs index 7092b4604..af1e160ca 100644 --- a/rust/crates/truapi/src/versioned/chat.rs +++ b/rust/crates/truapi/src/versioned/chat.rs @@ -23,6 +23,7 @@ truapi_macros::versioned_type! { #[cfg(test)] mod tests { use super::*; + use alloc::string::String; use parity_scale_codec::{Decode, Encode}; // Fixture is `ChatRegisterBotV1_request` output from triangle-js-sdks diff --git a/rust/crates/truapi/src/versioned/local_storage.rs b/rust/crates/truapi/src/versioned/local_storage.rs index 3ee37aea3..1c3dd41c2 100644 --- a/rust/crates/truapi/src/versioned/local_storage.rs +++ b/rust/crates/truapi/src/versioned/local_storage.rs @@ -11,6 +11,8 @@ //! product's storage for a write, and there is nothing for those requests to //! address. +use alloc::string::ToString; + use crate::versioned::{FromLatest, IntoLatest}; use crate::{v01, v02}; diff --git a/rust/crates/truapi/src/versioned/locale.rs b/rust/crates/truapi/src/versioned/locale.rs index a4c374eb6..77ab04bc9 100644 --- a/rust/crates/truapi/src/versioned/locale.rs +++ b/rust/crates/truapi/src/versioned/locale.rs @@ -1,9 +1,98 @@ //! Versioned wrappers for [`Locale`](crate::api::Locale) methods. -use crate::v01; +use crate::versioned::{FromLatest, IntoLatest}; +use crate::{v01, v02}; truapi_macros::versioned_type! { - pub enum HostLocaleSubscribeRequest { V1 } - pub enum HostLocaleSubscribeItem { V1 => v01::HostLocaleSubscribeItem } - pub enum HostLocaleSubscribeError { V1 => v01::GenericError } + pub enum HostLocaleSubscribeRequest { V1, V2 } + pub enum HostLocaleSubscribeItem { + V1 => v01::HostLocaleSubscribeItem, + V2 => v02::HostLocaleSubscribeItem, + } + pub enum HostLocaleSubscribeError { + V1 => v01::GenericError, + V2 => v01::GenericError, + } + pub enum HostLocaleLocalizeTimestampsRequest { V1 => v02::HostLocaleLocalizeTimestampsRequest } + pub enum HostLocaleLocalizeTimestampsResponse { V1 => v02::HostLocaleLocalizeTimestampsResponse } + pub enum HostLocaleLocalizeTimestampsError { V1 => v01::GenericError } +} + +impl IntoLatest for HostLocaleSubscribeRequest { + fn into_latest(self) -> Self::Latest {} +} + +impl IntoLatest for HostLocaleSubscribeItem { + fn into_latest(self) -> Self::Latest { + match self { + Self::V1(item) => v02::HostLocaleSubscribeItem { + language_tag: item.language_tag, + time_zone: None, + }, + Self::V2(item) => item, + } + } +} + +impl FromLatest for HostLocaleSubscribeItem { + fn from_latest(item: Self::Latest, target: u8) -> Self { + if target >= 2 { + Self::V2(item) + } else { + Self::V1(v01::HostLocaleSubscribeItem { + language_tag: item.language_tag, + }) + } + } +} + +impl IntoLatest for HostLocaleSubscribeError { + fn into_latest(self) -> Self::Latest { + match self { + Self::V1(error) | Self::V2(error) => error, + } + } +} + +impl FromLatest for HostLocaleSubscribeError { + fn from_latest(error: Self::Latest, target: u8) -> Self { + if target >= 2 { + Self::V2(error) + } else { + Self::V1(error) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_old_host_locale_does_not_invent_a_time_zone() { + let locale = HostLocaleSubscribeItem::V1(v01::HostLocaleSubscribeItem { + language_tag: "en-US".into(), + }) + .into_latest(); + assert_eq!(locale.language_tag, "en-US"); + assert_eq!(locale.time_zone, None); + } + + #[test] + fn an_old_subscriber_keeps_its_language_without_new_wire_fields() { + let locale = v02::HostLocaleSubscribeItem { + language_tag: "fr-CA".into(), + time_zone: Some("America/Toronto".into()), + }; + assert_eq!( + HostLocaleSubscribeItem::from_latest(locale.clone(), 1), + HostLocaleSubscribeItem::V1(v01::HostLocaleSubscribeItem { + language_tag: "fr-CA".into() + }), + ); + assert_eq!( + HostLocaleSubscribeItem::from_latest(locale.clone(), 2).into_latest(), + locale + ); + } } diff --git a/rust/crates/truapi/src/versioned/preimage.rs b/rust/crates/truapi/src/versioned/preimage.rs index af6538f27..98c7ddd99 100644 --- a/rust/crates/truapi/src/versioned/preimage.rs +++ b/rust/crates/truapi/src/versioned/preimage.rs @@ -1,4 +1,5 @@ //! Versioned wrappers for [`Preimage`](crate::api::Preimage) methods. +use alloc::vec::Vec; use crate::v01; diff --git a/rust/crates/truapi/src/versioned/renderer.rs b/rust/crates/truapi/src/versioned/renderer.rs index ff6231ce2..63522227f 100644 --- a/rust/crates/truapi/src/versioned/renderer.rs +++ b/rust/crates/truapi/src/versioned/renderer.rs @@ -14,6 +14,7 @@ truapi_macros::versioned_type! { #[cfg(test)] mod tests { use super::*; + use alloc::{vec, vec::Vec}; use parity_scale_codec::{Decode, Encode}; #[test] diff --git a/rust/crates/truapi/src/wasm.rs b/rust/crates/truapi/src/wasm.rs index d6fc53519..76bb8141c 100644 --- a/rust/crates/truapi/src/wasm.rs +++ b/rust/crates/truapi/src/wasm.rs @@ -868,6 +868,33 @@ fn wasm_platform(bridge: Arc) -> WasmPlatformAdapters { } } +fn connection_adapters_from_js( + callbacks: Option<&JsValue>, +) -> Result, JsValue> { + let Some(callbacks) = callbacks.filter(|value| !value.is_null() && !value.is_undefined()) + else { + return Ok(None); + }; + let WasmPlatformAdapters { + platform, + chat_platform, + contacts_platform, + status_host, + pocket_platform, + } = wasm_platform(Arc::new(JsBridge::from_js(callbacks)?)); + Ok(Some(crate::host_core::ConnectionAdapters { + platform, + chat_platform, + contacts_platform, + permission_status: status_host, + // One-use grants belong to this execution, not the shared host. + permission_grants: Arc::default(), + pocket_platform, + chat: Arc::new(crate::runtime::ActionChannel::chat()), + renderer: Arc::new(crate::runtime::ActionChannel::renderer()), + })) +} + /// Reports every worker demand transition to the host's /// `workerDemandChanged(productId, transition)` callback. struct WasmWorkerDemand { @@ -953,11 +980,13 @@ impl WasmPairingHostRuntime { } /// Build one product-scoped runtime from this pairing host runtime. + /// Optional platform callbacks are execution-local; shared authority stays here. #[wasm_bindgen(js_name = productRuntime)] pub fn product_runtime( &self, product: JsValue, core_callbacks: JsValue, + platform_callbacks: Option, ) -> Result { let product = product_context_from_js(&product)?; let channel = CoreChannel::from_js(&core_callbacks)?; @@ -966,7 +995,10 @@ impl WasmPairingHostRuntime { let sink = Arc::new(WasmFrameSink { emit_frame: SendWrapper::new(channel.emit_frame), }); - let runtime = self.runtime.product_runtime(product, sink); + let runtime = match connection_adapters_from_js(platform_callbacks.as_ref())? { + Some(adapters) => self.runtime.product_runtime_with(product, adapters, sink), + None => self.runtime.product_runtime(product, sink), + }; if let Some(debug_emit) = debug_emit { runtime.set_debug_sink( ChannelId(channel_id), @@ -1212,22 +1244,6 @@ impl WasmSigningHostRuntime { self.runtime.set_grant_allowances_unchecked(granted); } - /// The product's hard-subtree public key, derived from the active session - /// root, or `undefined` while no session is active. - /// - /// Paired with `deriveProductAccountPublicKey` and `productAccountAddress` - /// this gives a host the product's address without asking the product. - #[wasm_bindgen(js_name = productSubtreePublicKey)] - pub fn product_subtree_public_key( - &self, - product_id: String, - ) -> Result>, JsValue> { - self.runtime - .product_subtree_public_key(&product_id) - .map(|key| key.map(|key| key.to_vec())) - .map_err(generic_error_to_js) - } - /// Answer these resource tags as refused, replacing any earlier set. /// /// A suite proving its product survives a refused resource needs that one @@ -1251,14 +1267,25 @@ impl WasmSigningHostRuntime { let bridge = Arc::new(JsBridge::from_js(&callbacks)?); let WasmPlatformAdapters { platform, + chat_platform, + contacts_platform, + status_host, pocket_platform, - .. } = wasm_platform(bridge); let spawner: Spawner = Arc::new(|fut| { wasm_bindgen_futures::spawn_local(fut); }); let host_config = signing_host_config_from_js(&host_config)?; - let runtime = SigningHostRuntime::new(platform, host_config, spawner); + let runtime = SigningHostRuntime::with_platforms( + platform, + host_config, + spawner, + chat_platform, + contacts_platform, + ); + if let Some(status_host) = status_host { + runtime.set_permission_status_host(status_host); + } if let Some(pocket_platform) = pocket_platform { runtime.set_pocket_platform(pocket_platform); } @@ -1269,18 +1296,23 @@ impl WasmSigningHostRuntime { } /// Build one product-scoped runtime from this signing host. + /// Optional platform callbacks are execution-local; shared authority stays here. #[wasm_bindgen(js_name = productRuntime)] pub fn product_runtime( &self, product: JsValue, core_callbacks: JsValue, + platform_callbacks: Option, ) -> Result { let product = product_context_from_js(&product)?; let channel = CoreChannel::from_js(&core_callbacks)?; let sink = Arc::new(WasmFrameSink { emit_frame: SendWrapper::new(channel.emit_frame), }); - let runtime = self.runtime.product_runtime(product, sink); + let runtime = match connection_adapters_from_js(platform_callbacks.as_ref())? { + Some(adapters) => self.runtime.product_runtime_with(product, adapters, sink), + None => self.runtime.product_runtime(product, sink), + }; Ok(WasmProductRuntime::from_parts(runtime, channel.dispose)) } @@ -1290,6 +1322,99 @@ impl WasmSigningHostRuntime { self.runtime.disconnect_session().await; } + /// Tell the runtime that the host's contact list changed, so cached + /// handles are dropped and the next resolution reads the list. + #[wasm_bindgen(js_name = notifyContactsChanged)] + pub fn notify_contacts_changed(&self) { + self.runtime.notify_contacts_changed(); + } + + /// Read the active local session's X25519 chat identity private key. + #[wasm_bindgen(js_name = sessionChatIdentityKey)] + pub fn session_chat_identity_key(&self) -> Option> { + self.runtime + .session_chat_identity_key() + .map(|key| key.to_vec()) + } + + /// Read this browser's X25519 encryption secret, generating and persisting + /// it on first read. + #[wasm_bindgen(js_name = deviceEncryptionKey)] + pub async fn device_encryption_key(&self) -> Result, JsValue> { + self.runtime + .device_encryption_key() + .await + .map(|key| key.to_vec()) + .map_err(generic_error_to_js) + } + + /// Resolve a product's hard-subtree public key from the active local + /// signing session. + #[wasm_bindgen(js_name = productSubtreePublicKey)] + pub async fn product_subtree_public_key( + &self, + product_id: String, + timeout_ms: Option, + ) -> Result>, JsValue> { + self.runtime + .product_subtree_public_key(&product_id, timeout_ms) + .await + .map(|key| key.map(|key| key.to_vec())) + .map_err(generic_error_to_js) + } + + /// Read one permission authorization status for a product. + #[wasm_bindgen(js_name = permissionAuthorizationStatus)] + pub async fn permission_authorization_status( + &self, + product_id: String, + payload: Vec, + ) -> Result { + let request = decode_permission_authorization_request(&payload)?; + let status = self + .runtime + .permission_authorization_status(&product_id, request) + .await + .map_err(generic_error_to_js)?; + Ok(permission_authorization_status_to_js(status)) + } + + /// Read permission authorization statuses for a product. + #[wasm_bindgen(js_name = permissionAuthorizationStatuses)] + pub async fn permission_authorization_statuses( + &self, + product_id: String, + payloads: Array, + ) -> Result { + let requests = decode_permission_authorization_requests(&payloads)?; + let statuses = self + .runtime + .permission_authorization_statuses(&product_id, requests) + .await + .map_err(generic_error_to_js)?; + let values = Array::new(); + for status in statuses { + values.push(&permission_authorization_status_to_js(status)); + } + Ok(values) + } + + /// Update one stored permission authorization status for a product. + #[wasm_bindgen(js_name = setPermissionAuthorizationStatus)] + pub async fn set_permission_authorization_status( + &self, + product_id: String, + payload: Vec, + status: String, + ) -> Result<(), JsValue> { + let request = decode_permission_authorization_request(&payload)?; + let status = permission_authorization_status_from_js(&status)?; + self.runtime + .set_permission_authorization_status(&product_id, request, status) + .await + .map_err(generic_error_to_js) + } + /// Activate a wallet-local session from raw BIP-39 entropy. #[wasm_bindgen(js_name = activateLocalSession)] pub async fn activate_local_session(&self, secret: Vec) -> Result<(), JsValue> { @@ -1312,6 +1437,78 @@ impl WasmSigningHostRuntime { .map_err(generic_error_to_js) } + /// Capture an opaque activation fence and its UID account. + #[wasm_bindgen(js_name = localIdentityContext)] + pub fn local_identity_context(&self) -> Result { + let context = self + .runtime + .local_identity_context() + .map_err(generic_error_to_js)?; + let json = serde_json::to_string(&context) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + js_sys::JSON::parse(&json) + } + + /// Return the UID public key followed by its native sr25519 backend-auth proof. + #[wasm_bindgen(js_name = localIdentityAuthProof)] + pub fn local_identity_auth_proof( + &self, + activation_id: String, + challenge: Vec, + ) -> Result, JsValue> { + self.runtime + .local_identity_auth_proof(&activation_id, &challenge) + .map_err(generic_error_to_js) + } + + /// Build registration JSON without exporting entropy or implementing proofs in JavaScript. + #[wasm_bindgen(js_name = localLiteRegistrationBody)] + pub async fn local_lite_registration_body( + &self, + activation_id: String, + username_base: String, + verifier: Vec, + ) -> Result { + let verifier = verifier + .as_slice() + .try_into() + .map_err(|_| JsValue::from_str("attester must be 32 bytes"))?; + self.runtime + .local_lite_registration_body(&activation_id, &username_base, verifier) + .await + .map_err(generic_error_to_js) + } + + /// Install freshly verified dotNS metadata only for the captured local activation. + #[wasm_bindgen(js_name = refreshLocalIdentity)] + pub async fn refresh_local_identity(&self, activation_id: String) -> Result { + let identity = self + .runtime + .refresh_local_identity_for(&activation_id) + .await + .map_err(generic_error_to_js)?; + let json = serde_json::to_string(&identity) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + js_sys::JSON::parse(&json) + } + + /// Read finalized allowance state for this activation; never allocate or sign. + #[wasm_bindgen(js_name = getWalletAllowanceSnapshot)] + pub async fn get_wallet_allowance_snapshot( + &self, + activation_id: String, + product_ids: Vec, + ) -> Result { + let snapshot = self + .runtime + .get_wallet_allowance_snapshot(&activation_id, product_ids) + .await + .map_err(generic_error_to_js)?; + let json = serde_json::to_string(&snapshot) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + js_sys::JSON::parse(&json) + } + /// Revoke one product's grants from the current local activation. #[wasm_bindgen(js_name = clearProductState)] pub async fn clear_product_state(&self, product_id: String) -> Result<(), JsValue> { diff --git a/rust/crates/truapi/tests/common/mod.rs b/rust/crates/truapi/tests/common/mod.rs index 9faab07c0..cca88496a 100644 --- a/rust/crates/truapi/tests/common/mod.rs +++ b/rust/crates/truapi/tests/common/mod.rs @@ -255,7 +255,8 @@ impl ThemeHost for WireShapePlatform { impl LocaleHost for WireShapePlatform { fn subscribe_locale( &self, - ) -> BoxStream<'static, Result> { + ) -> BoxStream<'static, Result> + { Box::pin(stream::empty()) } } diff --git a/scripts/codegen.sh b/scripts/codegen.sh index e5778e872..92b5e22fb 100755 --- a/scripts/codegen.sh +++ b/scripts/codegen.sh @@ -2,7 +2,7 @@ # Regenerate js/packages/truapi/src/generated/* from rust/crates/truapi. # # Pipeline: -# 1. cargo + doc -p truapi --no-default-features +# 1. cargo + doc -p truapi --no-default-features --features host-api # -> target/doc/truapi.json, the protocol definitions alone, kept as # target/doc/truapi_protocol.json # 2. cargo run -p truapi-codegen -- --input target/doc/truapi_protocol.json @@ -47,7 +47,7 @@ unset DYLD_LIBRARY_PATH RUSTDOCFLAGS="${RUSTDOCFLAGS:-} -D warnings -Z unstable-options --output-format json" export RUSTDOCFLAGS -cargo +"$NIGHTLY_TOOLCHAIN" doc -p truapi --no-deps --no-default-features +cargo +"$NIGHTLY_TOOLCHAIN" doc -p truapi --no-deps --no-default-features --features host-api cp target/doc/truapi.json target/doc/truapi_protocol.json cargo run -p truapi-codegen -- \ --input target/doc/truapi_protocol.json \ @@ -60,6 +60,7 @@ cargo run -p truapi-codegen -- \ --playground-output js/packages/truapi/src/playground \ --client-examples-output playground/test/generated/examples \ --rust-output rust/crates/truapi/src/generated \ + --rust-client-output rust/crates/truapi-client/src/generated.rs \ --platform-input target/doc/truapi.json \ --platform-input target/doc/truapi_provider.json \ --platform-ts-output js/packages/truapi-host/src/generated \ @@ -68,6 +69,7 @@ cargo run -p truapi-codegen -- \ --explorer-output js/packages/truapi/src/explorer rustfmt +"$NIGHTLY_TOOLCHAIN" --edition 2024 \ + rust/crates/truapi-client/src/generated.rs \ rust/crates/truapi/src/generated/dispatcher.rs \ rust/crates/truapi/src/generated/wire_table.rs \ rust/crates/truapi/src/wasm/generated_bridge.rs @@ -105,5 +107,6 @@ echo "Generated client at js/packages/truapi/src/generated/" echo "Generated playground metadata at js/packages/truapi/src/playground/codegen/" echo "Generated client examples at playground/test/generated/examples/" echo "Generated Rust dispatcher at rust/crates/truapi/src/generated/" +echo "Generated no-std Rust client catalog at rust/crates/truapi-client/src/generated.rs" echo "Generated host-callbacks WASM adapter at js/packages/truapi-host/src/generated/" echo "Generated Rust WASM bridge at rust/crates/truapi/src/wasm/generated_bridge.rs" diff --git a/scripts/install-wasm-artifact-tools.sh b/scripts/install-wasm-artifact-tools.sh new file mode 100755 index 000000000..b6759d7e6 --- /dev/null +++ b/scripts/install-wasm-artifact-tools.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# wasm-pack remains responsible for provider/verifiable builds. The core builds +# explicit cdylib artifacts so its protocol can also be linked by no_std guests. +set -euo pipefail +cd "$(dirname "$0")/.." + +bindgen_version=$(node -e ' + const { execFileSync } = require("node:child_process"); + const metadata = JSON.parse(execFileSync("cargo", ["metadata", "--locked", "--format-version", "1"], { maxBuffer: 32 * 1024 * 1024 })); + console.log(metadata.packages.find(pkg => pkg.name === "wasm-bindgen").version); +') +cargo install wasm-bindgen-cli --version "$bindgen_version" --locked + +# Match wasm-pack 0.14.0, which builds the other published WASM modules. +version=117 +case "$(uname -s)/$(uname -m)" in + Linux/x86_64) platform=x86_64-linux ;; + Linux/aarch64) platform=aarch64-linux ;; + Darwin/arm64) platform=arm64-macos ;; + Darwin/x86_64) platform=x86_64-macos ;; + *) echo "Unsupported Binaryen platform" >&2; exit 1 ;; +esac +archive="binaryen-version_${version}-${platform}.tar.gz" +url="https://github.com/WebAssembly/binaryen/releases/download/version_${version}" +destination="${HOME}/.local/share/truapi-wasm-tools" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +curl --fail --location --silent --show-error "$url/$archive" -o "$tmp/$archive" +curl --fail --location --silent --show-error "$url/$archive.sha256" -o "$tmp/$archive.sha256" +(cd "$tmp" && shasum -a 256 --check "$archive.sha256") +mkdir -p "$destination" +tar -xzf "$tmp/$archive" -C "$destination" +bin="$destination/binaryen-version_${version}/bin" +if [[ -n "${GITHUB_PATH:-}" ]]; then + printf '%s\n' "$bin" >> "$GITHUB_PATH" +fi +printf 'WASM artifact tools installed. Add %s to PATH.\n' "$bin" diff --git a/scripts/lib/native-binding-sync.test.mjs b/scripts/lib/native-binding-sync.test.mjs new file mode 100644 index 000000000..d94446864 --- /dev/null +++ b/scripts/lib/native-binding-sync.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +test("host binding sync removes obsolete namespaces without deleting hand-written sources", () => { + const workspace = mkdtempSync(join(tmpdir(), "truapi-binding-sync-")); + const scriptPath = "ios/truapi-host/scripts/sync-bindings.sh"; + const script = join(workspace, scriptPath); + const sources = join(workspace, "ios/truapi-host/Sources"); + const hostSources = join(sources, "TrUAPIHost"); + const generated = join(workspace, "target/uniffi-swift-out"); + try { + mkdirSync(dirname(script), { recursive: true }); + symlinkSync(join(repoRoot, scriptPath), script); + mkdirSync(hostSources, { recursive: true }); + mkdirSync(generated, { recursive: true }); + writeFileSync(join(hostSources, "TrUAPIHost.swift"), "// hand-written host\n"); + mkdirSync(join(hostSources, "Resources")); + writeFileSync( + join(hostSources, "Resources/truapi-container.js"), + "// container\n", + ); + for (const namespace of ["truapi", "truapi_server", "truapi_platform"]) { + writeFileSync(join(generated, `${namespace}.swift`), "// generated\n"); + writeFileSync(join(generated, `${namespace}FFI.h`), "// header\n"); + writeFileSync( + join(generated, `${namespace}FFI.modulemap`), + `module ${namespace}FFI {}\n`, + ); + const headers = join(sources, `${namespace}FFI/include`); + mkdirSync(headers, { recursive: true }); + writeFileSync(join(headers, `${namespace}FFI.h`), "// stale header\n"); + writeFileSync(join(headers, "module.modulemap"), "// stale modulemap\n"); + writeFileSync(join(hostSources, `${namespace}.swift`), "// stale binding\n"); + } + + execFileSync("sh", [script], { cwd: workspace }); + + assert.deepEqual(readdirSync(sources).sort(), ["TrUAPIHost", "truapiFFI"]); + assert.deepEqual(readdirSync(hostSources).sort(), [ + "Resources", + "TrUAPIHost.swift", + "truapi.swift", + ]); + assert.equal( + readFileSync(join(hostSources, "TrUAPIHost.swift"), "utf8"), + "// hand-written host\n", + ); + assert.equal( + readFileSync(join(hostSources, "Resources/truapi-container.js"), "utf8"), + "// container\n", + ); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +}); diff --git a/scripts/snapshot-version.sh b/scripts/snapshot-version.sh index c33ea4df5..77a8d9ab0 100755 --- a/scripts/snapshot-version.sh +++ b/scripts/snapshot-version.sh @@ -70,7 +70,7 @@ TMP_DIR="$(mktemp -d -t truapi-snapshot.XXXXXX)" trap 'rm -rf "$TMP_DIR"' EXIT NIGHTLY_TOOLCHAIN="${TRUAPI_NIGHTLY_TOOLCHAIN:-$(head -n1 "$ROOT/nightly-toolchain" | tr -d '[:space:]')}" -cargo +"$NIGHTLY_TOOLCHAIN" rustdoc -p truapi --no-default-features -- -Z unstable-options --output-format json >/dev/null +cargo +"$NIGHTLY_TOOLCHAIN" rustdoc -p truapi --no-default-features --features host-api -- -Z unstable-options --output-format json >/dev/null codegen_args=( --input target/doc/truapi.json