diff --git a/.gitignore b/.gitignore index 620568d2..9dc237bb 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,4 @@ js/packages/truapi/src/generated/ js/packages/truapi/dist/generated/ js/packages/truapi-host/src/generated/ js/packages/truapi-host/dist/generated/ -js/packages/truapi-host-wasm/src/generated/ -js/packages/truapi-host-wasm/dist/generated/ -js/packages/truapi-host-wasm/dist/wasm/ +js/packages/truapi-host/dist/wasm/ diff --git a/CLAUDE.md b/CLAUDE.md index d0df0ce5..6a494b06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,8 +38,10 @@ scripts/codegen.sh regenerate the TS client from the Rust crate - `truapi-server` WASM artifacts live under `js/packages/truapi-host/dist/wasm/web/` and are gitignored. Build them locally with `make wasm` (rerun whenever - `rust/crates/truapi-server/` changes); CI builds the bundle fresh from the - Rust source on every run. + `rust/crates/truapi-server/` changes). CI compiles the crate for + `wasm32-unknown-unknown` to guard the wasm bridge and its offline subxt + surface, but does not build or publish the packaged bundle; run `make wasm` + locally before relying on the browser host. ## Code style diff --git a/Cargo.lock b/Cargo.lock index 84faa731..932a28dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -280,6 +292,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" +[[package]] +name = "base58" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6107fe1be6682a68940da878d9e9f5e90ca5745b3dec9fd1bb393c8777d4f581" + [[package]] name = "base64" version = "0.22.1" @@ -328,6 +346,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "blake2-rfc" version = "0.2.18" @@ -709,6 +736,41 @@ dependencies = [ "syn", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "der" version = "0.7.10" @@ -988,6 +1050,24 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "frame-decode" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88cda60c640572c970c544ba5879375a18ecfb2c47c617be8265830b63df193d" +dependencies = [ + "frame-metadata", + "parity-scale-codec", + "scale-decode", + "scale-encode", + "scale-info", + "scale-info-legacy", + "scale-type-resolver", + "serde_yaml", + "sp-crypto-hashing", + "thiserror 2.0.18", +] + [[package]] name = "frame-metadata" version = "23.0.1" @@ -997,6 +1077,7 @@ dependencies = [ "cfg-if", "parity-scale-codec", "scale-info", + "serde", ] [[package]] @@ -1229,6 +1310,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1427,6 +1518,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1688,6 +1785,16 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "keccak-hash" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1b8590eb6148af2ea2d75f38e7d29f5ca970d5a4df456b3ef19b8b415d0264" +dependencies = [ + "primitive-types", + "tiny-keccak", +] + [[package]] name = "konst" version = "0.2.20" @@ -2186,6 +2293,7 @@ dependencies = [ "fixed-hash", "impl-codec", "impl-serde", + "scale-info", "uint", ] @@ -2198,6 +2306,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2401,6 +2531,12 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2410,16 +2546,85 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scale-bits" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27243ab0d2d6235072b017839c5f0cd1a3b1ce45c0f7a715363b0c7d36c76c94" +dependencies = [ + "parity-scale-codec", + "scale-info", + "scale-type-resolver", + "serde", +] + +[[package]] +name = "scale-decode" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d6ed61699ad4d54101ab5a817169259b5b0efc08152f8632e61482d8a27ca3d" +dependencies = [ + "parity-scale-codec", + "primitive-types", + "scale-bits", + "scale-decode-derive", + "scale-type-resolver", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "scale-decode-derive" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cb245f7fdb489e7ba43a616cbd34427fe3ba6fe0edc1d0d250085e6c84f3ec" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "scale-encode" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2a976d73564a59e482b74fd5d95f7518b79ca8c8ca5865398a4d629dd15ee50" +dependencies = [ + "parity-scale-codec", + "primitive-types", + "scale-bits", + "scale-encode-derive", + "scale-type-resolver", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "scale-encode-derive" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17020f2d59baabf2ddcdc20a4e567f8210baf089b8a8d4785f5fd5e716f92038" +dependencies = [ + "darling", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "scale-info" version = "2.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b" dependencies = [ + "bitvec", "cfg-if", "derive_more 1.0.0", "parity-scale-codec", "scale-info-derive", + "serde", ] [[package]] @@ -2434,6 +2639,63 @@ dependencies = [ "syn", ] +[[package]] +name = "scale-info-legacy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d972ce93a4f81efc40fce251e992faf99ecb090c02bcbd3614e213991038c181" +dependencies = [ + "hashbrown 0.16.1", + "scale-type-resolver", + "serde", + "smallstr", + "smallvec", + "thiserror 2.0.18", + "yap", +] + +[[package]] +name = "scale-type-resolver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0cded6518aa0bd6c1be2b88ac81bf7044992f0f154bfbabd5ad34f43512abcb" +dependencies = [ + "scale-info", + "smallvec", +] + +[[package]] +name = "scale-typegen" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "642d2f13f3fc9a34ea2c1e36142984eba78cd2405a61632492f8b52993e98879" +dependencies = [ + "proc-macro2", + "quote", + "scale-info", + "syn", + "thiserror 2.0.18", +] + +[[package]] +name = "scale-value" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b64809a541e8d5a59f7a9d67cc700cdf5d7f907932a83a0afdedc90db07ccb" +dependencies = [ + "base58", + "blake2", + "either", + "parity-scale-codec", + "scale-bits", + "scale-decode", + "scale-encode", + "scale-type-resolver", + "serde", + "thiserror 2.0.18", + "yap", +] + [[package]] name = "schannel" version = "0.1.29" @@ -2578,6 +2840,19 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2666,6 +2941,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallstr" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862077b1e764f04c251fe82a2ef562fd78d7cadaeb072ca7c2bcaf7217b1ff3b" +dependencies = [ + "smallvec", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -2929,6 +3213,63 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "subxt" +version = "0.50.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "440b28070d3bba98d637791bc7ebbe362f5beb9e749204c16caaf344fef260ba" +dependencies = [ + "async-trait", + "derive-where", + "either", + "frame-decode", + "frame-metadata", + "futures", + "hex", + "impl-serde", + "keccak-hash", + "parity-scale-codec", + "primitive-types", + "scale-bits", + "scale-decode", + "scale-encode", + "scale-info", + "scale-info-legacy", + "scale-type-resolver", + "scale-value", + "serde", + "serde_json", + "sp-crypto-hashing", + "subxt-lightclient", + "subxt-macro", + "subxt-metadata", + "subxt-rpcs", + "subxt-utils-accountid32", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "web-time", +] + +[[package]] +name = "subxt-codegen" +version = "0.50.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc8a165f929034fd00e9bfa86b63e5d7c26b635e526fa9cadb01389eab92029" +dependencies = [ + "getrandom 0.2.17", + "heck", + "parity-scale-codec", + "proc-macro2", + "quote", + "scale-info", + "scale-typegen", + "subxt-metadata", + "syn", + "thiserror 2.0.18", +] + [[package]] name = "subxt-lightclient" version = "0.50.1" @@ -2956,6 +3297,40 @@ dependencies = [ "web-time", ] +[[package]] +name = "subxt-macro" +version = "0.50.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5132bb78596d4957bf3039b9c54f5f88cefefd64ab61f0e71592526e14ef7f13" +dependencies = [ + "darling", + "parity-scale-codec", + "proc-macro-error2", + "quote", + "scale-typegen", + "subxt-codegen", + "subxt-metadata", + "subxt-utils-fetchmetadata", + "syn", +] + +[[package]] +name = "subxt-metadata" +version = "0.50.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e48c34696956f995df947adeb77061cb413377fb412487c41e7c2089112d5b" +dependencies = [ + "frame-decode", + "frame-metadata", + "hashbrown 0.14.5", + "parity-scale-codec", + "scale-info", + "scale-info-legacy", + "scale-type-resolver", + "sp-crypto-hashing", + "thiserror 2.0.18", +] + [[package]] name = "subxt-rpcs" version = "0.50.1" @@ -2981,6 +3356,33 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "subxt-utils-accountid32" +version = "0.50.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d43f74707b4c7b7e1e40bf362aebaf42630211a8bcac46a94318284f305debd" +dependencies = [ + "base58", + "blake2", + "parity-scale-codec", + "scale-decode", + "scale-encode", + "scale-info", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "subxt-utils-fetchmetadata" +version = "0.50.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d58c4d891f3f8bd56acae29706b8bcde969ebb7421c447a8dff0117128416cb" +dependencies = [ + "hex", + "parity-scale-codec", + "thiserror 2.0.18", +] + [[package]] name = "syn" version = "2.0.117" @@ -3071,6 +3473,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -3281,13 +3692,11 @@ version = "0.1.0" dependencies = [ "aes-gcm", "async-trait", - "blake2-rfc", - "bs58", + "blake2b_simd", "console_error_panic_hook", "derive_more 2.1.1", "futures", "futures-timer", - "futures-util", "getrandom 0.2.17", "hex", "hkdf", @@ -3295,8 +3704,7 @@ dependencies = [ "nanoid", "p256", "parity-scale-codec", - "pin-project", - "primitive-types", + "scale-info", "schnorrkel", "send_wrapper 0.6.0", "serde", @@ -3304,6 +3712,7 @@ dependencies = [ "sha2 0.10.9", "sp-crypto-hashing", "substrate-bip39", + "subxt", "subxt-rpcs", "thiserror 1.0.69", "tracing", @@ -3328,6 +3737,7 @@ checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ "cfg-if", "digest 0.10.7", + "rand", "static_assertions", ] @@ -3392,6 +3802,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -3980,6 +4396,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "yap" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe269e7b803a5e8e20cbd97860e136529cd83bf2c9c6d37b142467e7e1f051f" + [[package]] name = "yoke" version = "0.8.2" diff --git a/Makefile b/Makefile index 4aeb0764..956c1a59 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # Run `make help` for the list of targets. .DEFAULT_GOAL := help -.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test dev dev-bootstrap dev-link-check e2e-dotli matrix explorer +.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test dotli-link dev dev-bootstrap dev-link-check e2e-dotli matrix explorer TRUAPI_PKG := js/packages/truapi PLAYGROUND := playground @@ -16,9 +16,15 @@ HOST_WASM_ADAPTER_GENERATED := $(HOST_WASM_PKG)/src/generated/host-callbacks-ada HOST_WASM_WORKER_CALLBACKS_GENERATED := $(HOST_WASM_PKG)/src/generated/worker-callbacks.ts HOST_WASM_WEB := $(HOST_WASM_PKG)/dist/wasm/web/truapi_server.js DOTLI_UI := $(DOTLI)/packages/ui -DOTLI_HOST_WASM_LINK := $(DOTLI_UI)/node_modules/@parity/truapi-host +DOTLI_NODE_MODULES := $(DOTLI)/node_modules +DOTLI_TRUAPI_LINK := $(DOTLI_NODE_MODULES)/@parity/truapi +DOTLI_HOST_WASM_LINK := $(DOTLI_NODE_MODULES)/@parity/truapi-host +DOTLI_UI_TRUAPI_SHADOW := $(DOTLI_UI)/node_modules/@parity/truapi +DOTLI_UI_HOST_WASM_SHADOW := $(DOTLI_UI)/node_modules/@parity/truapi-host SIGNER_BOT_BASE_URL ?= https://signing-bot-dev.novasama-tech.org/ SIGNER_BOT_NETWORK ?= paseo-next-v2 +SIGNER_BOT_BASE_URL_ORIGIN := $(origin SIGNER_BOT_BASE_URL) +SIGNER_BOT_NETWORK_ORIGIN := $(origin SIGNER_BOT_NETWORK) VITE_NETWORKS ?= paseo-next-v2,previewnet export SIGNER_BOT_BASE_URL export SIGNER_BOT_NETWORK @@ -42,6 +48,7 @@ setup: ## First-time setup: submodules, JS dependencies, generated artifacts. ./scripts/codegen.sh cd $(PLAYGROUND) && yarn install --frozen-lockfile cd $(DOTLI) && bun install --frozen-lockfile + $(MAKE) dotli-link build: ## Build the Rust workspace and the TypeScript client. cargo build --workspace @@ -58,6 +65,9 @@ wasm: ## Rebuild the truapi-server WASM artifacts under js/packages/truapi-host/ wasm-crypto-test: ## Run crypto/vector tests on wasm32 via wasm-pack/node. wasm-pack test --node rust/crates/truapi-server --test wasm_crypto_vectors --no-default-features +dotli-link: ## Link dotli to this checkout's local @parity/truapi packages. + cd $(DOTLI) && TRUAPI_REPO="$(CURDIR)" bun run link:truapi + test: ## Run Rust + TypeScript client tests. cargo test --workspace cd $(TRUAPI_PKG) && npm test @@ -102,19 +112,24 @@ dev-bootstrap: ## Prepare ignored generated/build artifacts needed by dotli prev # that only exist after codegen.sh, which also builds the packages. if [ ! -d node_modules ]; then npm ci --ignore-scripts; fi if [ ! -f "$(HOST_CALLBACKS_GENERATED)" ] || [ ! -f "$(HOST_WASM_ADAPTER_GENERATED)" ] || [ ! -f "$(HOST_WASM_WORKER_CALLBACKS_GENERATED)" ]; then ./scripts/codegen.sh; fi + cd $(TRUAPI_PKG) && npm run build cd $(HOST_WASM_PKG) && npm run build TRUAPI_WASM_PROFILE=dev $(MAKE) wasm cd $(PLAYGROUND) && yarn install --frozen-lockfile cd $(DOTLI) && bun install --frozen-lockfile $(MAKE) dev-link-check -dev-link-check: ## Verify dotli can resolve the local @parity/truapi-host package. +dev-link-check: dotli-link ## Verify dotli can resolve the local @parity/truapi-host package. @test -f "$(HOST_CALLBACKS_GENERATED)" || (echo "Missing generated host callbacks. Run: make codegen"; exit 1) @test -f "$(HOST_WASM_ADAPTER_GENERATED)" || (echo "Missing generated host callbacks WASM adapter. Run: make codegen"; exit 1) @test -f "$(HOST_WASM_WORKER_CALLBACKS_GENERATED)" || (echo "Missing generated host callbacks worker bridge. Run: make codegen"; exit 1) @test -f "$(HOST_WASM_PKG)/dist/index.js" || (echo "Missing @parity/truapi-host dist. Run: npm run build --prefix $(HOST_WASM_PKG)"; exit 1) @test -f "$(HOST_WASM_WEB)" || (echo "Missing @parity/truapi-host web WASM glue. Run: make wasm"; exit 1) - @test -e "$(DOTLI_HOST_WASM_LINK)/package.json" || (echo "dotli cannot resolve @parity/truapi-host. Run top-level: make dev"; exit 1) + @test -e "$(DOTLI_TRUAPI_LINK)/package.json" || (echo "dotli cannot resolve @parity/truapi. Run top-level: make dotli-link"; exit 1) + @test -e "$(DOTLI_HOST_WASM_LINK)/package.json" || (echo "dotli cannot resolve @parity/truapi-host. Run top-level: make dotli-link"; exit 1) + @test ! -e "$(DOTLI_UI_TRUAPI_SHADOW)/package.json" || (echo "$(DOTLI_UI_TRUAPI_SHADOW) shadows the local workspace link. Run top-level: make dotli-link"; exit 1) + @test ! -e "$(DOTLI_UI_HOST_WASM_SHADOW)/package.json" || (echo "$(DOTLI_UI_HOST_WASM_SHADOW) shadows the local workspace link. Run top-level: make dotli-link"; exit 1) + @node -e 'const fs = require("node:fs"); const checks = [["$(DOTLI_TRUAPI_LINK)/package.json", "@parity/truapi"], ["$(DOTLI_HOST_WASM_LINK)/package.json", "@parity/truapi-host"]]; for (const [path, name] of checks) { const pkg = JSON.parse(fs.readFileSync(path, "utf8")); if (pkg.name !== name) { console.error(path + " resolves " + pkg.name + ", expected local " + name + ". Run: make dotli-link"); process.exit(1); } }' cd $(DOTLI_UI) && bun -e 'await import("@parity/truapi-host"); await import("@parity/truapi-host/web");' dev: dev-bootstrap ## Start dotli host (:5173) + playground (:3000) together; open http://localhost:5173/localhost:3000. DEBUG=1 logs wire frames. @@ -128,12 +143,14 @@ e2e-dotli: ## Fully automated dotli + playground diagnosis e2e. Requires SIGNER_ @SIGNER_BOT_SVC_TOKEN_ENV="$$SIGNER_BOT_SVC_TOKEN"; \ SIGNER_BOT_BASE_URL_ENV="$$SIGNER_BOT_BASE_URL"; \ SIGNER_BOT_NETWORK_ENV="$$SIGNER_BOT_NETWORK"; \ + SIGNER_BOT_BASE_URL_ORIGIN="$(SIGNER_BOT_BASE_URL_ORIGIN)"; \ + SIGNER_BOT_NETWORK_ORIGIN="$(SIGNER_BOT_NETWORK_ORIGIN)"; \ set -a; \ if [ -f .env ]; then . ./.env; fi; \ set +a; \ if [ -n "$$SIGNER_BOT_SVC_TOKEN_ENV" ]; then SIGNER_BOT_SVC_TOKEN="$$SIGNER_BOT_SVC_TOKEN_ENV"; export SIGNER_BOT_SVC_TOKEN; fi; \ - if [ -n "$$SIGNER_BOT_BASE_URL_ENV" ]; then SIGNER_BOT_BASE_URL="$$SIGNER_BOT_BASE_URL_ENV"; export SIGNER_BOT_BASE_URL; fi; \ - if [ -n "$$SIGNER_BOT_NETWORK_ENV" ]; then SIGNER_BOT_NETWORK="$$SIGNER_BOT_NETWORK_ENV"; export SIGNER_BOT_NETWORK; fi; \ + if [ "$$SIGNER_BOT_BASE_URL_ORIGIN" != "file" ] && [ -n "$$SIGNER_BOT_BASE_URL_ENV" ]; then SIGNER_BOT_BASE_URL="$$SIGNER_BOT_BASE_URL_ENV"; export SIGNER_BOT_BASE_URL; fi; \ + if [ "$$SIGNER_BOT_NETWORK_ORIGIN" != "file" ] && [ -n "$$SIGNER_BOT_NETWORK_ENV" ]; then SIGNER_BOT_NETWORK="$$SIGNER_BOT_NETWORK_ENV"; export SIGNER_BOT_NETWORK; fi; \ if [ "$$E2E_DOTLI_SMOKE" != "1" ]; then test -n "$$SIGNER_BOT_SVC_TOKEN" || (echo "Missing SIGNER_BOT_SVC_TOKEN. e2e-dotli requires signer-bot; without it a human phone scan is required."; exit 1); fi; \ $(MAKE) dev-bootstrap; \ cd $(PLAYGROUND) && bun tests/e2e/dotli-diagnosis.ts diff --git a/hosts/dotli b/hosts/dotli index 4812841e..9f27a073 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit 4812841ea9d727e96fa241b3340c0296558e6a8a +Subproject commit 9f27a0731d81bb00bf6b6afd25e09202073d9c0a diff --git a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts index aa4fd591..f35b277c 100644 --- a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -12,7 +12,7 @@ import { RemotePermissionResponse, ThemeVariant, } from "@parity/truapi"; -import type { HostSignPayloadData } from "@parity/truapi"; +import type { GenericError, HostSignPayloadData } from "@parity/truapi"; import { createWasmRawCallbacks } from "./generated/host-callbacks-adapter.js"; import { @@ -208,10 +208,6 @@ describe("createWasmRawCallbacks", () => { }, }, preimage: { - submitPreimage: async (value) => { - calls.push(["submitPreimage", [...value]]); - return new Uint8Array([7, 8, 9]); - }, lookupPreimage: (key) => { calls.push(["lookupPreimage", [...key]]); return preimages(); @@ -325,9 +321,6 @@ describe("createWasmRawCallbacks", () => { }), ), ).toBe(true); - expect(await raw.submitPreimage!(new Uint8Array([6]))).toEqual( - new Uint8Array([7, 8, 9]), - ); await settle(); @@ -341,7 +334,6 @@ describe("createWasmRawCallbacks", () => { ["writeCoreStorage", { tag: "AuthSession", value: undefined }, [3, 2, 1]], ["clearCoreStorage", { tag: "AuthSession", value: undefined }], ["confirmUserAction:PreimageSubmit", 42n], - ["submitPreimage", [6]], ]); disposePreimages?.(); diff --git a/js/packages/truapi-host/src/runtime.ts b/js/packages/truapi-host/src/runtime.ts index d91872eb..8c4b0fb9 100644 --- a/js/packages/truapi-host/src/runtime.ts +++ b/js/packages/truapi-host/src/runtime.ts @@ -54,21 +54,39 @@ export interface ChainConnection { */ export type LogLevel = string; +/** Configuration for one product runtime hosted by the wasm core. */ export interface ProductRuntimeConfig { + /** Stable identifier used to scope product accounts, permissions, and storage. */ productId: string; + /** Metadata describing the host application. */ host: { + /** Human-readable host name. */ name: string; + /** Host icon URL. */ icon?: string; + /** Host application version. */ version?: string; }; + /** Metadata describing the platform running the host. */ platform?: { + /** Platform or operating-system name. */ type?: string; + /** Platform or operating-system version. */ version?: string; }; + /** People-chain configuration used for identity lookup. */ people: { + /** People-chain genesis hash. */ genesisHash: string | Uint8Array; }; + /** Bulletin-chain configuration used for in-core preimage submission. */ + bulletin: { + /** Bulletin-chain genesis hash. */ + genesisHash: string | Uint8Array; + }; + /** Wallet pairing configuration. */ pairing: { + /** URI scheme used for wallet pairing deeplinks. */ deeplinkScheme: string; }; } diff --git a/js/packages/truapi-host/src/test-support.ts b/js/packages/truapi-host/src/test-support.ts index f6e0240b..6964e2e4 100644 --- a/js/packages/truapi-host/src/test-support.ts +++ b/js/packages/truapi-host/src/test-support.ts @@ -35,7 +35,6 @@ export function makeHostCallbacks( auth: { authStateChanged: () => {} }, userConfirmation: { confirmUserAction: async () => false }, preimage: { - submitPreimage: async () => new Uint8Array(), async *lookupPreimage() {}, }, theme: { async *subscribeTheme() {} }, 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 1e69dd8d..8ea3eb86 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 @@ -92,7 +92,6 @@ interface RuntimeState { number, { resolve: () => void; reject: (error: Error) => void } >; - pendingBulletinAllowanceSigns: Map>; closedError: Error | null; logLevel: LogLevel; disposed: boolean; @@ -105,13 +104,6 @@ function debugLoggingEnabled(state: RuntimeState): boolean { let nextDisconnectRequestId = 0; let nextPermissionAuthorizationRequestId = 0; -let nextBulletinAllowanceSignRequestId = 0; - -interface WorkerBulletinAllowanceSigner { - publicKey: Uint8Array; - signerId: number; -} - function encodePermissionAuthorizationRequest( request: PermissionAuthorizationRequest, ): Uint8Array { @@ -160,9 +152,8 @@ function handleCallbackRequest( } satisfies MainToWorker); return; } - const args = reviveCallbackArgs(state, msg.name, msg.args); Promise.resolve() - .then(() => fn(...args)) + .then(() => fn(...msg.args)) .then( (value) => { state.worker.postMessage({ @@ -183,60 +174,6 @@ function handleCallbackRequest( ); } -function reviveCallbackArgs( - state: RuntimeState, - name: CallbackName, - args: readonly unknown[], -): readonly unknown[] { - if (name !== "submitPreimage") return args; - return args.map((arg, index) => { - if (index !== 1 || !isWorkerBulletinAllowanceSigner(arg)) return arg; - return { - publicKey: arg.publicKey, - sign: (input: Uint8Array) => - signBulletinAllowance(state, arg.signerId, input), - }; - }); -} - -function isWorkerBulletinAllowanceSigner( - value: unknown, -): value is WorkerBulletinAllowanceSigner { - return ( - typeof value === "object" && - value !== null && - "publicKey" in value && - value.publicKey instanceof Uint8Array && - "signerId" in value && - typeof value.signerId === "number" - ); -} - -function signBulletinAllowance( - state: RuntimeState, - signerId: number, - input: Uint8Array, -): Promise { - if (state.disposed) { - return Promise.reject(state.closedError ?? new Error("runtime disposed")); - } - return new Promise((resolve, reject) => { - const requestId = ++nextBulletinAllowanceSignRequestId; - state.pendingBulletinAllowanceSigns.set(requestId, { resolve, reject }); - try { - state.worker.postMessage({ - kind: "signBulletinAllowance", - requestId, - signerId, - input, - } satisfies MainToWorker); - } catch (err) { - state.pendingBulletinAllowanceSigns.delete(requestId); - reject(err instanceof Error ? err : new Error(String(err))); - } - }); -} - function handleSubscriptionStart( state: RuntimeState, msg: { @@ -451,7 +388,6 @@ function rejectPendingRuntimeRequests(state: RuntimeState, error: Error): void { rejectAll(state.pendingPermissionAuthorizationStatuses, error); rejectAll(state.pendingPermissionAuthorizationStatusBatches, error); rejectAll(state.pendingSetPermissionAuthorizationStatuses, error); - rejectAll(state.pendingBulletinAllowanceSigns, error); for (const pending of state.pendingCores.values()) { pending.reject(error); } @@ -551,7 +487,6 @@ export function createWebWorkerPairingHostRuntime( pendingPermissionAuthorizationStatuses: new Map(), pendingPermissionAuthorizationStatusBatches: new Map(), pendingSetPermissionAuthorizationStatuses: new Map(), - pendingBulletinAllowanceSigns: new Map(), closedError: null, logLevel: devLogLevelOverride ?? options.logLevel ?? "off", disposed: false, @@ -613,15 +548,6 @@ export function createWebWorkerPairingHostRuntime( } handleCallbackRequest(state, msg); break; - case "signBulletinAllowanceResponse": - settlePending( - state.pendingBulletinAllowanceSigns, - msg.requestId, - msg.ok - ? { ok: true, value: msg.signature } - : { ok: false, error: msg.error }, - ); - break; case "subscriptionStart": handleSubscriptionStart(state, msg); break; 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 39ae36a4..3c75785d 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -90,6 +90,10 @@ function runtimeConfig( genesisHash: "0xa22a2424d2cbf561eaecf7da8b1b548fa9d1939f60265e942b1049616a012f71", }, + bulletin: { + genesisHash: + "0xbbcccc1cbe333151b8ed63b17e9e0dec61ee53b57296f1fbe2d161ae3e6fb4dc", + }, pairing: { deeplinkScheme: "polkadotapp", }, @@ -521,76 +525,6 @@ describe("createWebWorkerPairingHostRuntime", () => { provider.dispose(); }); - it("revives Bulletin allowance signer handles for submitPreimage", async () => { - const worker = new FakeWorker(); - const publicKey = new Uint8Array(32); - publicKey.set([1, 2, 3]); - const value = new Uint8Array([10, 11, 12]); - const signingPayload = new Uint8Array([4, 5, 6]); - const signature = new Uint8Array(64); - signature.set([9, 8, 7]); - const result = new Uint8Array([30, 31, 32]); - const seen: { - publicKey?: Uint8Array; - signature?: Uint8Array; - value?: Uint8Array; - } = {}; - - const submitPreimage: PreimageHost["submitPreimage"] = async ( - submittedValue, - signer, - ) => { - seen.value = submittedValue; - seen.publicKey = signer.publicKey; - seen.signature = await signer.sign(signingPayload); - return result; - }; - const providerPromise = createProviderFromRuntime( - asWorker(worker), - makeHostCallbacks({ preimage: { submitPreimage } }), - { runtimeConfig: runtimeConfig() }, - ); - worker.emit({ kind: "loaded" }); - worker.emit({ kind: "ready" }); - const provider = await finishProviderReady(worker, providerPromise); - - worker.emit({ - kind: "callbackRequest", - requestId: 21, - name: "submitPreimage", - args: [value, { publicKey, signerId: 7 }], - }); - await settle(); - - const signRequest = lastMessageOfKind(worker, "signBulletinAllowance"); - expect(signRequest.kind).toBe("signBulletinAllowance"); - expect(signRequest.signerId).toBe(7); - expect(signRequest.input).toEqual(signingPayload); - expect(typeof signRequest.requestId).toBe("number"); - - worker.emit({ - kind: "signBulletinAllowanceResponse", - requestId: signRequest.requestId, - ok: true, - signature, - }); - await settle(); - - expect(seen).toEqual({ - value, - publicKey, - signature, - }); - expect(worker.messages.at(-1)).toEqual({ - kind: "callbackResponse", - requestId: 21, - ok: true, - value: result, - }); - - provider.dispose(); - }); - it("posts cancelPairing to the worker", async () => { const worker = new FakeWorker(); const config = runtimeConfig(); diff --git a/js/packages/truapi-host/src/worker-protocol.ts b/js/packages/truapi-host/src/worker-protocol.ts index 193a4b83..cdb3ea58 100644 --- a/js/packages/truapi-host/src/worker-protocol.ts +++ b/js/packages/truapi-host/src/worker-protocol.ts @@ -84,12 +84,6 @@ export type MainToWorker = } | { kind: "callbackResponse"; requestId: number; ok: true; value: unknown } | { kind: "callbackResponse"; requestId: number; ok: false; error: string } - | { - kind: "signBulletinAllowance"; - requestId: number; - signerId: number; - input: Uint8Array; - } | { kind: "subscriptionItem"; subId: number; value: unknown } | { kind: "subscriptionError"; subId: number; error: string } | { kind: "chainConnectAck"; connId: number; ok: true } @@ -159,18 +153,6 @@ export type WorkerToMain = name: CallbackName; args: CallbackArgs; } - | { - kind: "signBulletinAllowanceResponse"; - requestId: number; - ok: true; - signature: Uint8Array; - } - | { - kind: "signBulletinAllowanceResponse"; - requestId: number; - ok: false; - error: string; - } | { kind: "subscriptionStart"; subId: number; diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index 8be5bd41..c986b9a5 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -79,19 +79,6 @@ const pendingCallbacks = new Map< (result: { ok: true; value: unknown } | { ok: false; error: string }) => void >(); -interface BulletinAllowanceSigner { - publicKey: Uint8Array; - sign(input: Uint8Array): Promise; -} - -interface BulletinAllowanceSignerHandle { - publicKey: Uint8Array; - signerId: number; -} - -let nextBulletinAllowanceSignerId = 0; -const bulletinAllowanceSigners = new Map(); - let nextSubId = 0; const subscriptionListeners = new Map(); @@ -106,11 +93,7 @@ function callbackRequest( ): Promise { return new Promise((resolve, reject) => { const requestId = ++nextRequestId; - const signerIds = collectBulletinAllowanceSignerIds(args); pendingCallbacks.set(requestId, (r) => { - for (const signerId of signerIds) { - bulletinAllowanceSigners.delete(signerId); - } if (r.ok) resolve(r.value); else reject(new Error(r.error)); }); @@ -118,28 +101,6 @@ function callbackRequest( }); } -function registerBulletinAllowanceSigner( - signer: BulletinAllowanceSigner, -): BulletinAllowanceSignerHandle { - const signerId = ++nextBulletinAllowanceSignerId; - bulletinAllowanceSigners.set(signerId, signer); - return { publicKey: signer.publicKey, signerId }; -} - -function collectBulletinAllowanceSignerIds(args: readonly unknown[]): number[] { - return args.flatMap((arg) => { - if ( - typeof arg === "object" && - arg !== null && - "signerId" in arg && - typeof arg.signerId === "number" - ) { - return [arg.signerId]; - } - return []; - }); -} - function startSubscription( name: SubscriptionName, payload: Uint8Array | null, @@ -221,7 +182,6 @@ function chainConnect( function buildRawCallbacks() { return createWorkerRawCallbacks({ callbackRequest, - registerBulletinAllowanceSigner, startSubscription, chainConnect, }); @@ -360,9 +320,6 @@ ctx.addEventListener("message", (ev: MessageEvent) => { } break; } - case "signBulletinAllowance": - void handleSignBulletinAllowance(msg.requestId, msg.signerId, msg.input); - break; case "subscriptionItem": { dispatchSubscriptionItem( msg.subId, @@ -410,7 +367,6 @@ ctx.addEventListener("message", (ev: MessageEvent) => { } catch (err) { postToMain({ kind: "disposeError", error: errorMessage(err) }); } - bulletinAllowanceSigners.clear(); runtime = null; break; default: { @@ -434,40 +390,6 @@ function disposeCore(coreId: number): void { } } -async function handleSignBulletinAllowance( - requestId: number, - signerId: number, - input: Uint8Array, -): Promise { - const signer = bulletinAllowanceSigners.get(signerId); - if (!signer) { - postToMain({ - kind: "signBulletinAllowanceResponse", - requestId, - ok: false, - error: `unknown Bulletin allowance signer: ${signerId}`, - }); - return; - } - - try { - const signature = await signer.sign(input); - postToMain({ - kind: "signBulletinAllowanceResponse", - requestId, - ok: true, - signature, - }); - } catch (err) { - postToMain({ - kind: "signBulletinAllowanceResponse", - requestId, - ok: false, - error: errorMessage(err), - }); - } -} - async function handleDisconnectSession(requestId: number): Promise { if (!runtime) { postToMain({ diff --git a/js/packages/truapi/scripts/ensure-generated.sh b/js/packages/truapi/scripts/ensure-generated.sh index 6518aee5..43c4d9a2 100755 --- a/js/packages/truapi/scripts/ensure-generated.sh +++ b/js/packages/truapi/scripts/ensure-generated.sh @@ -5,7 +5,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" cd "$ROOT" -required=( +codegen_required=( "js/packages/truapi/src/generated/client.ts" "js/packages/truapi/src/generated/types.ts" "js/packages/truapi/src/generated/wire-table.ts" @@ -13,16 +13,28 @@ required=( "js/packages/truapi/src/explorer/codegen/types.ts" "js/packages/truapi/src/explorer/versions.ts" ) +truapi_dts="js/packages/truapi/src/playground/codegen/truapi-dts.ts" missing=0 -for path in "${required[@]}"; do +for path in "${codegen_required[@]}"; do if [ ! -f "$path" ]; then missing=1 break fi done -if [ "$missing" -eq 0 ] && find playground/test/generated/examples -name '*.ts' -print -quit >/dev/null 2>&1; then +if [ "$missing" -eq 1 ] || ! find playground/test/generated/examples -name '*.ts' -print -quit >/dev/null 2>&1; then + if [ "${TRUAPI_REQUIRE_GENERATED:-0}" = "1" ]; then + echo "ensure-generated: generated files are missing and TRUAPI_REQUIRE_GENERATED=1, so codegen will not run." >&2 + echo "These files are expected to be restored from the 'codegen-output' CI artifact." >&2 + echo "If you added a generated output, add its path to the upload-artifact step in .github/workflows/ci.yml." >&2 + exit 1 + fi + + TRUAPI_SKIP_PACKAGE_BUILD=1 ./scripts/codegen.sh +fi + +if [ -f "$truapi_dts" ]; then exit 0 fi @@ -33,4 +45,14 @@ if [ "${TRUAPI_REQUIRE_GENERATED:-0}" = "1" ]; then exit 1 fi -TRUAPI_SKIP_PACKAGE_BUILD=1 ./scripts/codegen.sh +if [ -x node_modules/.bin/tsc ]; then + tsc_bin="node_modules/.bin/tsc" +elif [ -x js/packages/truapi/node_modules/.bin/tsc ]; then + tsc_bin="js/packages/truapi/node_modules/.bin/tsc" +else + echo "ensure-generated: cannot find tsc. Run npm install from the repo root first." >&2 + exit 1 +fi + +"$tsc_bin" -b js/packages/truapi --force +node scripts/bundle-truapi-dts.mjs diff --git a/playground/src/lib/auto-test.ts b/playground/src/lib/auto-test.ts index 12d901a1..56ed0bbb 100644 --- a/playground/src/lib/auto-test.ts +++ b/playground/src/lib/auto-test.ts @@ -39,8 +39,8 @@ const LONG_TIMEOUT_METHODS = new Set([ const METHOD_TIMEOUT_MS = new Map([ ["Account/get_account_alias", SSO_TIMEOUT_MS], ["Resource Allocation/request", LIVE_ALLOCATION_TIMEOUT_MS], - ["Preimage/lookup_subscribe", SSO_TIMEOUT_MS], - ["Preimage/submit", SSO_TIMEOUT_MS], + ["Preimage/lookup_subscribe", LIVE_ALLOCATION_TIMEOUT_MS], + ["Preimage/submit", LIVE_ALLOCATION_TIMEOUT_MS], ["Signing/create_transaction", SSO_TIMEOUT_MS], ["Statement Store/create_proof_authorized", LIVE_ALLOCATION_TIMEOUT_MS], ["Statement Store/submit", LIVE_ALLOCATION_TIMEOUT_MS], diff --git a/playground/tests/e2e/dotli-diagnosis.ts b/playground/tests/e2e/dotli-diagnosis.ts index 3e9521d6..f0c9c2b4 100644 --- a/playground/tests/e2e/dotli-diagnosis.ts +++ b/playground/tests/e2e/dotli-diagnosis.ts @@ -41,6 +41,7 @@ const loginUserBadgeTimeoutMs = Number( const botToken = readEnv("SIGNER_BOT_SVC_TOKEN"); const botBase = process.env.SIGNER_BOT_BASE_URL ?? defaultBotBase; const botNetwork = process.env.SIGNER_BOT_NETWORK ?? defaultBotNetwork; +const botUsername = process.env.SIGNER_BOT_USERNAME; const serverProcesses: ChildProcess[] = []; const pageErrors: string[] = []; @@ -250,7 +251,7 @@ async function openLoginQr(page: Page): Promise { async function signInWithBot(page: Page): Promise { const { token, base, network } = requireBotEnv(); const handshake = await openLoginQr(page); - const username = generateUsername(); + const username = botUsername ?? generateUsername(); console.log(`[e2e-dotli] pairing signer-bot user ${username}`); const result = await pair(base, token, { handshake, @@ -488,6 +489,7 @@ async function runDiagnosis(page: Page): Promise<{ summary: string; report: string; copyReportClicked: boolean; + failedMethods: string[]; }> { for (let attempt = 1; attempt <= 2; attempt++) { try { @@ -510,6 +512,7 @@ async function runDiagnosisOnce(page: Page): Promise<{ summary: string; report: string; copyReportClicked: boolean; + failedMethods: string[]; }> { const frame = await findPlaygroundFrame(page); await captureStep(page, "diagnosis-ready"); @@ -531,10 +534,13 @@ async function runDiagnosisOnce(page: Page): Promise<{ if (report.trim().length === 0) { throw new Error("diagnosis report markdown is empty"); } + const failedMethods = await frame + .locator('[data-testid="diagnosis-row"][data-status="fail"] .diag__name') + .allInnerTexts(); await frame.locator('[data-testid="diagnosis-copy-report"]').click(); - return { summary, report, copyReportClicked: true }; + return { summary, report, copyReportClicked: true, failedMethods }; } async function waitForDiagnosisReportReady(frame: Frame): Promise { @@ -701,7 +707,8 @@ async function main(): Promise { pairResult = await signInWithBot(page); const stopClicker = startHostModalClicker(page); try { - const { summary, report, copyReportClicked } = await runDiagnosis(page); + const { summary, report, copyReportClicked, failedMethods } = + await runDiagnosis(page); const reportPath = resolve(outputDir, "diagnosis-report.md"); writeFileSync(reportPath, report); pairResult = await assertHostSignOutAndReconnect(page); @@ -711,6 +718,7 @@ async function main(): Promise { `${JSON.stringify( { summary, + failedMethods, reportPath, copyReportClicked, screenshots, @@ -726,6 +734,11 @@ async function main(): Promise { ); console.log(`[e2e-dotli] diagnosis complete: ${summary}`); console.log(`[e2e-dotli] report: ${reportPath}`); + if (failedMethods.length > 0) { + throw new Error( + `diagnosis reported failed methods: ${failedMethods.join(", ")}`, + ); + } if (pageErrors.length > 0) { throw new Error(`browser page errors occurred: ${pageErrors.length}`); } diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index 3d476692..d4c18d72 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -36,10 +36,9 @@ pub fn generate_wasm_bridge( use wasm_bindgen::JsValue; use super::{{ - WasmPlatform, bulletin_allowance_signer_to_js, call_js_function, decode_bytes, - decode_js_item, generic, get_function, invoke_bool, invoke_bytes_return, - invoke_js_subscription, invoke_optional_bytes_return, invoke_unit, - parse_optional_bytes_item, + WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, + invoke_bool, invoke_bytes_return, invoke_js_subscription, invoke_optional_bytes_return, + invoke_unit, parse_optional_bytes_item, }}; /// JS-side callbacks invoked by the wasm platform bridge. Methods with @@ -525,12 +524,6 @@ fn js_arg_expr(name: &str, ty: &TypeRef, ctx: &BridgeCtx<'_>) -> Result if is_bytes(ty) { return Ok(format!("Uint8Array::from({name}.as_slice()).into()")); } - if is_callback_byte_type(ty) { - return Ok(format!("Uint8Array::from({name}.as_secret_bytes()).into()")); - } - if is_callback_signer_type(ty) { - return Ok(format!("bulletin_allowance_signer_to_js({name})")); - } if ctx.is_api_codec(ty) || ctx.is_local_codec(ty) { return Ok(format!( "Uint8Array::from({name}.encode().as_slice()).into()" @@ -829,7 +822,7 @@ fn collect_local_from_type<'a>( ) { match ty { TypeRef::Named { name, args } => { - if local.contains(name.as_str()) && !is_callback_special_type_name(name) { + if local.contains(name.as_str()) { out.insert(name); } for arg in args { @@ -847,23 +840,3 @@ fn collect_local_from_type<'a>( TypeRef::Primitive(_) | TypeRef::Generic(_) | TypeRef::Unit => {} } } - -fn is_callback_byte_type(ty: &TypeRef) -> bool { - matches!(ty, TypeRef::Named { name, .. } if is_callback_byte_type_name(name)) -} - -fn is_callback_signer_type(ty: &TypeRef) -> bool { - matches!(ty, TypeRef::Named { name, .. } if is_callback_signer_type_name(name)) -} - -fn is_callback_special_type_name(name: &str) -> bool { - is_callback_byte_type_name(name) || is_callback_signer_type_name(name) -} - -fn is_callback_byte_type_name(name: &str) -> bool { - name == "BulletinAllowanceKey" -} - -fn is_callback_signer_type_name(name: &str) -> bool { - name == "BulletinAllowanceSigner" -} diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index b0b0b958..8ae1fcb5 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -125,16 +125,7 @@ fn emit_host_callbacks( out.push('\n'); } - if has_callback_signer_type(definition) { - out.push_str(&emit_callback_signer_interface()); - out.push('\n'); - } - - for type_def in definition - .types - .iter() - .filter(|ty| !is_callback_special_type_name(&ty.name)) - { + for type_def in &definition.types { let rendered = match &type_def.kind { TypeDefKind::Enum(_) => emit_enum_type(type_def)?, _ => emit_struct_interface(type_def)?, @@ -266,7 +257,6 @@ fn emit_wasm_adapter( out, r#" import type {{ - BulletinAllowanceSigner, RequiredHostCallbacks, }} from "./host-callbacks.js"; @@ -364,21 +354,6 @@ fn emit_worker_callbacks( "# ) .unwrap(); - if has_callback_signer_type(definition) { - writedoc!( - out, - r#" - import type {{ BulletinAllowanceSigner }} from "./host-callbacks.js"; - - export interface WorkerBulletinAllowanceSigner {{ - publicKey: Uint8Array; - signerId: number; - }} - - "# - ) - .unwrap(); - } emit_import_block(&mut out, true, "../runtime.js", &runtime_types); if !runtime_types.is_empty() { out.push('\n'); @@ -393,13 +368,6 @@ fn emit_worker_callbacks( out.push_str( " callbackRequest(name: CallbackName, args: readonly unknown[]): Promise;\n", ); - if has_callback_signer_type(definition) { - writeln!( - out, - " registerBulletinAllowanceSigner(signer: BulletinAllowanceSigner): WorkerBulletinAllowanceSigner;" - ) - .unwrap(); - } out.push_str(" startSubscription(\n"); out.push_str(" name: SubscriptionName,\n"); out.push_str(" payload: Uint8Array | null,\n"); @@ -519,24 +487,10 @@ fn emit_worker_callback_entry(method: &PlatformMethod) -> Result { .map(|p| to_camel_case(&p.name)) .collect::>() .join(", "); - let arg_exprs = method - .params - .iter() - .map(|p| { - let name = to_camel_case(&p.name); - if matches!(&p.type_ref, TypeRef::Named { name, .. } if is_callback_signer_type_name(name)) - { - format!("bridge.registerBulletinAllowanceSigner({name})") - } else { - name - } - }) - .collect::>() - .join(", "); - let arg_array = if arg_exprs.is_empty() { + let arg_array = if args.is_empty() { "[]".to_string() } else { - format!("[{arg_exprs}]") + format!("[{args}]") }; if method.return_shape.is_async { Ok(format!( @@ -738,10 +692,6 @@ fn raw_param_ts( local_codec_types: &BTreeSet, ) -> String { match ty { - TypeRef::Named { name, .. } if is_callback_byte_type_name(name) => "Uint8Array".to_string(), - TypeRef::Named { name, .. } if is_callback_signer_type_name(name) => { - "BulletinAllowanceSigner".to_string() - } TypeRef::Named { name, .. } if codec_types.contains(name) => "Uint8Array".to_string(), TypeRef::Named { name, .. } if local_codec_types.contains(name) => "Uint8Array".to_string(), TypeRef::Named { name, .. } => name.clone(), @@ -766,10 +716,6 @@ fn raw_ok_ts( local_codec_types: &BTreeSet, ) -> String { match ty { - TypeRef::Named { name, .. } if is_callback_byte_type_name(name) => "Uint8Array".to_string(), - TypeRef::Named { name, .. } if is_callback_signer_type_name(name) => { - "BulletinAllowanceSigner".to_string() - } TypeRef::Named { name, .. } if codec_types.contains(name) => "Uint8Array".to_string(), TypeRef::Named { name, .. } if local_codec_types.contains(name) => "Uint8Array".to_string(), TypeRef::Named { name, .. } => name.clone(), @@ -851,7 +797,6 @@ fn adapter_arg( ) -> String { let name = to_camel_case(¶m.name); match ¶m.type_ref { - TypeRef::Named { name: ty, .. } if is_callback_special_type_name(ty) => name, TypeRef::Named { name: ty, .. } if codec_types.contains(ty) || local_codec_types.contains(ty) => { @@ -1174,7 +1119,7 @@ fn walk_type_def( fn collect_local_from_type(ty: &TypeRef, local: &BTreeSet, out: &mut BTreeSet) { match ty { TypeRef::Named { name, args } => { - if local.contains(name) && !is_callback_special_type_name(name) { + if local.contains(name) { out.insert(name.clone()); } for arg in args { @@ -1599,9 +1544,6 @@ fn collect_named_types(definition: &PlatformDefinition) -> BTreeSet { } } for type_def in &definition.types { - if is_callback_special_type_name(&type_def.name) { - continue; - } collect_from_type_def(type_def, &mut out); } // Filter out names defined locally (the capability trait interfaces and @@ -1655,12 +1597,6 @@ fn ts_type(ty: &TypeRef) -> Result { _ => bail!("Unsupported primitive type `{name}` in host callbacks generation"), }, TypeRef::Named { name, args } => { - if is_callback_byte_type_name(name) { - return Ok("Uint8Array".to_string()); - } - if is_callback_signer_type_name(name) { - return Ok("BulletinAllowanceSigner".to_string()); - } if args.is_empty() { Ok(name.clone()) } else { @@ -1698,36 +1634,6 @@ fn ts_type(ty: &TypeRef) -> Result { } } -fn is_callback_byte_type_name(name: &str) -> bool { - name == "BulletinAllowanceKey" -} - -fn is_callback_signer_type_name(name: &str) -> bool { - name == "BulletinAllowanceSigner" -} - -fn is_callback_special_type_name(name: &str) -> bool { - is_callback_byte_type_name(name) || is_callback_signer_type_name(name) -} - -fn has_callback_signer_type(definition: &PlatformDefinition) -> bool { - definition - .types - .iter() - .any(|ty| is_callback_signer_type_name(&ty.name)) -} - -fn emit_callback_signer_interface() -> String { - formatdoc! { - r#" - export interface BulletinAllowanceSigner {{ - publicKey: Uint8Array; - sign(input: Uint8Array): Promise; - }} - "# - } -} - fn render_jsdoc(indent: &str, docs: Option<&str>) -> String { let Some(docs) = docs else { return String::new(); diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts index 1ac2e496..4a6ff49d 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -22,10 +22,7 @@ import { CoreStorageKey, UserConfirmationReview, } from "./host-callbacks.js"; -import type { - BulletinAllowanceSigner, - RequiredHostCallbacks, -} from "./host-callbacks.js"; +import type { RequiredHostCallbacks } from "./host-callbacks.js"; import type { ChainConnect } from "../runtime.js"; import { chainConnectAdapter, driveResultStream } from "../adapter-support.js"; @@ -42,10 +39,6 @@ export interface RawCallbacks { cancelNotification(id: NotificationId): Promise; devicePermission(request: Uint8Array): Promise; remotePermission(request: Uint8Array): Promise; - submitPreimage( - value: Uint8Array, - bulletinAllowanceSigner: BulletinAllowanceSigner, - ): Promise; lookupPreimage( key: Uint8Array, sendItem: (item?: Uint8Array) => void, @@ -105,8 +98,6 @@ export function createWasmRawCallbacks( RemotePermissionRequest.dec(request), ), ), - submitPreimage: async (value, bulletinAllowanceSigner) => - await callbacks.preimage.submitPreimage(value, bulletinAllowanceSigner), lookupPreimage: (key, sendItem, sendError) => driveResultStream( callbacks.preimage.lookupPreimage(key), diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index d4365287..a70a4b79 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -31,11 +31,6 @@ import type { ThemeVariant, } from "@parity/truapi"; -export interface BulletinAllowanceSigner { - publicKey: Uint8Array; - sign(input: Uint8Array): Promise; -} - /** * Review shown before a product asks to access another product account. */ @@ -639,18 +634,11 @@ export interface Permissions { } /** - * Host preimage backend. The core owns wire mapping and subscription - * lifecycle; the host owns the selected backend. + * 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 { - /** - * Submit the preimage and return its key. - */ - submitPreimage?( - value: Uint8Array, - bulletinAllowanceSigner: BulletinAllowanceSigner, - ): Promise; - /** * Emits current value/miss immediately, then future updates. */ diff --git a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs index e01cf767..c08a4868 100644 --- a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs @@ -11,9 +11,9 @@ use truapi::v01; use wasm_bindgen::JsValue; use super::{ - WasmPlatform, bulletin_allowance_signer_to_js, call_js_function, decode_bytes, decode_js_item, - generic, get_function, invoke_bool, invoke_bytes_return, invoke_js_subscription, - invoke_optional_bytes_return, invoke_unit, parse_optional_bytes_item, + WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, + invoke_bool, invoke_bytes_return, invoke_js_subscription, invoke_optional_bytes_return, + invoke_unit, parse_optional_bytes_item, }; /// JS-side callbacks invoked by the wasm platform bridge. Methods with @@ -32,7 +32,6 @@ pub(super) struct JsBridge { pub(super) cancel_notification: Function, pub(super) device_permission: Function, pub(super) remote_permission: Function, - pub(super) submit_preimage: Function, pub(super) lookup_preimage: Function, pub(super) read: Function, pub(super) write: Function, @@ -55,7 +54,6 @@ impl JsBridge { cancel_notification: get_function(callbacks, "cancelNotification")?, device_permission: get_function(callbacks, "devicePermission")?, remote_permission: get_function(callbacks, "remotePermission")?, - submit_preimage: get_function(callbacks, "submitPreimage")?, lookup_preimage: get_function(callbacks, "lookupPreimage")?, read: get_function(callbacks, "read")?, write: get_function(callbacks, "write")?, @@ -216,24 +214,7 @@ impl truapi_platform::Permissions for WasmPlatform { } } -#[truapi_platform::async_trait] impl truapi_platform::PreimageHost for WasmPlatform { - async fn submit_preimage( - &self, - value: Vec, - bulletin_allowance_signer: truapi_platform::BulletinAllowanceSigner, - ) -> Result, v01::PreimageSubmitError> { - invoke_bytes_return( - &self.bridge.submit_preimage, - vec![ - Uint8Array::from(value.as_slice()).into(), - bulletin_allowance_signer_to_js(bulletin_allowance_signer), - ], - ) - .await - .map_err(|reason| v01::PreimageSubmitError::Unknown { reason }) - } - fn lookup_preimage( &self, key: Vec, diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts index 135af163..5ce66c4a 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -8,13 +8,6 @@ import type { RawCallbacks } from "./host-callbacks-adapter.js"; import type { GenericError } from "@parity/truapi"; -import type { BulletinAllowanceSigner } from "./host-callbacks.js"; - -export interface WorkerBulletinAllowanceSigner { - publicKey: Uint8Array; - signerId: number; -} - import type { ChainConnect } from "../runtime.js"; export const CALLBACK_NAMES = [ @@ -28,7 +21,6 @@ export const CALLBACK_NAMES = [ "cancelNotification", "devicePermission", "remotePermission", - "submitPreimage", "read", "write", "clear", @@ -44,9 +36,6 @@ export interface WorkerCallbackBridge { name: CallbackName, args: readonly unknown[], ): Promise; - registerBulletinAllowanceSigner( - signer: BulletinAllowanceSigner, - ): WorkerBulletinAllowanceSigner; startSubscription( name: SubscriptionName, payload: Uint8Array | null, @@ -98,11 +87,6 @@ function rawCallbacks( bridge.callbackRequest("remotePermission", [request]) as ReturnType< RawCallbacks["remotePermission"] >, - submitPreimage: (value, bulletinAllowanceSigner) => - bridge.callbackRequest("submitPreimage", [ - value, - bridge.registerBulletinAllowanceSigner(bulletinAllowanceSigner), - ]) as ReturnType, read: (key) => bridge.callbackRequest("read", [key]) as ReturnType, write: (key, value) => diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index c0351268..330f2f7b 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -9,8 +9,6 @@ //! Async capability traits use `async_trait` so the combined [`Platform`] //! surface can be used as a trait object by the runtime. -use std::sync::Arc; - use futures::stream::BoxStream; use parity_scale_codec::{Decode, Encode}; use unicode_normalization::UnicodeNormalization; @@ -24,8 +22,7 @@ use truapi::latest::{ HostRequestResourceAllocationRequest, HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, NotificationId, - PreimageSubmitError, ProductAccountTxPayload, RemotePermissionRequest, - RemotePermissionResponse, ThemeVariant, + ProductAccountTxPayload, RemotePermissionRequest, RemotePermissionResponse, ThemeVariant, }; use url::Url; @@ -50,6 +47,8 @@ pub struct PairingHostConfig { pub host: HostRuntimeConfig, /// People-chain genesis hash used for statement-store SSO. pub people_chain_genesis_hash: [u8; 32], + /// Bulletin-chain genesis hash used for in-core preimage submission. + pub bulletin_chain_genesis_hash: [u8; 32], /// Deeplink URI scheme used in pairing QR payloads, without `://`. /// /// Host-spec L.2-L.3 define the `polkadotapp://pair` route and construction @@ -68,6 +67,8 @@ pub struct SigningHostConfig { pub host: HostRuntimeConfig, /// People-chain genesis hash used for statement-store product calls. pub people_chain_genesis_hash: [u8; 32], + /// Bulletin-chain genesis hash used for in-core preimage submission. + pub bulletin_chain_genesis_hash: [u8; 32], } /// Product identity attached to one product-facing TrUAPI connection. @@ -138,6 +139,7 @@ impl PairingHostConfig { host_info: HostInfo, platform_info: PlatformInfo, people_chain_genesis_hash: [u8; 32], + bulletin_chain_genesis_hash: [u8; 32], pairing_deeplink_scheme: String, ) -> Result { require_non_empty("pairing_deeplink_scheme", &pairing_deeplink_scheme)?; @@ -149,6 +151,7 @@ impl PairingHostConfig { let config = Self { host: HostRuntimeConfig::new(host_info, platform_info)?, people_chain_genesis_hash, + bulletin_chain_genesis_hash, pairing_deeplink_scheme, }; Ok(config) @@ -162,10 +165,12 @@ impl SigningHostConfig { host_info: HostInfo, platform_info: PlatformInfo, people_chain_genesis_hash: [u8; 32], + bulletin_chain_genesis_hash: [u8; 32], ) -> Result { Ok(Self { host: HostRuntimeConfig::new(host_info, platform_info)?, people_chain_genesis_hash, + bulletin_chain_genesis_hash, }) } } @@ -612,111 +617,11 @@ pub trait ThemeHost: Send + Sync { fn subscribe_theme(&self) -> BoxStream<'static, Result>; } -/// Secret key allocated for Bulletin preimage submission. -#[derive(Clone, derive_more::Debug, PartialEq, Eq)] -pub struct BulletinAllowanceKey { - #[debug("{:?}", "")] - secret: [u8; 64], -} - -impl BulletinAllowanceKey { - /// Build a Bulletin allowance key from raw secret bytes. - pub fn from_secret_bytes(secret: Vec) -> Result { - let secret: [u8; 64] = secret.try_into().map_err(|secret: Vec| { - BulletinAllowanceKeyError::InvalidLength { - actual: secret.len(), - } - })?; - Ok(Self { secret }) - } - - /// Raw secret bytes for bridge and storage adapters. - pub fn as_secret_bytes(&self) -> &[u8] { - &self.secret - } - - /// Consume the wrapper and return raw secret bytes. - pub fn into_secret_bytes(self) -> [u8; 64] { - self.secret - } -} - -/// Invalid Bulletin allowance key material. -#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] -pub enum BulletinAllowanceKeyError { - /// Secret material was not a 64-byte sr25519 secret key. - #[display("bulletin allowance key must be 64 bytes, got {actual}")] - InvalidLength { - /// Actual secret byte length. - actual: usize, - }, -} - -/// Bulletin allowance signing capability exposed across the platform boundary. -/// -/// Rust owns the allowance key format and secret material. Host code only gets -/// `public_key + sign(payload)`, enough for PAPI to build and submit the -/// Bulletin transaction without reintroducing allowance key parsing in host code. -type BulletinAllowanceSignFn = - dyn Fn(&[u8]) -> Result<[u8; 64], BulletinAllowanceSignError> + Send + Sync; - -/// Host-facing signer for Bulletin preimage submission. -#[derive(Clone, derive_more::Debug)] -pub struct BulletinAllowanceSigner { - public_key: [u8; 32], - /// Rust-owned signing capability passed to host code without exposing the - /// raw allowance secret. - #[debug("{:?}", "")] - sign: Arc, -} - -impl BulletinAllowanceSigner { - /// Build a signer from a public key and signing function. - pub fn new( - public_key: [u8; 32], - sign: impl Fn(&[u8]) -> Result<[u8; 64], BulletinAllowanceSignError> + Send + Sync + 'static, - ) -> Self { - Self { - public_key, - sign: Arc::new(sign), - } - } - - /// Public key of the allowance account. - pub fn public_key(&self) -> [u8; 32] { - self.public_key - } - - /// Sign a SCALE transaction payload with the allowance account. - pub fn sign(&self, payload: &[u8]) -> Result<[u8; 64], BulletinAllowanceSignError> { - (self.sign)(payload) - } -} - -/// Bulletin allowance signing failed. -#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] -#[display("{reason}")] -pub struct BulletinAllowanceSignError { - /// Human-readable failure reason. - pub reason: String, -} - -/// Host preimage backend. The core owns wire mapping and subscription -/// lifecycle; the host owns the selected backend. +/// 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). #[async_trait] pub trait PreimageHost: Send + Sync { - /// Submit the preimage and return its key. - async fn submit_preimage( - &self, - value: Vec, - bulletin_allowance_signer: BulletinAllowanceSigner, - ) -> Result, PreimageSubmitError> { - let _ = (value, bulletin_allowance_signer); - Err(PreimageSubmitError::Unknown { - reason: "submitPreimage callback not provided by host".to_string(), - }) - } - /// Emits current value/miss immediately, then future updates. fn lookup_preimage( &self, diff --git a/rust/crates/truapi-platform/tests/bounds.rs b/rust/crates/truapi-platform/tests/bounds.rs index 5a1733c4..e6973351 100644 --- a/rust/crates/truapi-platform/tests/bounds.rs +++ b/rust/crates/truapi-platform/tests/bounds.rs @@ -96,6 +96,7 @@ fn pairing_config_validation_cases() { }, PlatformInfo::default(), [0xa2; 32], + [0xbb; 32], case.pairing_deeplink_scheme.to_string(), ) .map(|_| ()); diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 4a2e9e72..bc068de5 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -35,11 +35,10 @@ unsafe_code = "forbid" truapi = { path = "../truapi" } truapi-platform = { path = "../truapi-platform" } async-trait = "0.1" -derive_more = { version = "2", features = ["display", "error"] } +derive_more = { version = "2", features = ["debug", "display", "error"] } futures = "0.3" -futures-timer = { version = "3", features = ["wasm-bindgen"] } +futures-timer = "3" parity-scale-codec = { version = "3", features = ["derive"] } -primitive-types = { version = "0.13", default-features = false, features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "1" @@ -47,12 +46,11 @@ unicode-normalization = "0.1" url = "2" hex = "0.4" nanoid = "0.4" -blake2-rfc = { version = "0.2", default-features = false } +blake2b_simd = { version = "1", default-features = false } sp-crypto-hashing = { version = "0.1", default-features = false } -bs58 = { version = "0.5", default-features = false, features = ["alloc"] } schnorrkel = { version = "0.11.5", default-features = false, features = ["alloc", "getrandom"] } substrate-bip39 = { version = "0.6", default-features = false } -zeroize = { version = "1", default-features = false, features = ["alloc"] } +zeroize = { version = "1", default-features = false, features = ["alloc", "derive"] } getrandom = { version = "0.2", features = ["js"] } p256 = { version = "0.13", default-features = false, features = ["ecdh"] } hkdf = "0.12" @@ -64,26 +62,23 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", default-features = false, features = ["registry", "std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +subxt = { version = "0.50.2", default-features = false, features = ["native"] } subxt-rpcs = { version = "0.50.1", default-features = false, features = ["native"] } [target.'cfg(target_arch = "wasm32")'.dependencies] +futures-timer = { version = "3", features = ["wasm-bindgen"] } js-sys = "0.3" +subxt = { version = "0.50.2", default-features = false, features = ["web"] } subxt-rpcs = { version = "0.50.1", default-features = false, features = ["web"] } wasm-bindgen = "0.2.118" wasm-bindgen-futures = "0.4" console_error_panic_hook = "0.1" -futures-util = "0.3" -pin-project = "1" send_wrapper = { version = "0.6", features = ["futures"] } web-time = "1" -web-sys = { version = "0.3", features = [ - "BinaryType", - "CloseEvent", - "console", - "Event", - "MessageEvent", - "WebSocket", -] } +web-sys = { version = "0.3", features = ["console"] } + +[dev-dependencies] +scale-info = "2.11" [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 853fc133..73028111 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -7,6 +7,11 @@ //! This module keeps the TrUAPI-facing local follow ids and maps subxt DTOs to //! public v01 [`RemoteChainHeadFollowItem`] values. //! +//! Each connection also lazily caches one genesis-pinned Subxt +//! [`OnlineClient`], its backend driver, and Subxt's per-client metadata cache. +//! Internal services use that client instead of hand-rolling chainHead +//! orchestration where Subxt fits the boundary. +//! //! The chain-side traits return [`RuntimeFailure`], a local classification //! that the [`crate::runtime`] layer maps to [`truapi::CallError`] variants //! (`Unsupported`, `HostFailure`, ...). This avoids leaking json-rpc plumbing @@ -17,22 +22,26 @@ use core::task::{Context, Poll}; use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; #[cfg(not(target_arch = "wasm32"))] use std::time::Duration; #[cfg(target_arch = "wasm32")] use web_time::Duration; -use futures::FutureExt; +use derive_more::{Display, Error}; use futures::channel::mpsc; use futures::future::{AbortHandle, Abortable}; use futures::future::{BoxFuture, Shared}; use futures::stream::BoxStream; -use futures::{Stream, StreamExt, pin_mut}; +use futures::{FutureExt, pin_mut}; +use futures::{Stream, StreamExt}; use parity_scale_codec::{Decode, Error as ScaleError, Input}; -use primitive_types::H256; use serde::de::{Deserializer, Error as DeError}; use serde_json::Value; +use subxt::OnlineClient; +use subxt::backend::ChainHeadBackend; +use subxt::config::substrate::{SubstrateConfig, SubstrateConfigBuilder}; +use subxt::utils::H256; use subxt_rpcs::client::RpcClient; use subxt_rpcs::methods::chain_head as subxt_chain; use subxt_rpcs::{ChainHeadRpcMethods, Error as SubxtRpcError, RpcConfig}; @@ -97,6 +106,19 @@ type FollowSetup = Shared>>; /// than each opening a connection and orphaning all but the last insert. type ConnectionSetup = Shared, RuntimeFailure>>>; +/// Shared, single-flight setup of the connection's cached Subxt client. +/// Concurrent first users await one in-flight build rather than each starting +/// (and leaking) a separate backend driver and follow subscription. +type SubxtConnectionSetup = Shared>>; + +/// Cached Subxt client built over one connection's transport. +/// The backend driver is owned by the setup task that created this value. +#[derive(Clone)] +pub(crate) struct SubxtConnection { + /// Client whose chain config pins the host-configured genesis hash. + pub(crate) client: OnlineClient, +} + /// Classification of framework-level chain failures separate from JSON-RPC /// domain errors. Maps cleanly to [`truapi::CallError`] variants at the /// `ProductRuntimeHost` boundary. @@ -109,7 +131,12 @@ pub enum RuntimeFailureKind { } /// Framework-level chain failure with a diagnostic reason. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Display, Error)] +#[display( + "{method}{}{}", + if reason.is_some() { ": " } else { "" }, + reason.as_deref().unwrap_or_default() +)] pub struct RuntimeFailure { kind: RuntimeFailureKind, method: &'static str, @@ -137,8 +164,19 @@ impl RuntimeFailure { } } + /// [`Self::unavailable`] carrying the underlying failure text for + /// diagnostics. + pub fn unavailable_with_reason(method: &'static str, reason: impl Into) -> Self { + Self { + kind: RuntimeFailureKind::Unavailable, + method, + reason: Some(reason.into()), + } + } + /// Failure classification. - pub fn kind(&self) -> RuntimeFailureKind { + #[cfg(test)] + fn kind(&self) -> RuntimeFailureKind { self.kind } @@ -156,11 +194,13 @@ impl RuntimeFailure { } } - /// Re-tag this failure under `method`, preserving its kind and reason. + /// Re-tag this failure under `method`, preserving its kind and nesting + /// the original reason (when one exists) for diagnostics. fn reclassify(&self, method: &'static str) -> RuntimeFailure { - match self.kind() { - RuntimeFailureKind::Unavailable => RuntimeFailure::unavailable(method), - RuntimeFailureKind::HostFailure => RuntimeFailure::host_failure(method, self.reason()), + RuntimeFailure { + kind: self.kind, + method, + reason: self.reason.as_ref().map(|_| self.reason()), } } } @@ -221,19 +261,13 @@ impl ChainRuntime { let setup_cancelled = cancelled.clone(); let cleanup_cancelled = cancelled.clone(); + // Every `start_follow` failure path tears the follow state down, + // dropping the stored sender; with this task's clone gone too, the + // local stream ends. Sender drop is the single termination mechanism. let fut = async move { - if runtime - .start_follow( - follow_subscription_id, - request, - Some(tx.clone()), - setup_cancelled, - ) - .await - .is_err() - { - let _ = tx.unbounded_send(FollowSignal::Interrupt); - } + let _ = runtime + .start_follow(follow_subscription_id, request, tx, setup_cancelled) + .await; }; (self.spawner)(fut.boxed()); @@ -244,12 +278,6 @@ impl ChainRuntime { cleanup_runtime.cleanup_follow(&cleanup_genesis_hash, &cleanup_follow_id); })), ) - .filter_map(|signal| async move { - match signal { - FollowSignal::Item(item) => Some(item), - FollowSignal::Interrupt => None, - } - }) .boxed() } @@ -365,10 +393,15 @@ impl ChainRuntime { let remote_follow_id = self .ensure_follow_context(method, &connection, request.follow_subscription_id, false) .await?; - for hash in request.hashes { + let hashes = request + .hashes + .iter() + .map(|hash| hash_from_bytes(method, hash)) + .collect::, _>>()?; + for hash in hashes { connection .methods - .chainhead_v1_unpin(&remote_follow_id, hash_from_bytes(method, &hash)?) + .chainhead_v1_unpin(&remote_follow_id, hash) .await .map_err(|err| rpc_failure(method, err))?; } @@ -493,6 +526,27 @@ impl ChainRuntime { .map_err(|err| rpc_failure(method, err)) } + /// Genesis-pinned Subxt client for the chain identified by `genesis_hash`. + /// The cached unit is the underlying Subxt connection bundle, not just + /// the cheap client handle. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.online_client"))] + pub(crate) async fn online_client( + &self, + genesis_hash: &[u8], + ) -> Result, RuntimeFailure> { + Ok(self.subxt_connection(genesis_hash).await?.client) + } + + async fn subxt_connection( + &self, + genesis_hash: &[u8], + ) -> Result { + let connection = self + .connection_for("subxt_connection", genesis_hash) + .await?; + connection.subxt_connection().await + } + #[instrument(skip_all, fields(runtime.method = "chain_runtime.connection_for", method = method))] async fn connection_for( &self, @@ -524,8 +578,8 @@ impl ChainRuntime { let setup_key = key.clone(); let genesis_hash = genesis_hash.to_owned(); let setup: ConnectionSetup = async move { - let result = provider.connect(genesis_hash).await.map(|rpc| { - let connection = ChainConnection::new(rpc, spawner); + let result = provider.connect(genesis_hash.clone()).await.map(|rpc| { + let connection = ChainConnection::new(rpc, spawner, genesis_hash); connections .lock() .unwrap() @@ -550,7 +604,7 @@ impl ChainRuntime { &self, local_follow_id: String, request: RemoteChainHeadFollowRequest, - sender: Option>, + sender: mpsc::UnboundedSender, cancelled: Arc, ) -> Result<(), RuntimeFailure> { if cancelled.load(Ordering::SeqCst) { @@ -611,32 +665,35 @@ impl ChainRuntime { } } -/// One delivery on the local follow stream. `Interrupt` signals an -/// abnormal close (connection dropped, follow setup failed); it produces no -/// item but ends the stream. -enum FollowSignal { - Item(RemoteChainHeadFollowItem), - Interrupt, -} - struct ChainConnection { rpc_client: HostRpcClient, methods: ChainHeadRpcMethods, spawner: Spawner, + /// Host-configured genesis hash this connection was opened for; pins the + /// cached Subxt bundle's chain config. + genesis_hash: Vec, follows: Mutex>, follow_setups: Mutex>, + /// Cached Subxt bundle setup tagged with its generation, so invalidation + /// (on setup failure or backend-driver exit) can never evict a newer + /// rebuild. + subxt_connection_setup: Mutex>, + subxt_connection_generation: AtomicU64, } impl ChainConnection { - fn new(rpc: Arc, spawner: Spawner) -> Arc { + fn new(rpc: Arc, spawner: Spawner, genesis_hash: Vec) -> Arc { let rpc_client = HostRpcClient::new(rpc, spawner.clone()); let methods = ChainHeadRpcMethods::new(RpcClient::new(rpc_client.clone())); Arc::new(Self { rpc_client, methods, spawner, + genesis_hash, follows: Mutex::new(HashMap::new()), follow_setups: Mutex::new(HashMap::new()), + subxt_connection_setup: Mutex::new(None), + subxt_connection_generation: AtomicU64::new(0), }) } @@ -644,6 +701,98 @@ impl ChainConnection { self.rpc_client.is_closed() } + /// Lazily build (single-flight) and cache the Subxt bundle over this + /// connection's transport. The chain config pins the host-configured + /// genesis hash, so Subxt never reads a provider-echoed one, and the + /// backend follow started here is shared by every user of this connection. + async fn subxt_connection(self: &Arc) -> Result { + let (generation, setup) = { + let mut slot = self.subxt_connection_setup.lock().unwrap(); + if let Some(existing) = slot.clone() { + existing + } else { + let generation = self + .subxt_connection_generation + .fetch_add(1, Ordering::Relaxed) + + 1; + let connection = self.clone(); + let setup: SubxtConnectionSetup = + async move { connection.build_subxt_connection(generation).await } + .boxed() + .shared(); + *slot = Some((generation, setup.clone())); + (generation, setup) + } + }; + let result = setup.await; + // On failure, drop the cached setup so a later call can retry. + if result.is_err() { + self.invalidate_subxt_connection(generation); + } + result + } + + /// Drop the cached Subxt setup if it still belongs to `generation`; + /// stale invalidations (an old driver exiting after a rebuild) are + /// ignored. + fn invalidate_subxt_connection(&self, generation: u64) { + let mut slot = self.subxt_connection_setup.lock().unwrap(); + if slot + .as_ref() + .is_some_and(|(cached, _)| *cached == generation) + { + *slot = None; + } + } + + /// Body of the single-flight Subxt setup: start the chainHead backend, + /// drive it on the connection's spawner, and build the client with the + /// config-pinned genesis hash. + async fn build_subxt_connection( + self: Arc, + generation: u64, + ) -> Result { + const METHOD: &str = "subxt_connection"; + let genesis_hash: [u8; 32] = self.genesis_hash.as_slice().try_into().map_err(|_| { + RuntimeFailure::host_failure( + METHOD, + format!( + "expected 32-byte genesis hash, got {}", + self.genesis_hash.len() + ), + ) + })?; + let (backend, mut driver) = ChainHeadBackend::::builder() + .build(RpcClient::new(self.rpc_client.clone())); + // The pump holds only a weak handle so a torn-down connection is not + // kept alive by its own driver task. + let pump_connection = Arc::downgrade(&self); + (self.spawner)( + async move { + while let Some(result) = driver.next().await { + if let Err(error) = result { + tracing::debug!(target: "subxt", "chainHead backend error={error}"); + } + } + // The backend can make no further progress; drop the cached + // Subxt bundle so the next caller rebuilds instead of hitting + // a permanently dead backend. + if let Some(connection) = pump_connection.upgrade() { + connection.invalidate_subxt_connection(generation); + } + } + .boxed(), + ); + let backend = Arc::new(backend); + let config = SubstrateConfigBuilder::new() + .set_genesis_hash(H256(genesis_hash)) + .build(); + let client = OnlineClient::from_backend_with_config(config, backend.clone()) + .await + .map_err(|error| RuntimeFailure::host_failure(METHOD, error.to_string()))?; + Ok(SubxtConnection { client }) + } + fn follow_with_runtime(&self, local_follow_id: &str) -> bool { self.follows .lock() @@ -667,16 +816,14 @@ impl ChainConnection { &self, local_follow_id: &str, with_runtime: bool, - sender: Option>, + sender: mpsc::UnboundedSender, cancelled: Arc, ) { let mut follows = self.follows.lock().unwrap(); match follows.get_mut(local_follow_id) { Some(follow) => { - if sender.is_some() { - follow.sender = sender; - follow.cancelled = cancelled; - } + follow.sender = sender; + follow.cancelled = cancelled; } None => { follows.insert( @@ -805,6 +952,7 @@ impl ChainConnection { let remote_follow_id = follow .subscription_id() .ok_or_else(|| { + self.remove_follow(&local_follow_id); RuntimeFailure::host_failure(FOLLOW_METHOD, "missing follow subscription id") })? .to_string(); @@ -823,18 +971,18 @@ impl ChainConnection { Ok(event) => match map_follow_event(event) { Ok(item) => { let is_stop = matches!(item, RemoteChainHeadFollowItem::Stop); - connection.deliver_follow_event(&pump_follow_id, item, false); + connection.deliver_follow_event(&pump_follow_id, item); if is_stop { break; } } Err(_) => { - connection.interrupt_follow(&pump_follow_id, false); + connection.interrupt_follow(&pump_follow_id); break; } }, Err(_) => { - connection.interrupt_follow(&pump_follow_id, false); + connection.interrupt_follow(&pump_follow_id); break; } } @@ -883,46 +1031,31 @@ impl ChainConnection { self.remove_follow(local_follow_id); } - fn deliver_follow_event( - &self, - local_follow_id: &str, - event: RemoteChainHeadFollowItem, - abort_on_stop: bool, - ) { + /// Deliver one follow event to the local subscriber; a `Stop` event also + /// tears the follow down, ending the local stream via sender drop. + /// Cleanup never aborts: the only caller is the pump itself, which the + /// stored abort handle targets. + fn deliver_follow_event(&self, local_follow_id: &str, event: RemoteChainHeadFollowItem) { let sender = self .follows .lock() .unwrap() .get(local_follow_id) - .and_then(|follow| follow.sender.clone()); + .map(|follow| follow.sender.clone()); let is_stop = matches!(event, RemoteChainHeadFollowItem::Stop); if let Some(sender) = sender { - let _ = sender.unbounded_send(FollowSignal::Item(event)); + let _ = sender.unbounded_send(event); } if is_stop { - if abort_on_stop { - self.remove_follow(local_follow_id); - } else { - self.remove_follow_without_abort(local_follow_id); - } + self.remove_follow_without_abort(local_follow_id); } } - fn interrupt_follow(&self, local_follow_id: &str, abort: bool) { - let sender = self - .follows - .lock() - .unwrap() - .get(local_follow_id) - .and_then(|follow| follow.sender.clone()); - if let Some(sender) = sender { - let _ = sender.unbounded_send(FollowSignal::Interrupt); - } - if abort { - self.remove_follow(local_follow_id); - } else { - self.remove_follow_without_abort(local_follow_id); - } + /// End the local follow stream on an abnormal close by tearing the follow + /// down (sender drop). Cleanup never aborts, same as + /// [`Self::deliver_follow_event`]. + fn interrupt_follow(&self, local_follow_id: &str) { + self.remove_follow_without_abort(local_follow_id); } } @@ -930,7 +1063,9 @@ struct FollowState { with_runtime: bool, remote_subscription_id: Option, abort: Option, - sender: Option>, + /// Local subscriber; dropping it (with the follow state) is what ends the + /// local follow stream. + sender: mpsc::UnboundedSender, cancelled: Arc, } @@ -1194,7 +1329,6 @@ async fn wait_for_chain_head_best_hash_after_initialization( ) -> Result, String> { let timeout = futures_timer::Delay::new(timeout).fuse(); pin_mut!(timeout); - let mut candidate = fallback; loop { let next = follow.next().fuse(); pin_mut!(next); @@ -1203,16 +1337,13 @@ async fn wait_for_chain_head_best_hash_after_initialization( Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { return Ok(best_block_hash); } - Some(RemoteChainHeadFollowItem::NewBlock { block_hash, .. }) => { - candidate = Some(block_hash); - } Some(RemoteChainHeadFollowItem::Stop) | None => { return Err(format!("{label} follow stopped before best block")); } _ => {} }, () = timeout => { - return candidate.ok_or_else(|| { + return fallback.clone().ok_or_else(|| { format!("{label} follow best block timed out") }); }, @@ -1220,15 +1351,30 @@ async fn wait_for_chain_head_best_hash_after_initialization( } } +/// Context for one storage operation observed on a `chainHead_v1_follow` stream. +pub(crate) struct ChainHeadStorageValueLookup<'a> { + pub(crate) chain: &'a ChainRuntime, + pub(crate) genesis_hash: &'a [u8], + pub(crate) follow_subscription_id: &'a str, + pub(crate) operation_id: &'a str, + pub(crate) key: &'a [u8], + pub(crate) label: &'static str, + pub(crate) timeout: Duration, +} + +/// Result of one value query observed on a `chainHead_v1_follow` stream. +pub(crate) enum ChainHeadStorageValue { + Found(Vec), + Missing, + Inaccessible, +} + /// Wait for one storage operation's value from a `chainHead_v1_follow` stream. pub(crate) async fn wait_for_chain_head_storage_value( follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, - operation_id: &str, - key: &[u8], - label: &'static str, - timeout: Duration, -) -> Result>, String> { - let timeout = futures_timer::Delay::new(timeout).fuse(); + lookup: ChainHeadStorageValueLookup<'_>, +) -> Result { + let timeout = futures_timer::Delay::new(lookup.timeout).fuse(); pin_mut!(timeout); let mut value = None; loop { @@ -1237,35 +1383,51 @@ pub(crate) async fn wait_for_chain_head_storage_value( futures::select! { item = next => match item { Some(RemoteChainHeadFollowItem::OperationStorageItems { operation_id: item_operation_id, items }) - if item_operation_id == operation_id => + if item_operation_id == lookup.operation_id => { for item in items { - if item.key == key { + if item.key == lookup.key { value = item.value; } } } Some(RemoteChainHeadFollowItem::OperationStorageDone { operation_id: item_operation_id }) - if item_operation_id == operation_id => + if item_operation_id == lookup.operation_id => + { + return Ok(match value { + Some(value) => ChainHeadStorageValue::Found(value), + None => ChainHeadStorageValue::Missing, + }); + } + Some(RemoteChainHeadFollowItem::OperationWaitingForContinue { operation_id: item_operation_id }) + if item_operation_id == lookup.operation_id => { - return Ok(value); + lookup + .chain + .remote_chain_head_continue(RemoteChainHeadContinueRequest { + genesis_hash: lookup.genesis_hash.to_vec(), + follow_subscription_id: lookup.follow_subscription_id.to_string(), + operation_id: lookup.operation_id.to_string(), + }) + .await + .map_err(|failure| failure.reason())?; } Some(RemoteChainHeadFollowItem::OperationInaccessible { operation_id: item_operation_id }) - if item_operation_id == operation_id => + if item_operation_id == lookup.operation_id => { - return Ok(None); + return Ok(ChainHeadStorageValue::Inaccessible); } Some(RemoteChainHeadFollowItem::OperationError { operation_id: item_operation_id, error }) - if item_operation_id == operation_id => + if item_operation_id == lookup.operation_id => { return Err(error); } Some(RemoteChainHeadFollowItem::Stop) | None => { - return Err(format!("{label} follow stopped during storage lookup")); + return Err(format!("{} follow stopped during storage lookup", lookup.label)); } _ => {} }, - () = timeout => return Err(format!("{label} storage lookup timed out")), + () = timeout => return Err(format!("{} storage lookup timed out", lookup.label)), } } } @@ -1318,6 +1480,33 @@ mod tests { assert_eq!(hash, vec![0x02]); } + #[test] + fn chain_head_best_hash_timeout_falls_back_to_finalized_not_new_block() { + let mut follow = stream::iter(vec![ + RemoteChainHeadFollowItem::Initialized { + finalized_block_hashes: vec![vec![0x01]], + finalized_block_runtime: None, + }, + RemoteChainHeadFollowItem::NewBlock { + block_hash: vec![0x03], + parent_block_hash: vec![0x01], + new_runtime: None, + }, + ]) + .chain(stream::pending()) + .boxed(); + + let hash = futures::executor::block_on(wait_for_chain_head_best_hash( + &mut follow, + "test chain", + Duration::from_secs(10), + Duration::from_millis(1), + )) + .expect("best hash should fall back to finalized hash"); + + assert_eq!(hash, vec![0x01]); + } + #[test] fn chain_head_best_hash_errors_on_stop_before_best_block() { let mut follow = stream::iter(vec![ @@ -1533,6 +1722,84 @@ mod tests { assert!(sent[1].contains("chainHead_v1_header")); } + #[test] + fn unpin_uses_typed_subxt_method_for_each_hash() { + let provider = Arc::new(ScriptedProvider::new(|request| { + let id = extract_id(request).unwrap(); + if request.contains("chainHead_v1_follow") { + Some(format!( + r#"{{"jsonrpc":"2.0","id":"{id}","result":"REMOTE-FOLLOW"}}"# + )) + } else if request.contains("chainHead_v1_unpin") { + Some(format!(r#"{{"jsonrpc":"2.0","id":"{id}","result":null}}"#)) + } else { + None + } + })); + let runtime = ChainRuntime::new(provider.clone(), spawner_for_tests()); + let _follow_stream = runtime.remote_chain_head_follow( + "local-follow".to_string(), + RemoteChainHeadFollowRequest { + genesis_hash: vec![0u8; 32], + with_runtime: false, + }, + ); + let sent = wait_for_sent(&provider, |sent| { + sent.iter() + .any(|request| request.contains("chainHead_v1_follow")) + }); + assert!( + sent.iter() + .any(|request| request.contains("chainHead_v1_follow")), + "follow setup did not start; sent: {sent:?}", + ); + + futures::executor::block_on( + runtime.remote_chain_head_unpin(RemoteChainHeadUnpinRequest { + genesis_hash: vec![0u8; 32], + follow_subscription_id: "local-follow".to_string(), + hashes: vec![vec![0x11; 32], vec![0x22; 32]], + }), + ) + .expect("unpin succeeds"); + + let sent = provider.sent.lock().unwrap().clone(); + let unpin_requests: Vec<_> = sent + .iter() + .filter(|request| request.contains("chainHead_v1_unpin")) + .collect(); + assert_eq!( + unpin_requests.len(), + 2, + "unpin should send each hash through Subxt; sent: {sent:?}", + ); + let params = unpin_requests + .iter() + .map(|request| { + let request: Value = serde_json::from_str(request).expect("json request"); + assert_eq!( + request.get("method").and_then(Value::as_str), + Some("chainHead_v1_unpin"), + ); + request + .get("params") + .and_then(Value::as_array) + .expect("params array") + .clone() + }) + .collect::>(); + assert_eq!(params[0][0].as_str(), Some("REMOTE-FOLLOW")); + assert_eq!(params[1][0].as_str(), Some("REMOTE-FOLLOW")); + assert_eq!( + params[0][1][0].as_str(), + Some("0x1111111111111111111111111111111111111111111111111111111111111111"), + ); + assert_eq!( + params[1][1][0].as_str(), + Some("0x2222222222222222222222222222222222222222222222222222222222222222"), + ); + } + #[test] fn header_request_rejects_unknown_follow_id_without_opening_follow() { let provider = Arc::new(ScriptedProvider::new(|request| { @@ -1613,6 +1880,88 @@ mod tests { assert_eq!(provider.inner.connect_calls.load(Ordering::SeqCst), 1); } + /// The cached Subxt bundle must pin the host-configured genesis hash + /// (never fetch it from the provider) and be built once per connection. + #[cfg_attr(target_arch = "wasm32", ignore)] + #[test] + fn subxt_connection_pins_configured_genesis_and_is_cached() { + let provider = Arc::new(ScriptedProvider::new(|_| None)); + let runtime = ChainRuntime::new(provider.clone(), spawner_for_tests()); + let genesis = vec![0xab; 32]; + + let connection = + futures::executor::block_on(runtime.connection_for("subxt_connection_test", &genesis)) + .expect("connection"); + let first = futures::executor::block_on(connection.subxt_connection()).expect("client"); + let second = futures::executor::block_on(connection.subxt_connection()).expect("client"); + + // With the config pin, construction never asks the provider for the + // genesis hash. The scripted provider answers nothing, so a fetch + // would have hung instead of returning. + assert_eq!(first.client.genesis_hash(), H256([0xab; 32])); + assert_eq!(second.client.genesis_hash(), H256([0xab; 32])); + let sent = provider.sent.lock().unwrap().clone(); + assert!( + !sent + .iter() + .any(|request| request.contains("chainSpec_v1_genesisHash")), + "genesis hash must come from config, not the provider; sent: {sent:?}", + ); + + // One backend driver total: its follow subscription shows up once. + let sent = wait_for_sent(&provider, |sent| { + sent.iter() + .any(|request| request.contains("chainHead_v1_follow")) + }); + assert!( + sent.iter() + .any(|request| request.contains("chainHead_v1_follow")), + "backend follow did not start; sent: {sent:?}", + ); + std::thread::sleep(std::time::Duration::from_millis(200)); + let follows = provider + .sent + .lock() + .unwrap() + .iter() + .filter(|request| request.contains("chainHead_v1_follow")) + .count(); + assert_eq!( + follows, 1, + "cached Subxt bundle must reuse one backend follow" + ); + } + + /// When the backend driver exits (transport gone quiet for good), the + /// cached Subxt bundle must be dropped so the next caller rebuilds it. + #[cfg_attr(target_arch = "wasm32", ignore)] + #[test] + fn subxt_connection_invalidated_when_backend_driver_exits() { + let provider = Arc::new(ScriptedProvider::new(|_| None)); + let runtime = ChainRuntime::new(provider.clone(), spawner_for_tests()); + let genesis = vec![0xab; 32]; + + let connection = + futures::executor::block_on(runtime.connection_for("subxt_connection_test", &genesis)) + .expect("connection"); + let _client = futures::executor::block_on(connection.subxt_connection()).expect("client"); + assert!(connection.subxt_connection_setup.lock().unwrap().is_some()); + + // End the response stream: the backend driver's follow stream ends, + // the pump exits, and the exit hook must clear the cached setup. + provider.sender.lock().unwrap().take(); + for _ in 0..500 { + if connection.subxt_connection_setup.lock().unwrap().is_none() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!( + connection.subxt_connection_setup.lock().unwrap().is_none(), + "driver exit must invalidate the cached Subxt bundle", + ); + } + #[test] fn unknown_genesis_chain_spec_propagates_failure() { let provider = Arc::new(UnavailableChainProvider); diff --git a/rust/crates/truapi-server/src/core.rs b/rust/crates/truapi-server/src/core.rs index a9fbd337..cb6df1d5 100644 --- a/rust/crates/truapi-server/src/core.rs +++ b/rust/crates/truapi-server/src/core.rs @@ -58,6 +58,7 @@ impl TrUApiCore { let services = RuntimeServices::new( platform, host_config.people_chain_genesis_hash, + host_config.bulletin_chain_genesis_hash, spawner.clone(), ); let pairing_host = PairingHostRole::new(services.clone(), host_config); diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 8478467c..1dbae792 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -80,6 +80,7 @@ impl PairingHostRuntime { let services = RuntimeServices::new( platform.clone(), config.people_chain_genesis_hash, + config.bulletin_chain_genesis_hash, spawner.clone(), ); let pairing_host = PairingHostRole::new(services.clone(), config); @@ -200,8 +201,12 @@ impl SigningHostRuntime { P: Platform + 'static, { let platform: Arc = platform; - let services = - RuntimeServices::new(platform.clone(), config.people_chain_genesis_hash, spawner); + let services = RuntimeServices::new( + platform.clone(), + config.people_chain_genesis_hash, + config.bulletin_chain_genesis_hash, + spawner, + ); let signing_host = SigningHostRole::new(platform); Self { services, diff --git a/rust/crates/truapi-server/src/host_logic.rs b/rust/crates/truapi-server/src/host_logic.rs index 0ed7572f..12a1c516 100644 --- a/rust/crates/truapi-server/src/host_logic.rs +++ b/rust/crates/truapi-server/src/host_logic.rs @@ -4,9 +4,10 @@ //! storage, URL handler, notification center). Everything else lives here so //! iOS, Android, and web hosts share one canonical implementation. -pub mod allowance_signer; +pub mod bulletin; pub mod dotns; pub mod entropy; +pub mod extrinsic; pub mod features; pub mod identity; pub mod permissions; diff --git a/rust/crates/truapi-server/src/host_logic/allowance_signer.rs b/rust/crates/truapi-server/src/host_logic/allowance_signer.rs deleted file mode 100644 index 9e414bea..00000000 --- a/rust/crates/truapi-server/src/host_logic/allowance_signer.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! Bulletin allowance signing helpers shared by platform backends. - -use schnorrkel::SecretKey; -use truapi_platform::{BulletinAllowanceKey, BulletinAllowanceSignError, BulletinAllowanceSigner}; - -use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; - -/// Build a host-facing Bulletin signer from cached allowance key material. -pub(crate) fn bulletin_allowance_signer_from_key( - key: BulletinAllowanceKey, -) -> Result { - let secret = key.into_secret_bytes(); - let public_key = public_key_from_allowance_secret(secret)?; - // The host receives only the allowance public key plus this Rust-backed - // signing capability while constructing the `TransactionStorage.store` - // extrinsic; the allowance secret stays in Rust. - Ok(BulletinAllowanceSigner::new(public_key, move |payload| { - let secret = secret_key_from_allowance_secret(secret) - .map_err(|reason| BulletinAllowanceSignError { reason })?; - let public = secret.to_public(); - Ok(secret - .sign_simple(SR25519_SIGNING_CONTEXT, payload, &public) - .to_bytes()) - })) -} - -/// Derive the public key for a mobile slot-account allowance secret. -pub(crate) fn public_key_from_allowance_secret(secret: [u8; 64]) -> Result<[u8; 32], String> { - Ok(secret_key_from_allowance_secret(secret)? - .to_public() - .to_bytes()) -} - -fn secret_key_from_allowance_secret(secret: [u8; 64]) -> Result { - // Mobile allowance keys are `SlotAccountKey` values (`privateKey || nonce`) - // and must use schnorrkel's canonical `SecretKey::from_bytes` path. Older - // JS-derived keys used ed25519-expanded bytes, so keep the fallback for - // compatibility with persisted allocations. - match SecretKey::from_bytes(&secret) { - Ok(secret) => Ok(secret), - Err(canonical_error) => SecretKey::from_ed25519_bytes(&secret).map_err(|ed_error| { - format!( - "invalid bulletin allowance key: canonical={canonical_error}; ed25519={ed_error}" - ) - }), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use schnorrkel::{PublicKey, Signature}; - - fn slot_secret_fixture() -> [u8; 64] { - hex::decode( - "0eef5183411d40c32446bb1cbaabd70004a17af6012a577c735d054f04059208\ - 573dfc9b6ffeb1c786a16349e70f9836876a743c31c0a7a2a70727a852eec372", - ) - .unwrap() - .try_into() - .unwrap() - } - - #[test] - fn derives_mobile_slot_account_public_key() { - let public_key = public_key_from_allowance_secret(slot_secret_fixture()).unwrap(); - - assert_eq!( - hex::encode(public_key), - "10c68432943c68a6e1be650818b5e08db79e57823de9f34df7ba36d404d91e1d" - ); - } - - #[test] - fn signs_with_mobile_slot_account_secret() { - let secret = slot_secret_fixture(); - let signer = bulletin_allowance_signer_from_key( - BulletinAllowanceKey::from_secret_bytes(secret.to_vec()).unwrap(), - ) - .unwrap(); - let payload = b"hello-slot"; - let signature = signer.sign(payload).unwrap(); - let public_key = PublicKey::from_bytes(&signer.public_key()).unwrap(); - let signature = Signature::from_bytes(&signature).unwrap(); - - assert!( - public_key - .verify_simple(SR25519_SIGNING_CONTEXT, payload, &signature) - .is_ok() - ); - } -} diff --git a/rust/crates/truapi-server/src/host_logic/bulletin.rs b/rust/crates/truapi-server/src/host_logic/bulletin.rs new file mode 100644 index 00000000..184b9b8b --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/bulletin.rs @@ -0,0 +1,521 @@ +//! Bulletin `TransactionStorage.store` construction and signing. +//! +//! This module is the only place allowance-key material becomes a signer, and +//! it only ever signs the `store` call it builds itself: the public surface +//! takes raw preimage bytes plus a [`BulletinAllowanceKey`], never +//! caller-supplied call data. + +use subxt::client::{ClientAtBlock, OnlineClientAtBlockT}; +use subxt::config::DefaultExtrinsicParamsBuilder; +use subxt::config::substrate::SubstrateConfig; +use subxt::error::ExtrinsicError; +use subxt::ext::codec::Encode; +use subxt::ext::scale_encode::{self, EncodeAsFields, FieldIter, TypeResolver}; +use subxt::ext::scale_type_resolver::{Primitive, visitor}; +use subxt::tx::{StaticPayload, SubmittableTransaction}; + +use crate::host_logic::extrinsic::Sr25519Signer; +use crate::runtime::BulletinAllowanceKey; + +pub(crate) const STORE_PALLET_NAME: &str = "TransactionStorage"; +pub(crate) const STORE_CALL_NAME: &str = "store"; + +/// Mortality window for store transactions. +const MORTAL_PERIOD_BLOCKS: u64 = 64; + +/// Preimage key: blake2b-256 of the raw preimage bytes. +pub(crate) fn preimage_key(value: &[u8]) -> [u8; 32] { + sp_crypto_hashing::blake2_256(value) +} + +/// 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. +pub(crate) async fn build_signed_store_transaction>( + client: &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) + .build(); + let mut tx = client.tx(); + tx.create_signed(&payload, signer, params).await +} + +/// The only [`BulletinAllowanceKey`] -> signer conversion in the crate. The +/// returned signer is a transient per-call value; callers must not store it. +pub(crate) fn allowance_signer(allowance: &BulletinAllowanceKey) -> Result { + Sr25519Signer::from_secret_bytes(allowance.as_secret_bytes()) + .map_err(|reason| format!("invalid bulletin allowance key: {reason}")) +} + +/// `store { data: Vec }` call arguments with exact metadata validation. +struct StoreCallData<'a>(&'a [u8]); + +impl EncodeAsFields for StoreCallData<'_> { + fn encode_as_fields_to( + &self, + fields: &mut dyn FieldIter<'_, R::TypeId>, + types: &R, + out: &mut Vec, + ) -> Result<(), scale_encode::Error> { + let field = fields + .next() + .ok_or_else(|| scale_encode::Error::custom_str("store call has no data field"))?; + if fields.next().is_some() { + return Err(scale_encode::Error::custom_str( + "store call has more than one field", + )); + } + require_u8_sequence(types, field.id.clone())?; + self.0.encode_to(out); + Ok(()) + } +} + +/// Hard-error unless `type_id` resolves to a sequence whose element type is +/// the `u8` primitive. +fn require_u8_sequence( + types: &R, + type_id: R::TypeId, +) -> Result<(), scale_encode::Error> { + let sequence_visitor = visitor::new((), |(), _kind| None::) + .visit_sequence(|(), _path, inner| Some(inner)); + let inner = types + .resolve_type(type_id, sequence_visitor) + .map_err(|err| scale_encode::Error::custom_string(err.to_string()))? + .ok_or_else(|| { + scale_encode::Error::custom_str("store data field is not a byte sequence") + })?; + + let u8_visitor = visitor::new((), |(), _kind| false) + .visit_primitive(|(), primitive| matches!(primitive, Primitive::U8)); + let is_u8 = types + .resolve_type(inner, u8_visitor) + .map_err(|err| scale_encode::Error::custom_string(err.to_string()))?; + if !is_u8 { + return Err(scale_encode::Error::custom_str( + "store data field is not a sequence of u8", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_logic::extrinsic::tests::{OfflineChainState, bulletin_chain_state, split_v4}; + use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; + use parity_scale_codec::Decode; + use schnorrkel::{PublicKey, Signature}; + use subxt::client::{ClientAtBlock, OfflineClientAtBlockT}; + use subxt::ext::frame_metadata::{RuntimeMetadata, RuntimeMetadataPrefixed, v14}; + use subxt::metadata::{ArcMetadata, Metadata}; + use subxt::tx::Signer; + use subxt::utils::H256; + + #[derive(Debug, Clone, Copy)] + struct TestMortalityAnchor { + number: u64, + hash: [u8; 32], + } + + fn allowance_fixture() -> BulletinAllowanceKey { + let secret = hex::decode( + "0eef5183411d40c32446bb1cbaabd70004a17af6012a577c735d054f04059208\ + 573dfc9b6ffeb1c786a16349e70f9836876a743c31c0a7a2a70727a852eec372", + ) + .unwrap(); + BulletinAllowanceKey::from_secret_bytes(secret).unwrap() + } + + fn signer_fixture() -> Sr25519Signer { + allowance_signer(&allowance_fixture()).unwrap() + } + + fn anchor_fixture() -> TestMortalityAnchor { + TestMortalityAnchor { + number: 4200, + hash: [0xaa; 32], + } + } + + fn build_signed_store_transaction_offline>( + client: &ClientAtBlock, + anchor: &TestMortalityAnchor, + signer: &Sr25519Signer, + nonce: u64, + data: &[u8], + ) -> Result, String> { + let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(data)); + let params = DefaultExtrinsicParamsBuilder::::new() + .nonce(nonce) + .mortal_from_unchecked(MORTAL_PERIOD_BLOCKS, anchor.number, H256(anchor.hash)) + .build(); + client + .tx() + .create_v4_signable_offline(&payload, params) + .map_err(|err| format!("store transaction assembly failed: {err}"))? + .sign(signer) + .map_err(|err| format!("store transaction signing failed: {err}")) + } + + /// Decode the fixture metadata down to its mutable v14 representation. + fn bulletin_metadata_v14() -> v14::RuntimeMetadataV14 { + let prefixed = RuntimeMetadataPrefixed::decode( + &mut &crate::host_logic::extrinsic::tests::BULLETIN_METADATA_BYTES[..], + ) + .unwrap(); + match prefixed.1 { + RuntimeMetadata::V14(metadata) => metadata, + other => panic!("expected v14 fixture metadata, got {other:?}"), + } + } + + fn metadata_from_v14(metadata: v14::RuntimeMetadataV14) -> ArcMetadata { + let prefixed = + RuntimeMetadataPrefixed(u32::from_le_bytes(*b"meta"), RuntimeMetadata::V14(metadata)); + ArcMetadata::from(Metadata::try_from(prefixed).unwrap()) + } + + fn state_with_metadata(metadata: ArcMetadata) -> OfflineChainState { + OfflineChainState { + metadata, + ..bulletin_chain_state() + } + } + + fn extension_by_identifier( + metadata: &v14::RuntimeMetadataV14, + identifier: &str, + ) -> v14::SignedExtensionMetadata { + metadata + .extrinsic + .signed_extensions + .iter() + .find(|extension| extension.identifier == identifier) + .unwrap_or_else(|| panic!("fixture metadata lacks the {identifier} extension")) + .clone() + } + + #[test] + fn preimage_key_is_blake2b_256() { + assert_eq!( + hex::encode(preimage_key(b"")), + "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8" + ); + } + + #[test] + fn builds_and_signs_store_extrinsic_against_fixture() { + let state = bulletin_chain_state(); + let data = b"hello bulletin".to_vec(); + let client = state.client_at(anchor_fixture().number).unwrap(); + let signed = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 7, + &data, + ) + .unwrap(); + + let (account, signature, tail) = split_v4(signed.encoded()); + assert_eq!(account, signer_fixture().account_id().0); + let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(&data)); + let call_data = client.tx().call_data(&payload).unwrap(); + assert!(tail.ends_with(&call_data)); + + // The signature must verify over the reconstructed signer payload. + let params = DefaultExtrinsicParamsBuilder::::new() + .nonce(7) + .mortal_from_unchecked( + MORTAL_PERIOD_BLOCKS, + anchor_fixture().number, + H256(anchor_fixture().hash), + ) + .build(); + let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(&data)); + let signer_payload = client + .tx() + .create_v4_signable_offline(&payload, params) + .unwrap() + .signer_payload() + .unwrap(); + let public = PublicKey::from_bytes(&account).unwrap(); + assert!( + public + .verify_simple( + SR25519_SIGNING_CONTEXT, + &signer_payload, + &Signature::from_bytes(&signature).unwrap() + ) + .is_ok() + ); + } + + #[test] + fn genesis_hash_binds_the_signature() { + let data = b"pinned to one chain".to_vec(); + let client = bulletin_chain_state() + .client_at(anchor_fixture().number) + .unwrap(); + let signed = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 0, + &data, + ) + .unwrap(); + let (account, signature, _) = split_v4(signed.encoded()); + + let mutated_state = OfflineChainState { + genesis_hash: [0xcc; 32], + ..bulletin_chain_state() + }; + let client = mutated_state.client_at(anchor_fixture().number).unwrap(); + let params = DefaultExtrinsicParamsBuilder::::new() + .mortal_from_unchecked( + MORTAL_PERIOD_BLOCKS, + anchor_fixture().number, + H256(anchor_fixture().hash), + ) + .build(); + let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(&data)); + let mutated_payload = client + .tx() + .create_v4_signable_offline(&payload, params) + .unwrap() + .signer_payload() + .unwrap(); + + let public = PublicKey::from_bytes(&account).unwrap(); + assert!( + public + .verify_simple( + SR25519_SIGNING_CONTEXT, + &mutated_payload, + &Signature::from_bytes(&signature).unwrap() + ) + .is_err() + ); + } + + #[test] + fn rejects_mutated_store_argument_type() { + // Point the store call's `data` field at a non-u8-sequence type: the + // CheckSpecVersion additional (u32) borrowed from the extension list. + let mut metadata = bulletin_metadata_v14(); + let u32_type = extension_by_identifier(&metadata, "CheckSpecVersion").additional_signed; + let calls_type_id = metadata + .pallets + .iter() + .find(|pallet| pallet.name == "TransactionStorage") + .unwrap() + .calls + .as_ref() + .unwrap() + .ty + .id; + let calls_type = metadata + .types + .types + .iter_mut() + .find(|ty| ty.id == calls_type_id) + .unwrap(); + let scale_info::TypeDef::Variant(variants) = &mut calls_type.ty.type_def else { + panic!("calls type is not a variant"); + }; + let store = variants + .variants + .iter_mut() + .find(|variant| variant.name == "store") + .unwrap(); + store.fields[0].ty = u32_type; + + let state = state_with_metadata(metadata_from_v14(metadata)); + let client = state.client_at(anchor_fixture().number).unwrap(); + let error = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 0, + &[1, 2, 3], + ) + .unwrap_err(); + assert!(error.contains("not a byte sequence"), "{error}"); + } + + #[test] + fn rejects_integer_sequence_that_is_not_bytes() { + let mut metadata = bulletin_metadata_v14(); + let u32_type = extension_by_identifier(&metadata, "CheckSpecVersion").additional_signed; + let calls_type_id = metadata + .pallets + .iter() + .find(|pallet| pallet.name == STORE_PALLET_NAME) + .unwrap() + .calls + .as_ref() + .unwrap() + .ty + .id; + let calls_type = metadata + .types + .types + .iter() + .find(|ty| ty.id == calls_type_id) + .unwrap(); + let scale_info::TypeDef::Variant(variants) = &calls_type.ty.type_def else { + panic!("calls type is not a variant"); + }; + let data_type_id = variants + .variants + .iter() + .find(|variant| variant.name == STORE_CALL_NAME) + .unwrap() + .fields[0] + .ty + .id; + let data_type = metadata + .types + .types + .iter_mut() + .find(|ty| ty.id == data_type_id) + .unwrap(); + let scale_info::TypeDef::Sequence(sequence) = &mut data_type.ty.type_def else { + panic!("store data type is not a sequence"); + }; + sequence.type_param = u32_type; + + let state = state_with_metadata(metadata_from_v14(metadata)); + let client = state.client_at(anchor_fixture().number).unwrap(); + let error = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 0, + &[1, 2, 3], + ) + .unwrap_err(); + assert!(error.contains("not a sequence of u8"), "{error}"); + } + + #[test] + fn unknown_extension_with_non_empty_implicit_errors() { + let mut metadata = bulletin_metadata_v14(); + let mut fake = extension_by_identifier(&metadata, "CheckSpecVersion"); + fake.identifier = "FakeImplicitExt".to_string(); + metadata.extrinsic.signed_extensions.push(fake); + + let state = state_with_metadata(metadata_from_v14(metadata)); + let client = state.client_at(anchor_fixture().number).unwrap(); + let error = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 0, + &[1], + ) + .unwrap_err(); + assert!(error.contains("FakeImplicitExt"), "{error}"); + } + + #[test] + fn unknown_extension_with_non_empty_value_errors() { + let mut metadata = bulletin_metadata_v14(); + let mut fake = extension_by_identifier(&metadata, "CheckNonce"); + fake.identifier = "FakeValueExt".to_string(); + metadata.extrinsic.signed_extensions.push(fake); + + let state = state_with_metadata(metadata_from_v14(metadata)); + let client = state.client_at(anchor_fixture().number).unwrap(); + let error = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 0, + &[1], + ) + .unwrap_err(); + assert!(error.contains("FakeValueExt"), "{error}"); + } + + #[test] + fn unknown_extension_with_option_value_encodes_none() { + // Accepted gap: an unknown extension whose extra is a bare `Option` + // silently encodes `None` instead of erroring. + let mut metadata = bulletin_metadata_v14(); + let option_type = extension_by_identifier(&metadata, "CheckMetadataHash").additional_signed; + let empty_type = extension_by_identifier(&metadata, "CheckSpecVersion").ty; + let mut fake = extension_by_identifier(&metadata, "CheckSpecVersion"); + fake.identifier = "FakeOptionExt".to_string(); + fake.ty = option_type; + fake.additional_signed = empty_type; + metadata.extrinsic.signed_extensions.push(fake); + + let state = state_with_metadata(metadata_from_v14(metadata)); + let baseline_client = bulletin_chain_state() + .client_at(anchor_fixture().number) + .unwrap(); + let baseline = build_signed_store_transaction_offline( + &baseline_client, + &anchor_fixture(), + &signer_fixture(), + 0, + &[1], + ) + .unwrap(); + let client = state.client_at(anchor_fixture().number).unwrap(); + let with_fake = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 0, + &[1], + ) + .unwrap(); + assert_eq!( + with_fake.encoded().len(), + baseline.encoded().len() + 1, + "the Option-typed extra should contribute exactly one None byte" + ); + } + + #[test] + fn builds_large_preimage_without_pathological_cost() { + // Keep a generous bound around metadata validation and byte encoding so + // a future library change cannot make large preimages pathological. + let data = vec![0x5au8; 8 * 1024 * 1024]; + let client = bulletin_chain_state() + .client_at(anchor_fixture().number) + .unwrap(); + let start = std::time::Instant::now(); + let signed = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 0, + &data, + ) + .unwrap(); + let elapsed = start.elapsed(); + assert!(signed.encoded().len() > data.len()); + assert!( + elapsed < std::time::Duration::from_secs(5), + "building an 8 MiB store extrinsic took {elapsed:?}" + ); + } + + #[test] + fn rejects_secret_of_wrong_shape() { + let error = + allowance_signer(&BulletinAllowanceKey::from_secret_bytes(vec![0xff; 64]).unwrap()) + .unwrap_err(); + assert!(error.contains("invalid bulletin allowance key"), "{error}"); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/entropy.rs b/rust/crates/truapi-server/src/host_logic/entropy.rs index 1c9323d9..652a880c 100644 --- a/rust/crates/truapi-server/src/host_logic/entropy.rs +++ b/rust/crates/truapi-server/src/host_logic/entropy.rs @@ -5,7 +5,6 @@ //! Host-spec C.8 defines the RFC-0007 product entropy algorithm: //! -use blake2_rfc::blake2b::blake2b; use thiserror::Error; const DOMAIN_SEPARATOR: &[u8] = b"product-entropy-derivation"; @@ -45,8 +44,11 @@ pub fn derive_product_entropy_from_source( } fn blake2b256_keyed(message: &[u8], key: &[u8]) -> [u8; 32] { - let hash = blake2b(32, key, message); - hash.as_bytes() + blake2b_simd::Params::new() + .hash_length(32) + .key(key) + .hash(message) + .as_bytes() .try_into() .expect("BLAKE2b-256 returns 32 bytes") } diff --git a/rust/crates/truapi-server/src/host_logic/extrinsic.rs b/rust/crates/truapi-server/src/host_logic/extrinsic.rs new file mode 100644 index 00000000..713ed740 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/extrinsic.rs @@ -0,0 +1,363 @@ +//! sr25519 transaction signing shared by chain-facing runtime services. +//! +//! [`Sr25519Signer`] is the one subxt [`Signer`] in the crate; Bulletin +//! preimage submission and the signing-host product key both go through it. +//! [`build_signed_extrinsic_v4`] assembles a signed V4 extrinsic from +//! caller-supplied, already-SCALE-encoded parts (local `create_transaction`), +//! so it needs no metadata at all. + +use parity_scale_codec::Encode; +use schnorrkel::{PublicKey, SecretKey}; +use subxt::config::substrate::SubstrateConfig; +use subxt::tx::Signer; +use subxt::utils::{AccountId32, MultiAddress, MultiSignature}; +use truapi::v01; + +use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; + +/// Parse a 64-byte sr25519 secret in either of the two wire encodings. +/// +/// Rust-generated keys use schnorrkel's canonical scalar bytes; legacy +/// JS-derived keys use scure/ed25519-expanded scalar bytes. Signatures are +/// identical for both once parsed, so callers never need to know which form +/// they hold. +pub(crate) fn sr25519_secret_from_bytes(secret: &[u8; 64]) -> Result { + match SecretKey::from_bytes(secret) { + Ok(secret) => Ok(secret), + Err(canonical_error) => SecretKey::from_ed25519_bytes(secret).map_err(|ed_error| { + format!("invalid sr25519 secret: canonical={canonical_error}; ed25519={ed_error}") + }), + } +} + +/// sr25519 [`Signer`] over a parsed schnorrkel key. +/// +/// Holds only the parsed key (schnorrkel zeroizes it on drop), never the raw +/// secret bytes. +#[derive(derive_more::Debug)] +pub(crate) struct Sr25519Signer { + #[debug("\"\"")] + secret: SecretKey, + #[debug("{}", hex::encode(public.to_bytes()))] + public: PublicKey, +} + +impl Sr25519Signer { + /// Parse a signer from 64-byte secret material. + pub(crate) fn from_secret_bytes(secret: &[u8; 64]) -> Result { + let secret = sr25519_secret_from_bytes(secret)?; + let public = secret.to_public(); + Ok(Self { secret, public }) + } + + /// Build a signer from an already-derived schnorrkel keypair (e.g. a + /// signing-host product key), reusing the same `sign`/`account_id` path. + pub(crate) fn from_keypair(keypair: &schnorrkel::Keypair) -> Self { + Self { + secret: keypair.secret.clone(), + public: keypair.public, + } + } +} + +impl Signer for Sr25519Signer { + fn account_id(&self) -> AccountId32 { + AccountId32(self.public.to_bytes()) + } + + fn sign(&self, signer_payload: &[u8]) -> MultiSignature { + let signature = + self.secret + .sign_simple(SR25519_SIGNING_CONTEXT, signer_payload, &self.public); + MultiSignature::Sr25519(signature.to_bytes()) + } +} + +/// The V4 signer payload: `call_data ++ Σextra ++ Σadditional_signed`, replaced +/// by its blake2_256 hash only when it exceeds 256 bytes. +/// +/// Note the order differs from the extrinsic body (which puts `extra` before +/// the call): the call comes first here, extras next, implicits last. +fn v4_signer_payload(call_data: &[u8], extensions: &[v01::TxPayloadExtension]) -> Vec { + let mut payload = Vec::with_capacity(call_data.len()); + payload.extend_from_slice(call_data); + for ext in extensions { + payload.extend_from_slice(&ext.extra); + } + for ext in extensions { + payload.extend_from_slice(&ext.additional_signed); + } + if payload.len() > 256 { + sp_crypto_hashing::blake2_256(&payload).to_vec() + } else { + payload + } +} + +/// Assemble a signed Extrinsic V4 from caller-supplied, already-SCALE-encoded +/// parts, entirely offline. +/// +/// Body layout (matches `frame_decode::encode_v4_signed` and subxt's own +/// assembler byte-for-byte): +/// +/// ```text +/// Compact(len) ++ 0x84 ++ 0x00 ++ signer(32) ++ 0x01 ++ signature(64) +/// ++ Σ extension.extra ++ call_data +/// ``` +/// +/// `0x84` is the V4 "signed" version byte, `0x00` the `MultiAddress::Id` +/// discriminant, `0x01` the `MultiSignature::Sr25519` discriminant. `extra` +/// bytes go in the body (in the given order, which must be the runtime's +/// canonical extension order); `additional_signed` bytes appear only in the +/// signed payload. The chain binding (genesis, spec/tx version, mortality +/// anchor, nonce, tip) lives inside the caller's extension bytes, so nothing +/// here is metadata-driven. +pub(crate) fn build_signed_extrinsic_v4( + signer: &Sr25519Signer, + call_data: &[u8], + extensions: &[v01::TxPayloadExtension], +) -> Vec { + /// `0b1000_0000 | 4`: the "signed" bit plus extrinsic format version 4. + const EXTRINSIC_V4_SIGNED: u8 = 0x84; + + let signature = signer.sign(&v4_signer_payload(call_data, extensions)); + let address = MultiAddress::::Id(signer.account_id()); + + let mut inner = Vec::new(); + inner.push(EXTRINSIC_V4_SIGNED); + address.encode_to(&mut inner); + signature.encode_to(&mut inner); + for ext in extensions { + inner.extend_from_slice(&ext.extra); + } + inner.extend_from_slice(call_data); + + // `Vec::encode` prepends the SCALE compact length, giving the outer + // length-prefixed opaque extrinsic. + inner.encode() +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use parity_scale_codec::{Compact, Decode}; + use subxt::client::{OfflineClient, OfflineClientAtBlock}; + use subxt::config::substrate::{SpecVersionForRange, SubstrateConfigBuilder}; + use subxt::ext::frame_metadata::RuntimeMetadataPrefixed; + use subxt::metadata::{ArcMetadata, Metadata}; + use subxt::utils::H256; + + /// Everything needed to build transactions offline for one chain at one + /// runtime version, mirroring what the production path gets from the + /// genesis-pinned online client. + pub(crate) struct OfflineChainState { + pub(crate) genesis_hash: [u8; 32], + pub(crate) spec_version: u32, + pub(crate) transaction_version: u32, + pub(crate) metadata: ArcMetadata, + } + + impl OfflineChainState { + /// Build an offline subxt client pinned at `block_number`. + pub(crate) fn client_at( + &self, + block_number: u64, + ) -> Result, String> { + let config = SubstrateConfigBuilder::new() + .set_genesis_hash(H256(self.genesis_hash)) + .set_spec_version_for_block_ranges([SpecVersionForRange { + block_range: 0..u64::MAX, + spec_version: self.spec_version, + transaction_version: self.transaction_version, + }]) + .set_metadata_for_spec_versions([(self.spec_version, self.metadata.clone())]) + .build(); + OfflineClient::new_with_config(config) + .at_block(block_number) + .map_err(|err| format!("offline client unavailable: {err}")) + } + } + + /// Raw `RuntimeMetadataPrefixed` bytes captured from the live + /// bulletin-paseo chain (`state_getMetadata`, spec 1000020, metadata v14). + pub(crate) const BULLETIN_METADATA_BYTES: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/bulletin_paseo_metadata.scale" + )); + + /// Decode the checked-in bulletin metadata fixture. + pub(crate) fn bulletin_metadata() -> Metadata { + let prefixed = RuntimeMetadataPrefixed::decode(&mut &BULLETIN_METADATA_BYTES[..]).unwrap(); + Metadata::try_from(prefixed).unwrap() + } + + /// Offline chain state over the bulletin fixture with a recognizable + /// genesis hash. + pub(crate) fn bulletin_chain_state() -> OfflineChainState { + OfflineChainState { + genesis_hash: [0xbb; 32], + spec_version: 1_000_020, + transaction_version: 1, + metadata: ArcMetadata::from(bulletin_metadata()), + } + } + + #[test] + fn parses_canonical_and_ed25519_expanded_secrets() { + let canonical: [u8; 64] = hex::decode( + "0eef5183411d40c32446bb1cbaabd70004a17af6012a577c735d054f04059208\ + 573dfc9b6ffeb1c786a16349e70f9836876a743c31c0a7a2a70727a852eec372", + ) + .unwrap() + .try_into() + .unwrap(); + assert!(sr25519_secret_from_bytes(&canonical).is_ok()); + + let mini = schnorrkel::MiniSecretKey::from_bytes(&[7; 32]).unwrap(); + let expanded = mini + .expand(schnorrkel::ExpansionMode::Ed25519) + .to_ed25519_bytes(); + let expanded: [u8; 64] = expanded.as_slice().try_into().unwrap(); + let parsed = sr25519_secret_from_bytes(&expanded).unwrap(); + assert_eq!( + parsed.to_public().to_bytes(), + mini.expand_to_keypair(schnorrkel::ExpansionMode::Ed25519) + .public + .to_bytes() + ); + } + + #[test] + fn signer_signs_under_substrate_context() { + let mini = schnorrkel::MiniSecretKey::from_bytes(&[9; 32]).unwrap(); + let secret: [u8; 64] = mini + .expand(schnorrkel::ExpansionMode::Ed25519) + .to_bytes() + .as_slice() + .try_into() + .unwrap(); + let signer = Sr25519Signer::from_secret_bytes(&secret).unwrap(); + + let payload = b"payload"; + let MultiSignature::Sr25519(signature) = signer.sign(payload) else { + panic!("expected sr25519 signature"); + }; + let public = PublicKey::from_bytes(&signer.account_id().0).unwrap(); + assert!( + public + .verify_simple( + SR25519_SIGNING_CONTEXT, + payload, + &schnorrkel::Signature::from_bytes(&signature).unwrap() + ) + .is_ok() + ); + } + + fn test_signer() -> Sr25519Signer { + let keypair = schnorrkel::MiniSecretKey::from_bytes(&[3; 32]) + .unwrap() + .expand_to_keypair(schnorrkel::ExpansionMode::Ed25519); + Sr25519Signer::from_keypair(&keypair) + } + + fn ext(id: &str, extra: &[u8], additional: &[u8]) -> v01::TxPayloadExtension { + v01::TxPayloadExtension { + id: id.to_string(), + extra: extra.to_vec(), + additional_signed: additional.to_vec(), + } + } + + /// Split a length-prefixed V4 signed extrinsic into + /// (account, signature, trailing-bytes-after-signature). + pub(crate) fn split_v4(extrinsic: &[u8]) -> ([u8; 32], [u8; 64], Vec) { + let mut input = extrinsic; + let len = Compact::::decode(&mut input).unwrap().0 as usize; + assert_eq!(input.len(), len, "compact length must cover the remainder"); + assert_eq!(input[0], 0x84, "V4 signed version byte"); + assert_eq!(input[1], 0x00, "MultiAddress::Id discriminant"); + let account: [u8; 32] = input[2..34].try_into().unwrap(); + assert_eq!(input[34], 0x01, "MultiSignature::Sr25519 discriminant"); + let signature: [u8; 64] = input[35..99].try_into().unwrap(); + (account, signature, input[99..].to_vec()) + } + + #[test] + fn v4_layout_and_signature_verify() { + let signer = test_signer(); + let call_data = vec![0x2a, 0x00, 0xde, 0xad]; + let extensions = vec![ + ext("CheckNonce", &[0x04], &[]), + ext("CheckGenesis", &[], &[9; 32]), + ]; + + let extrinsic = build_signed_extrinsic_v4(&signer, &call_data, &extensions); + let (account, signature, tail) = split_v4(&extrinsic); + + // Body tail is Σextra ++ call_data (extra before call). + let mut expected_tail = Vec::new(); + expected_tail.extend_from_slice(&extensions[0].extra); + expected_tail.extend_from_slice(&extensions[1].extra); + expected_tail.extend_from_slice(&call_data); + assert_eq!(account, signer.account_id().0); + assert_eq!(tail, expected_tail); + + // Signature verifies over call ++ Σextra ++ Σadditional (call first). + let mut payload = call_data.clone(); + payload.extend_from_slice(&extensions[0].extra); + payload.extend_from_slice(&extensions[1].extra); + payload.extend_from_slice(&extensions[0].additional_signed); + payload.extend_from_slice(&extensions[1].additional_signed); + let public = PublicKey::from_bytes(&account).unwrap(); + assert!( + public + .verify_simple( + SR25519_SIGNING_CONTEXT, + &payload, + &schnorrkel::Signature::from_bytes(&signature).unwrap() + ) + .is_ok() + ); + } + + #[test] + fn v4_signer_payload_hashes_when_over_256_bytes() { + let signer = test_signer(); + let call_data = vec![1u8; 200]; + // Push the payload (call ++ extra ++ additional) over 256 bytes. + let extensions = vec![ext("Big", &[2u8; 60], &[3u8; 60])]; + let extrinsic = build_signed_extrinsic_v4(&signer, &call_data, &extensions); + let (account, signature, _) = split_v4(&extrinsic); + let public = PublicKey::from_bytes(&account).unwrap(); + let sig = schnorrkel::Signature::from_bytes(&signature).unwrap(); + + let mut raw = call_data.clone(); + raw.extend_from_slice(&extensions[0].extra); + raw.extend_from_slice(&extensions[0].additional_signed); + assert!(raw.len() > 256); + let hashed = sp_crypto_hashing::blake2_256(&raw); + + // Signs over the hash, not the raw concatenation. + assert!( + public + .verify_simple(SR25519_SIGNING_CONTEXT, &hashed, &sig) + .is_ok() + ); + assert!( + public + .verify_simple(SR25519_SIGNING_CONTEXT, &raw, &sig) + .is_err() + ); + } + + #[test] + fn v4_preserves_extension_order() { + let signer = test_signer(); + let call_data = vec![0xff]; + // Deliberately not in sorted order; the assembler must not reorder. + let extensions = vec![ext("B", &[0xbb], &[]), ext("A", &[0xaa], &[])]; + let (_, _, tail) = split_v4(&build_signed_extrinsic_v4(&signer, &call_data, &extensions)); + assert_eq!(tail, vec![0xbb, 0xaa, 0xff]); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/product_account.rs b/rust/crates/truapi-server/src/host_logic/product_account.rs index cfc329aa..a70cceff 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -6,7 +6,6 @@ //! `ProductAccountId` shape: //! -use blake2_rfc::blake2b::blake2b; use parity_scale_codec::Encode; use schnorrkel::derive::{ChainCode, Derivation}; use schnorrkel::{ExpansionMode, Keypair, PublicKey}; @@ -14,8 +13,6 @@ use thiserror::Error; const JUNCTION_ID_LEN: usize = 32; const PRODUCT_JUNCTION: &str = "product"; -const SS58_PREFIX: &[u8] = b"SS58PRE"; -const SUBSTRATE_GENERIC_SS58_PREFIX: u8 = 42; /// Substrate sr25519 signing-context string, shared by every sr25519 signature /// the core produces (statement store, product raw signing). @@ -86,18 +83,12 @@ pub fn derive_product_public_key( } /// Encode a product account public key as a generic Substrate SS58 address. +/// +/// Delegates to subxt's `AccountId32` Display, which is the generic-substrate +/// prefix-42 SS58-check encoding host-spec C.6 mandates; the test vector +/// below pins the format against drift. pub fn product_public_key_to_address(public_key: [u8; 32]) -> String { - let mut payload = Vec::with_capacity(35); - payload.push(SUBSTRATE_GENERIC_SS58_PREFIX); - payload.extend_from_slice(&public_key); - - let mut checksum_input = Vec::with_capacity(SS58_PREFIX.len() + payload.len()); - checksum_input.extend_from_slice(SS58_PREFIX); - checksum_input.extend_from_slice(&payload); - let checksum = blake2b(64, &[], &checksum_input); - payload.extend_from_slice(&checksum.as_bytes()[..2]); - - bs58::encode(payload).into_string() + subxt::utils::AccountId32(public_key).to_string() } /// Create a Substrate soft-derivation chain code for one junction. @@ -112,8 +103,7 @@ fn create_chain_code(code: &str) -> Result<[u8; 32], ProductAccountError> { let mut chain_code = [0u8; JUNCTION_ID_LEN]; if encoded.len() > JUNCTION_ID_LEN { - let hash = blake2b(JUNCTION_ID_LEN, &[], &encoded); - chain_code.copy_from_slice(hash.as_bytes()); + chain_code = sp_crypto_hashing::blake2_256(&encoded); } else { chain_code[..encoded.len()].copy_from_slice(&encoded); } diff --git a/rust/crates/truapi-server/src/host_logic/session.rs b/rust/crates/truapi-server/src/host_logic/session.rs index b19f4704..b8970fb0 100644 --- a/rust/crates/truapi-server/src/host_logic/session.rs +++ b/rust/crates/truapi-server/src/host_logic/session.rs @@ -140,6 +140,24 @@ impl SessionState { } } + /// Replace the active session only when it still matches `expected`. + pub fn replace_session_if_current(&self, expected: &SessionInfo, info: SessionInfo) -> bool { + let mut inner = self.inner.lock().expect("session-state mutex poisoned"); + if inner.current.as_ref() != Some(expected) { + return false; + } + + let should_broadcast = inner.current.as_ref() != Some(&info); + inner.current = Some(info); + if should_broadcast { + broadcast( + &mut inner.subscribers, + HostAccountConnectionStatusSubscribeItem::Connected, + ); + } + true + } + /// Drop the active session. Emits a `Disconnected` event to every live /// subscriber if there was a session to clear. pub fn clear_session(&self) { @@ -240,6 +258,30 @@ mod tests { assert_eq!(got.lite_username.as_deref(), Some("alice")); } + #[test] + fn replace_session_if_current_rejects_stale_expected_session() { + let state = SessionState::new(); + let original = info(0x01); + let replacement = info(0x02); + state.set_session(original.clone()); + state.set_session(replacement.clone()); + + assert!(!state.replace_session_if_current(&original, info(0x03))); + assert_eq!(state.current(), Some(replacement)); + } + + #[test] + fn replace_session_if_current_updates_matching_session() { + let state = SessionState::new(); + let original = info(0x01); + let replacement = info(0x02); + state.set_session(original.clone()); + + assert!(state.replace_session_if_current(&original, replacement.clone())); + + assert_eq!(state.current(), Some(replacement)); + } + #[test] fn persisted_session_round_trips() { let mut session = info(0x42); diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs index eff47058..4c09adb5 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -14,7 +14,6 @@ use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Nonce}; -use blake2_rfc::blake2b::blake2b; use hkdf::Hkdf; use p256::ecdh::diffie_hellman; use p256::elliptic_curve::sec1::ToEncodedPoint; @@ -310,7 +309,10 @@ fn create_session_id( } fn keyed_hash(key: [u8; 32], message: &[u8]) -> [u8; 32] { - let digest = blake2b(32, &key, message); + let digest = blake2b_simd::Params::new() + .hash_length(32) + .key(&key) + .hash(message); let mut output = [0u8; 32]; output.copy_from_slice(digest.as_bytes()); output @@ -486,6 +488,7 @@ mod tests { version: Some("192.32".to_string()), }, [0; 32], + [0xbb; 32], "polkadotapp".to_string(), ) .expect("test runtime config is valid") diff --git a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs index 1340cd3a..f7dd7021 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs @@ -1,8 +1,9 @@ use parity_scale_codec::{Compact, Decode, Encode}; -use schnorrkel::{PublicKey, SecretKey, Signature}; +use schnorrkel::{PublicKey, Signature}; use truapi::v01; use super::StatementStoreParseError; +use crate::host_logic::extrinsic::sr25519_secret_from_bytes; use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; use crate::host_logic::session::SsoSessionInfo; @@ -175,7 +176,8 @@ pub fn sign_statement_fields( } fields.sort_by_key(statement_field_sort_index); - let secret = statement_secret_key_from_bytes(ss_secret)?; + let secret = sr25519_secret_from_bytes(&ss_secret) + .map_err(|reason| format!("invalid ss_secret: {reason}"))?; let public = secret.to_public(); if public.to_bytes() != expected_public_key { return Err("ss_secret does not match session statement public key".to_string()); @@ -197,21 +199,11 @@ pub fn sign_statement_fields( /// Derive the sr25519 public key for a 64-byte statement-store secret. pub fn statement_public_key_from_secret(ss_secret: [u8; 64]) -> Result<[u8; 32], String> { - let secret = statement_secret_key_from_bytes(ss_secret)?; + let secret = sr25519_secret_from_bytes(&ss_secret) + .map_err(|reason| format!("invalid ss_secret: {reason}"))?; Ok(secret.to_public().to_bytes()) } -fn statement_secret_key_from_bytes(ss_secret: [u8; 64]) -> Result { - // Rust-generated session keys use schnorrkel's canonical scalar bytes. - // Legacy JS signers may send scure/ed25519-style scalar bytes instead. - match SecretKey::from_bytes(&ss_secret) { - Ok(secret) => Ok(secret), - Err(canonical_error) => SecretKey::from_ed25519_bytes(&ss_secret).map_err(|ed_error| { - format!("invalid ss_secret: canonical={canonical_error}; ed25519={ed_error}") - }), - } -} - /// Build the statement proof payload for unsigned fields. pub fn unsigned_statement_signing_payload( mut fields: Vec, diff --git a/rust/crates/truapi-server/src/host_rpc_client.rs b/rust/crates/truapi-server/src/host_rpc_client.rs index b487107f..d158a6ad 100644 --- a/rust/crates/truapi-server/src/host_rpc_client.rs +++ b/rust/crates/truapi-server/src/host_rpc_client.rs @@ -15,7 +15,7 @@ use std::sync::{Arc, Mutex}; use futures::channel::{mpsc, oneshot}; use futures::{FutureExt, pin_mut}; use futures::{Stream, StreamExt}; -use serde::Serialize; +use serde::{Serialize, Serializer}; use serde_json::value::RawValue; use subxt_rpcs::client::{RawRpcFuture, RawRpcSubscription, RpcClientT}; use subxt_rpcs::{Error as RpcError, UserError}; @@ -65,10 +65,23 @@ struct JsonRpcRequest<'a> { jsonrpc: &'static str, id: &'a str, method: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(serialize_with = "serialize_json_rpc_params")] params: Option<&'a RawValue>, } +fn serialize_json_rpc_params( + params: &Option<&RawValue>, + serializer: S, +) -> Result +where + S: Serializer, +{ + match params { + Some(params) => params.serialize(serializer), + None => <[(); 0]>::default().serialize(serializer), + } +} + impl HostRpcClient { /// Wrap `connection` and start the response pump on `spawner`. pub(crate) fn new(connection: Arc, spawner: Spawner) -> Self { @@ -186,11 +199,12 @@ impl HostRpcClientInner { method: &str, params: Option<&RawValue>, ) -> Result<(), RpcError> { + let normalized_params = normalize_outbound_params(method, params)?; let request = JsonRpcRequest { jsonrpc: "2.0", id, method, - params, + params: normalized_params.as_deref().or(params), }; let encoded = serde_json::to_string(&request).map_err(RpcError::Serialization)?; self.connection.send(encoded); @@ -449,6 +463,31 @@ fn raw_value_from_json(value: &serde_json::Value) -> Result, RpcEr RawValue::from_string(value.to_string()).map_err(RpcError::Deserialization) } +/// PAPI's modern middleware requires the array variant even though Subxt emits +/// the protocol's valid single-hash unpin form. +fn normalize_outbound_params( + method: &str, + params: Option<&RawValue>, +) -> Result>, RpcError> { + if method != "chainHead_v1_unpin" { + return Ok(None); + } + let Some(params) = params else { + return Ok(None); + }; + let mut params: Vec = + serde_json::from_str(params.get()).map_err(RpcError::Serialization)?; + let Some(hash_slot @ serde_json::Value::String(_)) = params.get_mut(1) else { + return Ok(None); + }; + let hash = mem::take(hash_slot); + *hash_slot = serde_json::Value::Array(vec![hash]); + let encoded = serde_json::to_string(¶ms).map_err(RpcError::Serialization)?; + RawValue::from_string(encoded) + .map(Some) + .map_err(RpcError::Serialization) +} + fn subscription_id_from_raw(raw: &RawValue) -> Result { let value: serde_json::Value = serde_json::from_str(raw.get()).map_err(RpcError::Deserialization)?; @@ -490,6 +529,7 @@ mod tests { struct TrackingConnection { sender: Mutex>>, receiver: Mutex>>, + sent: Mutex>, close_count: AtomicUsize, } @@ -499,6 +539,7 @@ mod tests { Arc::new(Self { sender: Mutex::new(Some(tx)), receiver: Mutex::new(Some(rx)), + sent: Mutex::new(Vec::new()), close_count: AtomicUsize::new(0), }) } @@ -506,6 +547,10 @@ mod tests { fn close_count(&self) -> usize { self.close_count.load(Ordering::SeqCst) } + + fn sent(&self) -> Vec { + self.sent.lock().unwrap().clone() + } } impl JsonRpcConnection for TrackingConnection { @@ -513,6 +558,7 @@ mod tests { let Ok(value) = serde_json::from_str::(&request) else { return; }; + self.sent.lock().unwrap().push(value.clone()); let Some(id) = value.get("id").cloned() else { return; }; @@ -558,6 +604,36 @@ mod tests { assert_eq!(connection.close_count(), 1); } + #[test] + fn requests_without_arguments_serialize_empty_params() { + let connection = TrackingConnection::new(); + let spawner: Spawner = Arc::new(|_| {}); + let client = HostRpcClient::new(connection.clone(), spawner); + + client + .send_fire_and_forget("chainSpec_v1_chainName", None) + .unwrap(); + + assert_eq!(connection.sent()[0]["params"], json!([])); + } + + #[test] + fn subxt_single_hash_unpin_is_normalized_for_host_providers() { + let connection = TrackingConnection::new(); + let spawner: Spawner = Arc::new(|_| {}); + let client = HostRpcClient::new(connection.clone(), spawner); + let params = RawValue::from_string(r#"["follow-id","0x1234"]"#.to_string()).unwrap(); + + client + .send_fire_and_forget("chainHead_v1_unpin", Some(params)) + .unwrap(); + + assert_eq!( + connection.sent()[0]["params"], + json!(["follow-id", ["0x1234"]]), + ); + } + #[test] fn subscription_stream_holds_connection_lease_until_dropped() { let connection = TrackingConnection::new(); diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index c4b18ce2..808d0c38 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -10,6 +10,7 @@ mod allowances; pub(crate) mod auth_state; mod authority; +pub(crate) mod bulletin_rpc; mod identity; mod pairing_host; pub(crate) mod services; @@ -22,9 +23,13 @@ mod statement_store_rpc; use core::future::Future; use core::time::Duration; use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Instant; +#[cfg(target_arch = "wasm32")] +use web_time::Instant; use crate::chain_runtime::RuntimeFailure; -use crate::host_logic::allowance_signer::bulletin_allowance_signer_from_key; +use crate::host_logic::bulletin::preimage_key; use crate::host_logic::dotns::{NavigateDecision, parse_navigate}; use crate::host_logic::features::feature_supported; use crate::host_logic::permissions::PermissionsService; @@ -34,9 +39,10 @@ use crate::host_logic::product_account::{ use crate::host_logic::session::SessionInfo; #[cfg(test)] use crate::host_logic::session::SessionState; +use crate::runtime::bulletin_rpc::BulletinSubmitError; #[cfg(test)] use crate::subscription::Spawner; -pub(crate) use authority::ProductAuthority; +pub(crate) use authority::{BulletinAllowanceKey, ProductAuthority}; #[cfg(test)] use pairing_host::PairingHost; pub(crate) use pairing_host::PairingHost as PairingHostRole; @@ -145,9 +151,12 @@ pub(super) const REMOTE_PERMISSION_DENIED_REASON: &str = "Permission denied"; /// after 180 seconds: /// const DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT: Duration = Duration::from_secs(180); -/// Preimage submission is exercised by host diagnosis with a 60s method -/// watchdog. Keep the allowance request below that so products receive a -/// TrUAPI error instead of an outer harness timeout. +/// End-to-end timeout for an in-core Bulletin preimage submit, starting after +/// user confirmation and covering allowance allocation, chain submission, and +/// one optional allowance refresh and retry. +const PREIMAGE_SUBMIT_TIMEOUT: Duration = Duration::from_secs(60); +/// Per-request cap for obtaining or refreshing the Bulletin allowance. The +/// end-to-end submit deadline may reduce it further. const PREIMAGE_REMOTE_AUTHORITY_RESPONSE_TIMEOUT: Duration = Duration::from_secs(45); fn remote_authority_context(cx: &CallContext) -> CallContext { @@ -165,6 +174,20 @@ fn remote_authority_context_with_default( cx } +fn remote_authority_context_until( + cx: &CallContext, + default_timeout: Duration, + deadline: Instant, +) -> CallContext { + let mut cx = cx.clone(); + let timeout = cx + .timeout() + .unwrap_or(default_timeout) + .min(deadline.saturating_duration_since(Instant::now())); + cx.set_timeout(timeout); + cx +} + async fn remote_authority_call(cx: &CallContext, call: F) -> Result where F: Future>, @@ -258,11 +281,8 @@ impl ProductRuntimeHost { } #[cfg(test)] - fn new_compat_with_pairing( - platform: Arc, - spawner: Spawner, - ) -> (Self, Arc) { - let host_config = truapi_platform::PairingHostConfig::new( + fn compat_host_config() -> truapi_platform::PairingHostConfig { + truapi_platform::PairingHostConfig::new( truapi_platform::HostInfo { name: "Polkadot Web".to_string(), icon: Some("https://example.invalid/dotli.png".to_string()), @@ -270,9 +290,31 @@ impl ProductRuntimeHost { }, truapi_platform::PlatformInfo::default(), [0; 32], + [0xbb; 32], "polkadotapp".to_string(), ) - .expect("compat runtime config is valid"); + .expect("compat runtime config is valid") + } + + /// Compat host used by preimage tests. + #[cfg(test)] + fn new_compat_with_bulletin(platform: Arc, spawner: Spawner) -> Self { + Self::new_pairing_for_tests( + platform, + Self::compat_host_config(), + ProductContext::new("unknown.dot".to_string()) + .expect("compat product context is valid"), + spawner, + ) + .0 + } + + #[cfg(test)] + fn new_compat_with_pairing( + platform: Arc, + spawner: Spawner, + ) -> (Self, Arc) { + let host_config = Self::compat_host_config(); Self::new_pairing_for_tests( platform, host_config, @@ -292,6 +334,7 @@ impl ProductRuntimeHost { let services = RuntimeServices::new( platform.clone(), host_config.people_chain_genesis_hash, + host_config.bulletin_chain_genesis_hash, spawner.clone(), ); let pairing_host = PairingHost::new(services.clone(), host_config); @@ -902,24 +945,22 @@ impl Account for ProductRuntimeHost { Err(reason) => return Err(CallError::HostFailure { reason }), } - let primary_username = session - .full_username - .clone() - .filter(|value| !value.is_empty()) - .or_else(|| { - session - .lite_username - .clone() - .filter(|value| !value.is_empty()) - }) - .ok_or_else(|| { - CallError::Domain(HostGetUserIdError::V1(v01::HostGetUserIdError::Unknown { - reason: "No primary username for this session".to_string(), - })) - })?; + let session = if session.primary_username().is_some() { + session + } else { + self.authority + .refresh_session_identity() + .await + .unwrap_or(session) + }; + let primary_username = session.primary_username().ok_or_else(|| { + CallError::Domain(HostGetUserIdError::V1(v01::HostGetUserIdError::Unknown { + reason: "No primary username for this session".to_string(), + })) + })?; Ok(HostGetUserIdResponse::V1(v01::HostGetUserIdResponse { - primary_username, + primary_username: primary_username.to_string(), })) } @@ -958,9 +999,9 @@ fn account_get_error_from_authority(err: AuthorityError) -> v01::HostAccountGetE AuthorityError::Cancelled(err) => v01::HostAccountGetError::Unknown { reason: err.to_string(), }, - AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { - v01::HostAccountGetError::Unknown { reason } - } + AuthorityError::Unavailable { reason } + | AuthorityError::NotSupported { reason } + | AuthorityError::Unknown { reason } => v01::HostAccountGetError::Unknown { reason }, } } @@ -975,9 +1016,9 @@ fn signing_call_error( AuthorityError::Cancelled(err) => v01::HostSignPayloadError::Unknown { reason: err.to_string(), }, - AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { - v01::HostSignPayloadError::Unknown { reason } - } + AuthorityError::Unavailable { reason } + | AuthorityError::NotSupported { reason } + | AuthorityError::Unknown { reason } => v01::HostSignPayloadError::Unknown { reason }, })) } @@ -992,6 +1033,9 @@ fn transaction_call_error( AuthorityError::Cancelled(err) => v01::HostCreateTransactionError::Unknown { reason: err.to_string(), }, + AuthorityError::NotSupported { reason } => { + v01::HostCreateTransactionError::NotSupported { reason } + } AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { v01::HostCreateTransactionError::Unknown { reason } } @@ -1743,22 +1787,58 @@ impl Preimage for ProductRuntimeHost { let RemotePreimageLookupSubscribeRequest::V1(v01::RemotePreimageLookupSubscribeRequest { key, }) = request; + + // A cache hit is final: preimages are content-addressed and immutable. + // Emit the value once, then keep the subscription open (never complete, + // which would emit a product-visible interrupt frame) until the caller + // unsubscribes. + if let Ok(key_bytes) = <[u8; 32]>::try_from(key.as_slice()) + && let Some(value) = self.services.cached_preimage(&key_bytes) + { + let item = + RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { + value: Some(value), + }); + let stream = + futures::stream::once(async move { item }).chain(futures::stream::pending()); + return Subscription::new(Box::pin(stream)); + } + + // Otherwise delegate to the host content backend, verifying that any + // returned value hashes to the requested key so a compromised backend + // cannot feed products forged content. A mismatch is downgraded to a + // miss (the wire item has no error channel and the product still needs + // its initial current-value/miss emission). let stream = self .services .platform - .lookup_preimage(key) - .filter_map(|item| async move { - match item { - Ok(value) => Some(RemotePreimageLookupSubscribeItem::V1( + .lookup_preimage(key.clone()) + .filter_map(move |item| { + let key = key.clone(); + async move { + let value = match item { + Ok(value) => value, + Err(error) => { + warn!( + reason = %error.reason, + "preimage lookup platform stream failed" + ); + return None; + } + }; + let value = value.filter(|value| { + let matches = preimage_key(value)[..] == key[..]; + if !matches { + warn!( + "preimage lookup returned a value whose hash does not match the \ + requested key; downgrading to a miss" + ); + } + matches + }); + Some(RemotePreimageLookupSubscribeItem::V1( v01::RemotePreimageLookupSubscribeItem { value }, - )), - Err(error) => { - warn!( - reason = %error.reason, - "preimage lookup platform stream failed" - ); - None - } + )) } }); Subscription::new(Box::pin(stream)) @@ -1772,12 +1852,9 @@ impl Preimage for ProductRuntimeHost { ) -> Result> { let RemotePreimageSubmitRequest::V1(value) = request; let Some(session) = self.authority.current_session() else { - return Err(CallError::Domain(RemotePreimageSubmitError::V1( - v01::PreimageSubmitError::Unknown { - reason: "No active session".to_string(), - }, - ))); + return Err(preimage_submit_error("No active session".to_string())); }; + let bulletin = &self.services.bulletin; self.require_remote_permission( v01::RemotePermission::PreimageSubmit, RemotePreimageSubmitError::V1(v01::PreimageSubmitError::Unknown { @@ -1794,47 +1871,81 @@ impl Preimage for ProductRuntimeHost { }, )) .await - .map_err(|err| { - CallError::Domain(RemotePreimageSubmitError::V1( - v01::PreimageSubmitError::Unknown { reason: err.reason }, - )) - })?; + .map_err(|err| preimage_submit_error(err.reason))?; if !confirmed { - return Err(CallError::Domain(RemotePreimageSubmitError::V1( - v01::PreimageSubmitError::Unknown { - reason: "User rejected preimage submission".to_string(), - }, - ))); + return Err(preimage_submit_error( + "User rejected preimage submission".to_string(), + )); } - let cx = - remote_authority_context_with_default(cx, PREIMAGE_REMOTE_AUTHORITY_RESPONSE_TIMEOUT); + let submission_deadline = Instant::now() + PREIMAGE_SUBMIT_TIMEOUT; + let authority_cx = remote_authority_context_until( + cx, + PREIMAGE_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, + submission_deadline, + ); let allowance = remote_authority_call( - &cx, + &authority_cx, self.authority - .bulletin_allowance_key(&cx, &session, self.product_id()), + .bulletin_allowance_key(&authority_cx, &session, self.product_id()), ) .await - .map_err(|err| { - CallError::Domain(RemotePreimageSubmitError::V1( - v01::PreimageSubmitError::Unknown { - reason: err.reason(), - }, - )) - })?; - let signer = bulletin_allowance_signer_from_key(allowance).map_err(|reason| { - CallError::Domain(RemotePreimageSubmitError::V1( - v01::PreimageSubmitError::Unknown { reason }, - )) - })?; - self.services - .platform - .submit_preimage(value, signer) + .map_err(|err| preimage_submit_error(err.reason()))?; + + let key = match bulletin + .submit_preimage(cx, submission_deadline, &allowance, &value) .await - .map(RemotePreimageSubmitResponse::V1) - .map_err(|err| CallError::Domain(RemotePreimageSubmitError::V1(err))) + { + Ok(key) => key, + // A rejected allowance is the one case a refresh-and-retry can fix: + // evict the exhausted key, allocate a fresh (increased) allowance, + // and try exactly once more. + Err(BulletinSubmitError::AllowanceRejected { .. }) => { + let authority_cx = remote_authority_context_until( + cx, + PREIMAGE_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, + submission_deadline, + ); + let allowance = remote_authority_call( + &authority_cx, + self.authority.refresh_bulletin_allowance_key( + &authority_cx, + &session, + self.product_id(), + ), + ) + .await + .map_err(|err| preimage_submit_error(err.reason()))?; + bulletin + .submit_preimage(cx, submission_deadline, &allowance, &value) + .await + .map_err(|err| preimage_submit_error(err.to_string()))? + } + Err(err) => return Err(preimage_submit_error(err.to_string())), + }; + // Move the owned body into the lookup cache (no extra copy) so an + // immediate product lookup hits before the content backend has it. + self.prime_preimage_cache(&key, value); + Ok(RemotePreimageSubmitResponse::V1(key)) + } +} + +impl ProductRuntimeHost { + /// Cache a just-submitted preimage under its key for immediate lookups. + fn prime_preimage_cache(&self, key: &[u8], value: Vec) { + if let Ok(key_bytes) = <[u8; 32]>::try_from(key) { + debug_assert_eq!(key_bytes, preimage_key(&value)); + self.services.cache_preimage(key_bytes, value); + } } } +/// Build the product-facing `Unknown` wire error carrying `reason`. +fn preimage_submit_error(reason: String) -> CallError { + CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { reason }, + )) +} + // --------------------------------------------------------------------------- // Theme // --------------------------------------------------------------------------- @@ -1908,10 +2019,7 @@ impl Notifications for ProductRuntimeHost { #[cfg(test)] mod tests { use super::*; - use crate::host_logic::sso::messages::{ - OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, ResourceAllocationResponse, - SsoAllocatableResource, SsoAllocatedResource, SsoAllocationOutcome, v1, - }; + use crate::host_logic::sso::messages::{RemoteMessageData, v1}; use crate::test_support::*; use std::sync::Mutex; use std::sync::atomic::Ordering; @@ -1966,6 +2074,7 @@ mod tests { let services = RuntimeServices::new( platform.clone(), host_config.people_chain_genesis_hash, + host_config.bulletin_chain_genesis_hash, spawner.clone(), ); let pairing_host = PairingHost::new(services.clone(), host_config); @@ -2767,137 +2876,9 @@ mod tests { } } - fn bulletin_slot_account_key_fixture() -> Vec { - hex::decode( - "0eef5183411d40c32446bb1cbaabd70004a17af6012a577c735d054f04059208\ - 573dfc9b6ffeb1c786a16349e70f9836876a743c31c0a7a2a70727a852eec372", - ) - .unwrap() - } - - fn expected_bulletin_slot_account_public_key() -> Vec { - hex::decode("10c68432943c68a6e1be650818b5e08db79e57823de9f34df7ba36d404d91e1d").unwrap() - } - #[test] - fn preimage_submit_confirms_and_delegates_to_platform() { - let session = sso_session_info(); - let slot_account_key = bulletin_slot_account_key_fixture(); - let preimage_submit_allowance_public_keys = Arc::new(Mutex::new(Vec::new())); - let preimage_submit_signatures = Arc::new(Mutex::new(Vec::new())); - let platform = Arc::new(StubPlatform { - preimage_submit_allowance_public_keys: preimage_submit_allowance_public_keys.clone(), - preimage_submit_signatures: preimage_submit_signatures.clone(), - sso_response_script: Some(sso_success_response_script( - &session, - RemoteMessage { - message_id: "wallet-preimage-allowance".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse( - ResourceAllocationResponse { - responding_to: "preimage-submit".to_string(), - payload: Ok(vec![SsoAllocationOutcome::Allocated( - SsoAllocatedResource::BulletinAllowance { - slot_account_key: slot_account_key.clone(), - }, - )]), - }, - )), - }, - )), - ..Default::default() - }); - let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); - host.test_session_state().set_session(session.clone()); - let cx = CallContext::new(); - let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); - let response = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap(); - assert_eq!(response, RemotePreimageSubmitResponse::V1(vec![1, 2, 3])); - assert_eq!( - preimage_submit_allowance_public_keys - .lock() - .expect("preimage allowance public key list mutex poisoned") - .as_slice(), - &[expected_bulletin_slot_account_public_key()] - ); - assert_eq!( - preimage_submit_signatures - .lock() - .expect("preimage allowance signature list mutex poisoned")[0] - .len(), - 64 - ); - let message = submitted_remote_message(&platform, &session); - match message.data { - RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationRequest(request)) => { - assert_eq!( - request.resources, - vec![SsoAllocatableResource::BulletinAllowance] - ); - assert_eq!(request.on_existing, OnExistingAllowancePolicy::Ignore); - } - other => panic!("expected bulletin allowance request, got {other:?}"), - } - } - - #[test] - fn preimage_submit_uses_persisted_bulletin_allowance_key() { - let session = sso_session_info(); - let slot_account_key = bulletin_slot_account_key_fixture(); - let preimage_submit_allowance_public_keys = Arc::new(Mutex::new(Vec::new())); - let preimage_submit_signatures = Arc::new(Mutex::new(Vec::new())); - let platform = Arc::new(StubPlatform { - preimage_submit_allowance_public_keys: preimage_submit_allowance_public_keys.clone(), - preimage_submit_signatures: preimage_submit_signatures.clone(), - ..Default::default() - }); - futures::executor::block_on(allowances::write_allowance_key( - &*platform, - &session, - "unknown.dot", - allowances::AllowanceResource::Bulletin, - slot_account_key.clone(), - )) - .unwrap(); - let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); - host.test_session_state().set_session(session); - let cx = CallContext::new(); - let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); - let response = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap(); - assert_eq!(response, RemotePreimageSubmitResponse::V1(vec![1, 2, 3])); - assert_eq!( - preimage_submit_allowance_public_keys - .lock() - .expect("preimage allowance public key list mutex poisoned") - .as_slice(), - &[expected_bulletin_slot_account_public_key()] - ); - assert_eq!( - preimage_submit_signatures - .lock() - .expect("preimage allowance signature list mutex poisoned")[0] - .len(), - 64 - ); - assert!( - platform - .sent_rpc - .lock() - .expect("rpc list mutex poisoned") - .is_empty(), - "persisted allowance should not send an SSO resource-allocation request" - ); - } - - #[test] - fn preimage_submit_requires_session_before_backend_call() { - let preimage_submits = Arc::new(Mutex::new(Vec::new())); - let host = ProductRuntimeHost::new_compat( - Arc::new(StubPlatform { - preimage_submits: preimage_submits.clone(), - ..Default::default() - }), - test_spawner(), - ); + fn preimage_submit_requires_session_first() { + let host = ProductRuntimeHost::new_compat_with_bulletin(stub_platform(), test_spawner()); let cx = CallContext::new(); let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); @@ -2909,25 +2890,15 @@ mod tests { )) => assert_eq!(reason, "No active session"), other => panic!("expected preimage session error, got {other:?}"), } - assert!( - preimage_submits - .lock() - .expect("preimage submit list mutex poisoned") - .is_empty() - ); } #[test] fn preimage_submit_requires_remote_permission_before_backend_call() { - let preimage_submits = Arc::new(Mutex::new(Vec::new())); - let host = ProductRuntimeHost::new_compat( - Arc::new(StubPlatform { - remote_permission_denied: true, - preimage_submits: preimage_submits.clone(), - ..Default::default() - }), - test_spawner(), - ); + let platform = Arc::new(StubPlatform { + remote_permission_denied: true, + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat_with_bulletin(platform.clone(), test_spawner()); host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); @@ -2939,9 +2910,10 @@ mod tests { other => panic!("expected preimage permission denial, got {other:?}"), } assert!( - preimage_submits + platform + .sent_rpc .lock() - .expect("preimage submit list mutex poisoned") + .expect("rpc list mutex poisoned") .is_empty() ); } @@ -2976,19 +2948,76 @@ mod tests { } #[test] - fn preimage_lookup_subscribe_maps_platform_values() { + fn preimage_lookup_cache_hit_emits_once_and_stays_open() { + use crate::host_logic::bulletin::preimage_key; + use futures::FutureExt; + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let value = vec![4, 5, 6, 7]; + let key = preimage_key(&value); + host.services.cache_preimage(key, value.clone()); + let cx = CallContext::new(); let request = RemotePreimageLookupSubscribeRequest::V1(v01::RemotePreimageLookupSubscribeRequest { - key: vec![0; 32], + key: key.to_vec(), + }); + let mut subscription = futures::executor::block_on(host.lookup_subscribe(&cx, request)); + let item = futures::executor::block_on(subscription.next()).expect("preimage item"); + assert_eq!( + item, + RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { + value: Some(value) + }) + ); + // The subscription stays open (no completion/interrupt frame) after the + // single cache-hit emission. + assert!(subscription.next().now_or_never().is_none()); + } + + #[test] + fn preimage_lookup_forged_host_bytes_downgraded_to_miss() { + use crate::host_logic::bulletin::preimage_key; + + let value = vec![1, 1, 2, 3, 5, 8]; + let key = preimage_key(&value); + + // Host returns bytes that do not hash to the requested key. + let forged = Arc::new(StubPlatform { + preimage_lookup_value: Some(vec![9, 9, 9]), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(forged, test_spawner()); + let cx = CallContext::new(); + let request = + RemotePreimageLookupSubscribeRequest::V1(v01::RemotePreimageLookupSubscribeRequest { + key: key.to_vec(), + }); + let mut subscription = futures::executor::block_on(host.lookup_subscribe(&cx, request)); + let item = futures::executor::block_on(subscription.next()).expect("preimage item"); + assert_eq!( + item, + RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { + value: None + }) + ); + + // Correct bytes pass the integrity check through. + let genuine = Arc::new(StubPlatform { + preimage_lookup_value: Some(value.clone()), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(genuine, test_spawner()); + let request = + RemotePreimageLookupSubscribeRequest::V1(v01::RemotePreimageLookupSubscribeRequest { + key: key.to_vec(), }); let mut subscription = futures::executor::block_on(host.lookup_subscribe(&cx, request)); let item = futures::executor::block_on(subscription.next()).expect("preimage item"); assert_eq!( item, RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { - value: Some(vec![9, 8, 7]) + value: Some(value) }) ); } diff --git a/rust/crates/truapi-server/src/runtime/allowances.rs b/rust/crates/truapi-server/src/runtime/allowances.rs index aa5e2ad1..433f0919 100644 --- a/rust/crates/truapi-server/src/runtime/allowances.rs +++ b/rust/crates/truapi-server/src/runtime/allowances.rs @@ -87,6 +87,24 @@ pub(super) async fn write_allowance_key( .map_err(storage_error) } +pub(super) async fn remove_allowance_key( + storage: &(impl CoreStorage + ?Sized), + session: &SessionInfo, + product_id: &str, + resource: AllowanceResource, +) -> Result<(), AuthorityError> { + let mut entries = read_entries(storage, session).await?; + let before = entries.len(); + entries.retain(|entry| !(entry.product_id == product_id && entry.resource == resource)); + if entries.len() == before { + return Ok(()); + } + storage + .write_core_storage(storage_key(session)?, encode_entries(entries)) + .await + .map_err(storage_error) +} + pub(super) async fn clear_session_allowance_keys( storage: &(impl CoreStorage + ?Sized), session: &SessionInfo, diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index 7b5ce02d..60db64cb 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -17,13 +17,40 @@ use truapi::latest::{ }; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, CancellationReason}; -use truapi_platform::{BulletinAllowanceKeyError, ProductContext}; - -pub(crate) use truapi_platform::BulletinAllowanceKey; +use truapi_platform::ProductContext; use crate::host_logic::session::{SessionInfo, SessionState}; use crate::host_logic::statement_store::statement_public_key_from_secret; +/// Secret key allocated for Bulletin preimage submission. +/// +/// The core is the sole holder: the secret never crosses the host boundary. +/// Zeroized on drop, and its `Debug` redacts the material. +#[derive(Clone, zeroize::Zeroize, zeroize::ZeroizeOnDrop, derive_more::Debug)] +pub(crate) struct BulletinAllowanceKey { + #[debug("\"\"")] + secret: [u8; 64], +} + +impl BulletinAllowanceKey { + pub(crate) fn from_secret_bytes(secret: Vec) -> Result { + let secret: [u8; 64] = + secret + .try_into() + .map_err(|secret: Vec| AuthorityError::Unavailable { + reason: format!( + "bulletin allowance key must be 64 bytes, got {}", + secret.len() + ), + })?; + Ok(Self { secret }) + } + + pub(crate) fn as_secret_bytes(&self) -> &[u8; 64] { + &self.secret + } +} + /// Snapshot of an account-authority session selected by the authority. /// /// This is the neutral session projection product runtimes can use while @@ -53,6 +80,17 @@ impl AuthoritySession { validation_id, } } + + pub(crate) fn primary_username(&self) -> Option<&str> { + self.full_username + .as_deref() + .filter(|value| !value.is_empty()) + .or_else(|| { + self.lite_username + .as_deref() + .filter(|value| !value.is_empty()) + }) + } } /// Typed account-authority failure before it is mapped to an API-specific error. @@ -70,6 +108,10 @@ pub(crate) enum AuthorityError { /// The authority cannot service the request. #[display("{reason}")] Unavailable { reason: String }, + /// The authority cannot service this request shape (e.g. an unsupported + /// transaction-extension version). + #[display("{reason}")] + NotSupported { reason: String }, /// Catch-all authority failure. #[display("{reason}")] Unknown { reason: String }, @@ -81,14 +123,6 @@ impl AuthorityError { } } -impl From for AuthorityError { - fn from(err: BulletinAllowanceKeyError) -> Self { - AuthorityError::Unavailable { - reason: err.to_string(), - } - } -} - /// Cancellation cause for an account-authority call. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct AuthorityCancelError { @@ -224,6 +258,12 @@ pub(crate) trait ProductAuthority: Send + Sync { /// Disconnect the current account-authority session. async fn disconnect(&self); + /// Refresh identity fields for the current session if the authority can do + /// so without user interaction. + async fn refresh_session_identity(&self) -> Option { + self.current_session() + } + /// Sign a SCALE transaction payload for a product account. async fn sign_payload( &self, @@ -282,6 +322,18 @@ pub(crate) trait ProductAuthority: Send + Sync { product_id: String, ) -> Result; + /// Evict any cached Bulletin allowance key for the product and allocate a + /// fresh one, increasing the existing allowance. + /// + /// Called after a submission is rejected for an exhausted/missing + /// allowance, where reusing the cached key would loop forever. + async fn refresh_bulletin_allowance_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result; + /// Sign exact statement-store proof bytes with a product-derived account. async fn sign_statement_store_product_payload( &self, diff --git a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs new file mode 100644 index 00000000..0005c44d --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs @@ -0,0 +1,1353 @@ +//! 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 +//! dispatch outcome from the inclusion block's events. + +use std::sync::Mutex as StdMutex; +#[cfg(not(target_arch = "wasm32"))] +use std::time::{Duration, Instant}; +#[cfg(target_arch = "wasm32")] +use web_time::{Duration, Instant}; + +use crate::chain_runtime::{ChainRuntime, RuntimeFailure}; +use crate::host_logic::bulletin::{ + STORE_PALLET_NAME, allowance_signer, build_signed_store_transaction, preimage_key, +}; +use crate::host_logic::extrinsic::Sr25519Signer; +use crate::runtime::BulletinAllowanceKey; +use futures::{FutureExt, pin_mut}; +use subxt::client::{Block, Blocks, OnlineClientAtBlockImpl}; +use subxt::config::substrate::SubstrateConfig; +use subxt::error::{ + DispatchError, TransactionEventsError, TransactionProgressError, TransactionStatusError, +}; +use subxt::tx::{ + SubmittableTransaction, TransactionInBlock, TransactionInvalid, TransactionStatus, + TransactionUnknown, ValidationResult, +}; +use tracing::{instrument, warn}; +use truapi::CallContext; + +/// Retry once when a broadcast cannot be verified after a successful dry-run. +/// This covers the post-allocation propagation window where dry-run can +/// succeed against one node while the authoring path rejects or drops the +/// broadcast. +const SUBMIT_ATTEMPTS: usize = 2; +/// Number of newer best blocks to try before treating a dry-run allowance +/// rejection as real. Three blocks are about 18 seconds on Bulletin and leave +/// room in the end-to-end submit timeout for one refresh and retry. +const ALLOWANCE_DRY_RUN_PROPAGATION_BLOCKS: usize = 3; +/// Bound each wait for a newer best block while allowance state propagates. +#[cfg(not(test))] +const ALLOWANCE_DRY_RUN_BLOCK_WAIT_TIMEOUT: Duration = Duration::from_secs(20); +#[cfg(test)] +const ALLOWANCE_DRY_RUN_BLOCK_WAIT_TIMEOUT: Duration = Duration::from_millis(25); +/// Budget for the best-block stream to replay the current chain head. +const INITIALIZATION_TIMEOUT: Duration = Duration::from_secs(10); +/// Quiet window after which the newest replayed block is taken as the head. +/// The replay delivers the initialized finalized block first and any known +/// best block right after; active chains never need the full window. +#[cfg(not(test))] +const BEST_BLOCK_TIMEOUT: Duration = Duration::from_secs(2); +#[cfg(test)] +const BEST_BLOCK_TIMEOUT: Duration = Duration::from_millis(10); + +/// `TransactionStorage` module errors that mean the allowance account itself +/// was rejected (missing or exhausted authorization), i.e. the one condition +/// where refreshing the allowance key and retrying can help. +const ALLOWANCE_REJECTED_MODULE_ERRORS: &[&str] = + &["AuthorizationNotFound", "PermanentAllowanceExceeded"]; + +/// Where a submission currently is, for retry decisions and timeout reasons. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub(crate) enum SubmissionPhase { + #[display("connect")] + Connect, + #[display("build")] + Build, + #[display("dry-run")] + DryRun, + #[display("watch")] + Watch, + #[display("events")] + Events, +} + +/// The at-block client every submission step runs against. +type BulletinAtBlock = OnlineClientAtBlockImpl; + +/// A signed store transaction bound to its build block, ready for the +/// dry-run and submission steps. +type SignedStore = SubmittableTransaction; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DryRunStatus { + Valid, + AllowanceRejected, +} + +/// Typed submission failure driving the retry decision at the runtime call +/// site. Wire mapping stays `v01::PreimageSubmitError::Unknown { reason }`. +#[derive(Debug, derive_more::Display, derive_more::Error)] +pub(crate) enum BulletinSubmitError { + /// The host-owned physical connection could not provide a Subxt client. + #[display("bulletin chain unavailable: {_0}")] + Host(#[error(source)] RuntimeFailure), + /// Subxt failed while building, validating, submitting, or reading events. + #[display("subxt: {_0}")] + Subxt(#[error(source)] Box), + /// The allowance key could not be converted to its purpose-scoped signer. + #[display("invalid allowance key: {_0}")] + InvalidAllowanceKey(#[error(not(source))] String), + /// The runtime rejected the transaction for a non-allowance reason. + #[display("invalid: {_0:?}")] + InvalidTransaction(#[error(not(source))] TransactionInvalid), + /// The runtime could not determine transaction validity. + #[display("unknown transaction validity: {_0:?}")] + UnknownTransaction(#[error(not(source))] TransactionUnknown), + /// The allowance account was rejected; refresh + one retry may help. + #[display("allowance rejected: {phase}")] + AllowanceRejected { phase: AllowanceRejectionPhase }, + /// The submission budget elapsed; `phase` names the step reached. + #[display( + "timeout: {phase}{}", + if matches!(*phase, SubmissionPhase::Watch | SubmissionPhase::Events) { + ", inclusion unverified" + } else { + "" + } + )] + Timeout { phase: SubmissionPhase }, + /// Inclusion events contained no explicit dispatch outcome. + #[display("inclusion unverified: included block reported no dispatch outcome")] + DispatchOutcomeMissing, + /// The Subxt best-block stream ended without reporting an error. + #[display("Bulletin best-block stream ended")] + BestBlockStreamEnded, + /// The calling context was cancelled. + #[display("cancelled")] + Cancelled, +} + +/// Which stage rejected the allowance account. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub(crate) enum AllowanceRejectionPhase { + #[display("dry-run")] + DryRun, + #[display("dispatch")] + Dispatch, +} + +impl BulletinSubmitError { + fn is_retryable_submission_uncertain(&self, phase: SubmissionPhase) -> bool { + phase == SubmissionPhase::Watch && matches!(self, Self::Subxt(_)) + } +} + +/// Bulletin-chain submission service shared by all product runtimes. +pub(crate) struct BulletinRpc { + chain: ChainRuntime, + genesis_hash: [u8; 32], + /// Serializes submissions: no same-account nonce races between + /// concurrent submits. + submit_lock: futures::lock::Mutex<()>, + /// Last phase entered, for timeout reasons. + phase: StdMutex, +} + +impl BulletinRpc { + /// Build a bulletin submission service over the shared chain runtime. + pub(crate) fn new(chain: ChainRuntime, genesis_hash: [u8; 32]) -> Self { + Self { + chain, + genesis_hash, + submit_lock: futures::lock::Mutex::new(()), + phase: StdMutex::new(SubmissionPhase::Connect), + } + } + + /// Submit `value` as a Bulletin preimage signed by `allowance`, returning + /// the preimage key once the transaction is included and its dispatch + /// succeeded. + #[instrument(skip_all, fields(runtime.method = "bulletin_rpc.submit_preimage"))] + pub(crate) async fn submit_preimage( + &self, + cx: &CallContext, + deadline: Instant, + allowance: &BulletinAllowanceKey, + value: &[u8], + ) -> Result, BulletinSubmitError> { + // Serialize submissions, keeping the lock wait cancellable. + let lock = self.submit_lock.lock().fuse(); + let lock_cancelled = cx.cancel().cancelled().fuse(); + pin_mut!(lock, lock_cancelled); + let _guard = futures::select! { + guard = lock => guard, + _ = lock_cancelled => return Err(BulletinSubmitError::Cancelled), + }; + + // The Preimage::submit boundary owns one absolute chain deadline across + // the initial submission and an optional refreshed-allowance retry. + // The context is used only for cancellation. Dropping the flow on + // timeout/cancel drops its in-flight chain work. + let mut attempt = 0; + loop { + attempt += 1; + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return Err(BulletinSubmitError::Timeout { + phase: self.current_phase(), + }); + }; + let flow = self.submit_flow(allowance, value).fuse(); + let timeout = futures_timer::Delay::new(remaining).fuse(); + let cancelled = cx.cancel().cancelled().fuse(); + pin_mut!(flow, timeout, cancelled); + let result = futures::select! { + result = flow => result, + () = timeout => Err(BulletinSubmitError::Timeout { phase: self.current_phase() }), + _ = cancelled => Err(BulletinSubmitError::Cancelled), + }; + match result { + Err(err) + if attempt < SUBMIT_ATTEMPTS + && err.is_retryable_submission_uncertain(self.current_phase()) => + { + warn!( + attempt, + reason = %err, + "Bulletin preimage broadcast not included; retrying" + ); + } + result => return result, + } + } + } + + async fn submit_flow( + &self, + allowance: &BulletinAllowanceKey, + value: &[u8], + ) -> Result, BulletinSubmitError> { + let key = preimage_key(value); + + self.enter_phase(SubmissionPhase::Connect); + let client = self + .chain + .online_client(&self.genesis_hash) + .await + .map_err(BulletinSubmitError::Host)?; + let mut best_blocks = client + .stream_best_blocks() + .await + .map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; + let head = initial_best_block(&mut best_blocks).await?; + + self.enter_phase(SubmissionPhase::Build); + let signer = + allowance_signer(allowance).map_err(BulletinSubmitError::InvalidAllowanceKey)?; + let signed = self + .build_signed_and_dry_run(&mut best_blocks, head, &signer, value) + .await?; + drop(best_blocks); + + self.enter_phase(SubmissionPhase::Watch); + let in_block = watch_until_included(&signed).await?; + + self.enter_phase(SubmissionPhase::Events); + require_dispatch_success(&in_block).await?; + + Ok(key.to_vec()) + } + + /// Build, sign, and dry-run the extrinsic against the chosen best block. + /// Dry-run provides the typed signal used to distinguish stale allowances, + /// nonce races, and other runtime validity failures. + async fn build_signed_and_dry_run( + &self, + best_blocks: &mut Blocks, + head: Block, + signer: &Sr25519Signer, + value: &[u8], + ) -> Result { + let mut block = head; + let mut allowance_rejections = 0; + let mut allowance_rejection_started = None; + loop { + self.enter_phase(SubmissionPhase::Build); + let at_block = block + .at() + .await + .map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; + let signed = build_signed_store_transaction(&at_block, signer, value) + .await + .map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; + + self.enter_phase(SubmissionPhase::DryRun); + let validity = signed + .validate() + .await + .map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; + match Self::classify_dry_run_validity(validity)? { + DryRunStatus::Valid => return Ok(signed), + DryRunStatus::AllowanceRejected => { + let started = allowance_rejection_started.get_or_insert_with(Instant::now); + let elapsed = started.elapsed(); + if allowance_rejections >= ALLOWANCE_DRY_RUN_PROPAGATION_BLOCKS { + warn!( + rejections = allowance_rejections + 1, + elapsed_ms = elapsed.as_millis(), + stop = "block-limit", + "Bulletin allowance remained unavailable to dry-run" + ); + return Err(BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::DryRun, + }); + } + allowance_rejections += 1; + warn!( + attempt = allowance_rejections, + elapsed_ms = elapsed.as_millis(), + block_wait_timeout_ms = ALLOWANCE_DRY_RUN_BLOCK_WAIT_TIMEOUT.as_millis(), + "Bulletin allowance not visible to dry-run yet; rebuilding at next block" + ); + block = match next_best_block( + best_blocks, + ALLOWANCE_DRY_RUN_BLOCK_WAIT_TIMEOUT, + SubmissionPhase::DryRun, + ) + .await + { + Err(BulletinSubmitError::Timeout { .. }) => { + warn!( + rejections = allowance_rejections, + elapsed_ms = started.elapsed().as_millis(), + stop = "block-wait-timeout", + "Bulletin allowance remained unavailable to dry-run" + ); + return Err(BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::DryRun, + }); + } + result => result?, + }; + } + } + } + } + + fn classify_dry_run_validity( + validity: ValidationResult, + ) -> Result { + match validity { + ValidationResult::Valid(_) => Ok(DryRunStatus::Valid), + // Bulletin's fee/authorization transaction extensions report + // stale or missing allowance through these generic validity + // variants. The runtime does not publish stable Custom codes, so + // narrowing Custom would bypass the bounded refresh path. + ValidationResult::Invalid( + TransactionInvalid::Payment + | TransactionInvalid::Custom(_) + | TransactionInvalid::BadSigner, + ) => Ok(DryRunStatus::AllowanceRejected), + ValidationResult::Invalid(other) => Err(BulletinSubmitError::InvalidTransaction(other)), + ValidationResult::Unknown(other) => Err(BulletinSubmitError::UnknownTransaction(other)), + } + } + + fn enter_phase(&self, phase: SubmissionPhase) { + *self.phase.lock().expect("phase slot poisoned") = phase; + } + + fn current_phase(&self) -> SubmissionPhase { + *self.phase.lock().expect("phase slot poisoned") + } +} + +/// Take the stream's replayed view of the current chain head: the initialized +/// finalized block arrives first, followed by the newest known best block. +/// Returns the newest block seen during one bounded [`BEST_BLOCK_TIMEOUT`] +/// replay window; falling back to the finalized block is safe for cold starts. +/// The deadline is absolute so a continuously advancing best-block stream +/// cannot keep initialization alive forever. +async fn initial_best_block( + blocks: &mut Blocks, +) -> Result, BulletinSubmitError> { + let mut block = + next_best_block(blocks, INITIALIZATION_TIMEOUT, SubmissionPhase::Connect).await?; + let replay_deadline = futures_timer::Delay::new(BEST_BLOCK_TIMEOUT).fuse(); + pin_mut!(replay_deadline); + loop { + let next = blocks.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(Ok(newer)) => block = newer, + Some(Err(error)) => { + return Err(BulletinSubmitError::Subxt(Box::new(error.into()))); + } + None => return Err(BulletinSubmitError::BestBlockStreamEnded), + }, + () = replay_deadline => return Ok(block), + } + } +} + +async fn next_best_block( + blocks: &mut Blocks, + timeout: Duration, + phase: SubmissionPhase, +) -> Result, BulletinSubmitError> { + let timeout = futures_timer::Delay::new(timeout).fuse(); + let next = blocks.next().fuse(); + pin_mut!(timeout, next); + futures::select! { + block = next => match block { + Some(Ok(block)) => Ok(block), + Some(Err(error)) => Err(BulletinSubmitError::Subxt(Box::new(error.into()))), + None => Err(BulletinSubmitError::BestBlockStreamEnded), + }, + () = timeout => Err(BulletinSubmitError::Timeout { phase }), + } +} + +/// Submit the signed transaction and watch its progress until it lands in a +/// best or finalized block. +async fn watch_until_included( + signed: &SignedStore, +) -> Result, BulletinSubmitError> { + let mut progress = signed + .submit_and_watch() + .await + .map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; + while let Some(status) = progress.next().await { + let status = status.map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; + match status { + TransactionStatus::InBestBlock(block) | TransactionStatus::InFinalizedBlock(block) => { + return Ok(block); + } + TransactionStatus::Invalid { message } => { + return Err(BulletinSubmitError::Subxt(Box::new( + TransactionStatusError::Invalid(message).into(), + ))); + } + TransactionStatus::Dropped { message } => { + return Err(BulletinSubmitError::Subxt(Box::new( + TransactionStatusError::Dropped(message).into(), + ))); + } + TransactionStatus::Error { message } => { + return Err(BulletinSubmitError::Subxt(Box::new( + TransactionStatusError::Error(message).into(), + ))); + } + TransactionStatus::Validated + | TransactionStatus::Broadcasted + | TransactionStatus::NoLongerInBestBlock => {} + } + } + Err(BulletinSubmitError::Subxt(Box::new( + TransactionProgressError::UnexpectedEndOfTransactionStatusStream.into(), + ))) +} + +/// Require a successful dispatch outcome from the inclusion block's events. +/// Fail-closed: inclusion without an explicit `System.ExtrinsicSuccess` event +/// is reported as unverified, never as success. +async fn require_dispatch_success( + in_block: &TransactionInBlock, +) -> Result<(), BulletinSubmitError> { + match in_block.wait_for_success().await { + Ok(events) => { + for event in events.iter() { + let event = + event.map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; + if event.pallet_name() == "System" && event.event_name() == "ExtrinsicSuccess" { + return Ok(()); + } + } + Err(BulletinSubmitError::DispatchOutcomeMissing) + } + Err(TransactionEventsError::ExtrinsicFailed(error)) => Err(classify_dispatch_error(error)), + Err(other) => Err(BulletinSubmitError::Subxt(Box::new(other.into()))), + } +} + +/// Map a dispatch failure to the submission error, singling out the +/// allowance-rejection module errors that a key refresh can fix. +fn classify_dispatch_error(error: DispatchError) -> BulletinSubmitError { + let DispatchError::Module(module_error) = &error else { + return BulletinSubmitError::Subxt(Box::new(TransactionEventsError::from(error).into())); + }; + match module_error.details() { + Ok(details) => { + if details.pallet.name() == STORE_PALLET_NAME + && ALLOWANCE_REJECTED_MODULE_ERRORS.contains(&details.variant.name.as_str()) + { + BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::Dispatch, + } + } else { + BulletinSubmitError::Subxt(Box::new(TransactionEventsError::from(error).into())) + } + } + Err(_) => BulletinSubmitError::Subxt(Box::new(TransactionEventsError::from(error).into())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_logic::extrinsic::tests::bulletin_metadata; + use subxt::metadata::ArcMetadata; + + #[test] + fn dry_run_classifies_allowance_rejections_for_retry() { + for validity in [ + ValidationResult::Invalid(TransactionInvalid::Payment), + ValidationResult::Invalid(TransactionInvalid::Custom(7)), + ValidationResult::Invalid(TransactionInvalid::BadSigner), + ] { + assert_eq!( + BulletinRpc::classify_dry_run_validity(validity).unwrap(), + DryRunStatus::AllowanceRejected + ); + } + } + + #[test] + fn retries_only_uncertain_watch_phase_submissions() { + let unverified = BulletinSubmitError::Subxt(Box::new( + TransactionStatusError::Invalid("bad nonce".to_string()).into(), + )); + assert!(unverified.is_retryable_submission_uncertain(SubmissionPhase::Watch)); + assert!(!unverified.is_retryable_submission_uncertain(SubmissionPhase::Events)); + + assert!( + !BulletinSubmitError::Timeout { + phase: SubmissionPhase::Watch + } + .is_retryable_submission_uncertain(SubmissionPhase::Watch) + ); + assert!( + !BulletinSubmitError::Timeout { + phase: SubmissionPhase::DryRun + } + .is_retryable_submission_uncertain(SubmissionPhase::DryRun) + ); + } + + /// Decode a `DispatchError::Module` for the named error variant out of + /// the bulletin fixture metadata. + fn module_error(error_name: &str) -> DispatchError { + let metadata = ArcMetadata::from(bulletin_metadata()); + let pallet = metadata.pallet_by_name(STORE_PALLET_NAME).unwrap(); + let variant_index = (0..=u8::MAX) + .find(|index| { + pallet + .error_variant_by_index(*index) + .is_some_and(|variant| variant.name == error_name) + }) + .unwrap_or_else(|| panic!("fixture metadata lacks the {error_name} error")); + // `DispatchError::Module` is variant 3: (pallet index, 4 error bytes). + let bytes = [3, pallet.error_index(), variant_index, 0, 0, 0]; + DispatchError::decode_from(&bytes, metadata).unwrap() + } + + /// Any error variant that is not an allowance rejection. + fn non_allowance_error_name() -> String { + let metadata = ArcMetadata::from(bulletin_metadata()); + let pallet = metadata.pallet_by_name(STORE_PALLET_NAME).unwrap(); + (0..=u8::MAX) + .find_map(|index| { + let name = &pallet.error_variant_by_index(index)?.name; + (!ALLOWANCE_REJECTED_MODULE_ERRORS.contains(&name.as_str())).then(|| name.clone()) + }) + .expect("fixture metadata has a non-allowance error variant") + } + + #[test] + fn dispatch_errors_classify_allowance_rejections() { + assert!(matches!( + classify_dispatch_error(module_error("AuthorizationNotFound")), + BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::Dispatch + } + )); + + let other = non_allowance_error_name(); + let error = classify_dispatch_error(module_error(&other)); + let BulletinSubmitError::Subxt(error) = error else { + panic!("non-allowance dispatch error must remain a Subxt error"); + }; + assert!(matches!( + error.as_ref(), + subxt::Error::TransactionEventsError(TransactionEventsError::ExtrinsicFailed(_)) + )); + } + + #[test] + fn error_reason_strings_are_stable() { + assert_eq!( + BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::DryRun + } + .to_string(), + "allowance rejected: dry-run" + ); + assert_eq!( + BulletinSubmitError::InvalidTransaction(TransactionInvalid::Future).to_string(), + "invalid: Future" + ); + assert_eq!( + BulletinSubmitError::Timeout { + phase: SubmissionPhase::Watch + } + .to_string(), + "timeout: watch, inclusion unverified" + ); + assert_eq!( + BulletinSubmitError::Timeout { + phase: SubmissionPhase::Connect + } + .to_string(), + "timeout: connect" + ); + assert_eq!( + BulletinSubmitError::Subxt(Box::new( + TransactionStatusError::Invalid("bad nonce".to_string()).into(), + )) + .to_string(), + "subxt: The transaction is not valid: bad nonce" + ); + } + + #[cfg(not(target_arch = "wasm32"))] + mod orchestration { + use super::*; + use crate::chain_runtime::{RuntimeChainProvider, RuntimeFailure}; + use crate::host_logic::extrinsic::tests::BULLETIN_METADATA_BYTES; + use crate::subscription::thread_per_subscription_spawner; + use async_trait::async_trait; + use futures::StreamExt; + use futures::channel::mpsc; + use futures::stream::BoxStream; + use parity_scale_codec::{Compact, Encode}; + use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + use serde_json::{Value as JsonValue, json}; + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + use subxt::events::Phase; + use subxt::ext::scale_encode::{EncodeAsFields, Field}; + use subxt::ext::scale_value::{Primitive, Value as ScaleValue}; + use truapi_platform::JsonRpcConnection; + + const FOLLOW_ID: &str = "bulletin-follow"; + const BLOCK_HASH: &str = + "0x1111111111111111111111111111111111111111111111111111111111111111"; + const INCLUDED_HASH: &str = + "0x2222222222222222222222222222222222222222222222222222222222222222"; + + #[derive(Clone, Copy)] + enum TransactionOutcome { + Included, + Invalid, + Dropped, + } + + #[derive(Clone, Copy, PartialEq, Eq)] + enum ValidationOutcome { + Valid, + AllowanceRejected, + } + + struct ScriptedState { + transaction_outcomes: VecDeque, + validation_outcomes: VecDeque, + next_operation: usize, + next_transaction: usize, + next_best_block: usize, + current_best_hash: String, + advance_best_block_after_rejection: bool, + stall_headers: bool, + last_transaction: Option, + } + + struct BulletinScriptedProvider { + state: Arc>, + sent: Arc>>, + sender: Arc>>>, + receiver: Mutex>>, + events: String, + } + + impl BulletinScriptedProvider { + fn new(outcomes: impl IntoIterator) -> Self { + Self::with_options(outcomes, false) + } + + fn with_options( + outcomes: impl IntoIterator, + stall_headers: bool, + ) -> Self { + let (sender, receiver) = mpsc::unbounded(); + Self { + state: Arc::new(Mutex::new(ScriptedState { + transaction_outcomes: outcomes.into_iter().collect(), + validation_outcomes: VecDeque::new(), + next_operation: 0, + next_transaction: 0, + next_best_block: 0, + current_best_hash: BLOCK_HASH.to_string(), + advance_best_block_after_rejection: false, + stall_headers, + last_transaction: None, + })), + sent: Arc::new(Mutex::new(Vec::new())), + sender: Arc::new(Mutex::new(Some(sender))), + receiver: Mutex::new(Some(receiver)), + events: format!("0x{}", hex::encode(success_events())), + } + } + + fn with_validation_outcomes( + self, + outcomes: impl IntoIterator, + advance_best_block_after_rejection: bool, + ) -> Self { + { + let mut state = self.state.lock().unwrap(); + state.validation_outcomes = outcomes.into_iter().collect(); + state.advance_best_block_after_rejection = advance_best_block_after_rejection; + } + self + } + + fn method_count(&self, method: &str) -> usize { + self.sent + .lock() + .unwrap() + .iter() + .filter(|request| { + serde_json::from_str::(request) + .ok() + .and_then(|value| { + value + .get("method") + .and_then(JsonValue::as_str) + .map(ToOwned::to_owned) + }) + .as_deref() + == Some(method) + }) + .count() + } + + fn runtime_call_count(&self, runtime_method: &str) -> usize { + self.sent + .lock() + .unwrap() + .iter() + .filter(|request| { + serde_json::from_str::(request) + .ok() + .and_then(|value| { + (value.get("method").and_then(JsonValue::as_str) + == Some("chainHead_v1_call")) + .then(|| { + value + .get("params") + .and_then(|params| params.get(2)) + .and_then(JsonValue::as_str) + .map(ToOwned::to_owned) + }) + .flatten() + }) + .as_deref() + == Some(runtime_method) + }) + .count() + } + } + + struct BulletinScriptedConnection { + state: Arc>, + sent: Arc>>, + sender: Arc>>>, + receiver: Mutex>>, + events: String, + } + + impl JsonRpcConnection for BulletinScriptedConnection { + fn send(&self, request: String) { + self.sent.lock().unwrap().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() { + for frame in frames { + sender.unbounded_send(frame).unwrap(); + } + } + } + + fn responses(&self) -> BoxStream<'static, String> { + self.receiver + .lock() + .unwrap() + .take() + .expect("responses called once") + .boxed() + } + + fn close(&self) { + self.sender.lock().unwrap().take(); + } + } + + #[async_trait] + impl RuntimeChainProvider for BulletinScriptedProvider { + async fn connect( + &self, + _genesis_hash: Vec, + ) -> Result, RuntimeFailure> { + Ok(Arc::new(BulletinScriptedConnection { + state: self.state.clone(), + sent: self.sent.clone(), + sender: self.sender.clone(), + receiver: Mutex::new(self.receiver.lock().unwrap().take()), + events: self.events.clone(), + })) + } + } + + fn scripted_frames(request: &str, events: &str, state: &mut ScriptedState) -> Vec { + let request: JsonValue = serde_json::from_str(request).unwrap(); + let id = request.get("id").cloned().unwrap_or(JsonValue::Null); + let method = request["method"].as_str().unwrap(); + let response = |result: JsonValue| { + json!({"jsonrpc": "2.0", "id": id, "result": result}).to_string() + }; + let follow_event = |result: JsonValue| { + json!({ + "jsonrpc": "2.0", + "method": "chainHead_v1_followEvent", + "params": {"subscription": FOLLOW_ID, "result": result} + }) + .to_string() + }; + + match method { + "chainHead_v1_follow" => vec![ + response(json!(FOLLOW_ID)), + follow_event(json!({ + "event": "initialized", + "finalizedBlockHashes": [BLOCK_HASH], + "finalizedBlockRuntime": null + })), + ], + "chainHead_v1_header" if state.stall_headers => Vec::new(), + "chainHead_v1_header" => vec![response(json!(encoded_header()))], + "chainHead_v1_call" => { + state.next_operation += 1; + let operation_id = format!("call-{}", state.next_operation); + let runtime_method = request["params"][2].as_str().unwrap(); + let validation_outcome = + if runtime_method == "TaggedTransactionQueue_validate_transaction" { + state + .validation_outcomes + .pop_front() + .unwrap_or(ValidationOutcome::Valid) + } else { + ValidationOutcome::Valid + }; + let output = runtime_call_output(runtime_method, validation_outcome); + let mut frames = vec![ + response(json!({"result": "started", "operationId": operation_id})), + follow_event(json!({ + "event": "operationCallDone", + "operationId": operation_id, + "output": format!("0x{}", hex::encode(output)) + })), + ]; + if validation_outcome == ValidationOutcome::AllowanceRejected + && state.advance_best_block_after_rejection + { + state.next_best_block += 1; + let block_hash = scripted_block_hash(state.next_best_block); + let parent_block_hash = + std::mem::replace(&mut state.current_best_hash, block_hash.clone()); + frames.push(follow_event(json!({ + "event": "newBlock", + "blockHash": block_hash, + "parentBlockHash": parent_block_hash, + "newRuntime": null + }))); + frames.push(follow_event(json!({ + "event": "bestBlockChanged", + "bestBlockHash": block_hash + }))); + } + frames + } + "transactionWatch_v1_submitAndWatch" => { + state.next_transaction += 1; + state.last_transaction = request["params"][0].as_str().map(ToOwned::to_owned); + let subscription_id = format!("tx-{}", state.next_transaction); + let outcome = state + .transaction_outcomes + .pop_front() + .expect("scripted transaction outcome"); + let status = match outcome { + TransactionOutcome::Included => json!({ + "event": "bestChainBlockIncluded", + "block": {"hash": INCLUDED_HASH, "index": "0"} + }), + TransactionOutcome::Invalid => { + json!({"event": "invalid", "error": "scripted invalid"}) + } + TransactionOutcome::Dropped => { + json!({"event": "dropped", "error": "scripted dropped"}) + } + }; + vec![ + response(json!(subscription_id)), + json!({ + "jsonrpc": "2.0", + "method": "transactionWatch_v1_watchEvent", + "params": {"subscription": subscription_id, "result": status} + }) + .to_string(), + ] + } + "chainHead_v1_body" => { + state.next_operation += 1; + let operation_id = format!("body-{}", state.next_operation); + let transaction = state + .last_transaction + .clone() + .expect("submitted transaction available"); + vec![ + response(json!({"result": "started", "operationId": operation_id})), + follow_event(json!({ + "event": "operationBodyDone", + "operationId": operation_id, + "value": [transaction] + })), + ] + } + "chainHead_v1_storage" => { + state.next_operation += 1; + let operation_id = format!("storage-{}", state.next_operation); + let key = request["params"][2][0]["key"].clone(); + vec![ + response(json!({"result": "started", "operationId": operation_id})), + follow_event(json!({ + "event": "operationStorageItems", + "operationId": operation_id, + "items": [{"key": key, "value": events}] + })), + follow_event(json!({ + "event": "operationStorageDone", + "operationId": operation_id + })), + ] + } + "chainHead_v1_unpin" | "chainHead_v1_unfollow" | "transactionWatch_v1_unwatch" => { + vec![response(JsonValue::Null)] + } + other => { + vec![json!({ + "jsonrpc": "2.0", + "id": id, + "error": {"code": -32601, "message": format!("unexpected method {other}")} + }) + .to_string()] + } + } + } + + fn encoded_header() -> String { + let mut bytes = Vec::new(); + [0u8; 32].encode_to(&mut bytes); + Compact(1u32).encode_to(&mut bytes); + [0u8; 32].encode_to(&mut bytes); + [0u8; 32].encode_to(&mut bytes); + Vec::::new().encode_to(&mut bytes); + format!("0x{}", hex::encode(bytes)) + } + + fn scripted_block_hash(index: usize) -> String { + format!("0x{index:064x}") + } + + fn runtime_call_output(method: &str, validation_outcome: ValidationOutcome) -> Vec { + match method { + "Core_version" => ( + "bulletin", + "bulletin", + 1u32, + 1u32, + 1u32, + Vec::<([u8; 8], u32)>::new(), + 1u32, + ) + .encode(), + "Metadata_metadata_versions" => vec![14u32].encode(), + "Metadata_metadata_at_version" => { + let mut output = vec![1]; + Compact(u32::try_from(BULLETIN_METADATA_BYTES.len()).unwrap()) + .encode_to(&mut output); + output.extend_from_slice(BULLETIN_METADATA_BYTES); + output + } + "AccountNonceApi_account_nonce" => 0u32.encode(), + "TaggedTransactionQueue_validate_transaction" + if validation_outcome == ValidationOutcome::Valid => + { + let mut output = vec![0]; + 0u64.encode_to(&mut output); + Vec::>::new().encode_to(&mut output); + Vec::>::new().encode_to(&mut output); + 64u64.encode_to(&mut output); + true.encode_to(&mut output); + output + } + "TaggedTransactionQueue_validate_transaction" => { + // Result::Err(TransactionValidityError::Invalid( + // TransactionInvalid::Payment)). + vec![1, 0, 1] + } + other => panic!("unexpected runtime call {other}"), + } + } + + fn success_events() -> Vec { + let metadata = ArcMetadata::from(bulletin_metadata()); + let system = metadata.pallet_by_name("System").unwrap(); + let event = system + .event_variants() + .unwrap() + .iter() + .find(|event| event.name == "ExtrinsicSuccess") + .unwrap(); + let values = ScaleValue::unnamed_composite( + event + .fields + .iter() + .map(|field| default_value(metadata.types(), field.ty.id)), + ); + let mut fields = event + .fields + .iter() + .map(|field| Field::new(field.ty.id, field.name.as_deref())); + + let mut bytes = Vec::new(); + Compact(1u32).encode_to(&mut bytes); + Phase::ApplyExtrinsic(0).encode_to(&mut bytes); + system.event_index().encode_to(&mut bytes); + event.index.encode_to(&mut bytes); + values + .encode_as_fields_to(&mut fields, metadata.types(), &mut bytes) + .unwrap(); + Vec::<[u8; 32]>::new().encode_to(&mut bytes); + bytes + } + + fn default_value(types: &PortableRegistry, type_id: u32) -> ScaleValue { + let ty = types.resolve(type_id).expect("metadata type exists"); + match &ty.type_def { + TypeDef::Composite(composite) => ScaleValue::unnamed_composite( + composite + .fields + .iter() + .map(|field| default_value(types, field.ty.id)), + ), + TypeDef::Variant(variants) => { + let variant = variants.variants.first().expect("variant exists"); + ScaleValue::unnamed_variant( + variant.name.clone(), + variant + .fields + .iter() + .map(|field| default_value(types, field.ty.id)), + ) + } + TypeDef::Sequence(_) => ScaleValue::unnamed_composite([]), + TypeDef::Array(array) => ScaleValue::unnamed_composite( + (0..array.len).map(|_| default_value(types, array.type_param.id)), + ), + TypeDef::Tuple(tuple) => ScaleValue::unnamed_composite( + tuple + .fields + .iter() + .map(|field| default_value(types, field.id)), + ), + TypeDef::Primitive(primitive) => match primitive { + TypeDefPrimitive::Bool => ScaleValue::bool(false), + TypeDefPrimitive::Char => ScaleValue::char('\0'), + TypeDefPrimitive::Str => ScaleValue::string(""), + TypeDefPrimitive::U8 + | TypeDefPrimitive::U16 + | TypeDefPrimitive::U32 + | TypeDefPrimitive::U64 + | TypeDefPrimitive::U128 => ScaleValue::u128(0), + TypeDefPrimitive::U256 => ScaleValue::primitive(Primitive::U256([0; 32])), + TypeDefPrimitive::I8 + | TypeDefPrimitive::I16 + | TypeDefPrimitive::I32 + | TypeDefPrimitive::I64 + | TypeDefPrimitive::I128 => ScaleValue::i128(0), + TypeDefPrimitive::I256 => ScaleValue::primitive(Primitive::I256([0; 32])), + }, + TypeDef::Compact(_) => ScaleValue::u128(0), + TypeDef::BitSequence(_) => { + ScaleValue::bit_sequence(subxt::ext::scale_bits::Bits::new()) + } + } + } + + fn allowance_fixture() -> BulletinAllowanceKey { + BulletinAllowanceKey::from_secret_bytes( + hex::decode( + "0eef5183411d40c32446bb1cbaabd70004a17af6012a577c735d054f04059208\ + 573dfc9b6ffeb1c786a16349e70f9836876a743c31c0a7a2a70727a852eec372", + ) + .unwrap(), + ) + .unwrap() + } + + fn rpc(provider: Arc) -> BulletinRpc { + BulletinRpc::new( + ChainRuntime::new(provider, thread_per_subscription_spawner()), + [0x42; 32], + ) + } + + #[test] + fn submit_preimage_drives_subxt_happy_path() { + let provider = Arc::new(BulletinScriptedProvider::new([ + TransactionOutcome::Included, + ])); + let value = b"scripted bulletin happy path"; + let result = futures::executor::block_on(rpc(provider.clone()).submit_preimage( + &CallContext::new(), + Instant::now() + Duration::from_secs(2), + &allowance_fixture(), + value, + )) + .unwrap(); + + assert_eq!(result, preimage_key(value)); + assert_eq!( + provider.runtime_call_count("TaggedTransactionQueue_validate_transaction"), + 1 + ); + assert_eq!( + provider.method_count("transactionWatch_v1_submitAndWatch"), + 1 + ); + assert_eq!(provider.method_count("chainHead_v1_storage"), 1); + } + + #[test] + fn submit_preimage_retries_uncertain_broadcast_once() { + let provider = Arc::new(BulletinScriptedProvider::new([ + TransactionOutcome::Dropped, + TransactionOutcome::Included, + ])); + let value = b"scripted bulletin retry"; + let result = futures::executor::block_on(rpc(provider.clone()).submit_preimage( + &CallContext::new(), + Instant::now() + Duration::from_secs(2), + &allowance_fixture(), + value, + )) + .unwrap(); + + assert_eq!(result, preimage_key(value)); + assert_eq!( + provider.method_count("transactionWatch_v1_submitAndWatch"), + 2 + ); + } + + #[test] + fn submit_preimage_does_not_infer_allowance_rejection_from_authoring_invalidity() { + let provider = Arc::new(BulletinScriptedProvider::new([ + TransactionOutcome::Invalid, + TransactionOutcome::Invalid, + ])); + let error = futures::executor::block_on(rpc(provider.clone()).submit_preimage( + &CallContext::new(), + Instant::now() + Duration::from_secs(2), + &allowance_fixture(), + b"scripted stale allowance", + )) + .unwrap_err(); + + let BulletinSubmitError::Subxt(error) = error else { + panic!("invalid authoring status must remain a Subxt error"); + }; + assert!(matches!( + error.as_ref(), + subxt::Error::TransactionStatusError(TransactionStatusError::Invalid(message)) + if message == "scripted invalid" + )); + assert_eq!( + provider.method_count("transactionWatch_v1_submitAndWatch"), + 2 + ); + assert_eq!( + provider.runtime_call_count("TaggedTransactionQueue_validate_transaction"), + 2 + ); + } + + #[test] + fn submit_preimage_classifies_allowance_only_from_the_follow_up_dry_run() { + let provider = Arc::new( + BulletinScriptedProvider::new([TransactionOutcome::Invalid]) + .with_validation_outcomes( + [ + ValidationOutcome::Valid, + ValidationOutcome::AllowanceRejected, + ], + false, + ), + ); + let error = futures::executor::block_on(rpc(provider.clone()).submit_preimage( + &CallContext::new(), + Instant::now() + Duration::from_secs(2), + &allowance_fixture(), + b"scripted stale allowance", + )) + .unwrap_err(); + + assert!(matches!( + error, + BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::DryRun, + } + )); + assert_eq!( + provider.method_count("transactionWatch_v1_submitAndWatch"), + 1 + ); + assert_eq!( + provider.runtime_call_count("TaggedTransactionQueue_validate_transaction"), + 2 + ); + } + + #[test] + fn submit_preimage_budget_reports_pre_broadcast_phase() { + let provider = Arc::new(BulletinScriptedProvider::with_options([], true)); + let error = futures::executor::block_on(rpc(provider).submit_preimage( + &CallContext::new(), + Instant::now() + Duration::from_millis(25), + &allowance_fixture(), + b"timeout", + )) + .unwrap_err(); + + assert!(matches!( + &error, + BulletinSubmitError::Timeout { + phase: SubmissionPhase::Connect + } + )); + assert_eq!(error.to_string(), "timeout: connect"); + } + + #[test] + fn dry_run_rebuilds_at_a_new_best_block_then_submits() { + let provider = Arc::new( + BulletinScriptedProvider::new([TransactionOutcome::Included]) + .with_validation_outcomes( + [ + ValidationOutcome::AllowanceRejected, + ValidationOutcome::Valid, + ], + true, + ), + ); + let value = b"scripted allowance propagation"; + let result = futures::executor::block_on(rpc(provider.clone()).submit_preimage( + &CallContext::new(), + Instant::now() + Duration::from_secs(2), + &allowance_fixture(), + value, + )) + .unwrap(); + + assert_eq!(result, preimage_key(value)); + assert_eq!( + provider.runtime_call_count("TaggedTransactionQueue_validate_transaction"), + 2 + ); + assert_eq!( + provider.method_count("transactionWatch_v1_submitAndWatch"), + 1 + ); + } + + #[test] + fn dry_run_propagation_block_wait_timeout_remains_an_allowance_rejection() { + let provider = Arc::new( + BulletinScriptedProvider::new([]) + .with_validation_outcomes([ValidationOutcome::AllowanceRejected], false), + ); + let error = futures::executor::block_on(rpc(provider.clone()).submit_preimage( + &CallContext::new(), + Instant::now() + Duration::from_secs(2), + &allowance_fixture(), + b"scripted propagation block wait timeout", + )) + .unwrap_err(); + + assert!(matches!( + error, + BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::DryRun, + } + )); + assert_eq!( + provider.method_count("transactionWatch_v1_submitAndWatch"), + 0 + ); + } + + #[test] + fn dry_run_propagation_stops_after_the_block_limit() { + let provider = Arc::new(BulletinScriptedProvider::new([]).with_validation_outcomes( + [ValidationOutcome::AllowanceRejected; ALLOWANCE_DRY_RUN_PROPAGATION_BLOCKS + 1], + true, + )); + let error = futures::executor::block_on(rpc(provider.clone()).submit_preimage( + &CallContext::new(), + Instant::now() + Duration::from_secs(2), + &allowance_fixture(), + b"scripted propagation block limit", + )) + .unwrap_err(); + + assert!(matches!( + error, + BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::DryRun, + } + )); + assert_eq!( + provider.runtime_call_count("TaggedTransactionQueue_validate_transaction"), + ALLOWANCE_DRY_RUN_PROPAGATION_BLOCKS + 1 + ); + assert_eq!( + provider.method_count("transactionWatch_v1_submitAndWatch"), + 0 + ); + } + } +} diff --git a/rust/crates/truapi-server/src/runtime/identity.rs b/rust/crates/truapi-server/src/runtime/identity.rs index 0c36a897..36e7d9f4 100644 --- a/rust/crates/truapi-server/src/runtime/identity.rs +++ b/rust/crates/truapi-server/src/runtime/identity.rs @@ -1,24 +1,42 @@ //! People-chain identity lookup used to resolve usernames for a paired session. +use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(not(target_arch = "wasm32"))] use std::time::Duration; #[cfg(target_arch = "wasm32")] use web_time::Duration; use crate::chain_runtime::{ - ChainRuntime, wait_for_chain_head_best_hash, wait_for_chain_head_storage_value, + ChainHeadStorageValue, ChainHeadStorageValueLookup, ChainRuntime, + wait_for_chain_head_best_hash, wait_for_chain_head_storage_value, }; use crate::host_logic::identity::{ PeopleIdentity, decode_people_identity, resources_consumers_storage_key, }; use crate::host_logic::session::SessionInfo; +use futures::{FutureExt, pin_mut}; use tracing::{debug, instrument, warn}; -use truapi::latest::{ +use truapi::v01::{ OperationStartedResult, RemoteChainHeadFollowRequest, RemoteChainHeadStorageRequest, StorageQueryItem, StorageQueryType, }; +/// Budget for the whole People-chain lookup (best block + storage read). +const LOOKUP_TIMEOUT: Duration = Duration::from_secs(10); +const LOOKUP_RETRY_INTERVAL: Duration = Duration::from_secs(2); +const BEST_BLOCK_TIMEOUT: Duration = Duration::from_secs(2); + +/// Monotonic salt for local identity lookup follow ids, avoiding collisions +/// between concurrent People-chain identity lookups. +static IDENTITY_LOOKUP_COUNTER: AtomicU64 = AtomicU64::new(1); + +enum ConsumerRecordLookup { + Found(Vec), + Missing, + Inaccessible, +} + /// Fill in missing usernames by querying the people chain; returns the /// session unchanged when it already carries a username or no people chain /// is configured. @@ -101,18 +119,44 @@ async fn lookup_people_identity( people_chain_genesis_hash: [u8; 32], account_id: [u8; 32], ) -> Result, String> { - let genesis_hash = people_chain_genesis_hash.to_vec(); - let key = resources_consumers_storage_key(&account_id); - let lookup_id = { - use std::sync::atomic::{AtomicU64, Ordering}; + let timeout = futures_timer::Delay::new(LOOKUP_TIMEOUT).fuse(); + pin_mut!(timeout); + loop { + let lookup = fetch_consumer_record(chain, people_chain_genesis_hash, account_id).fuse(); + pin_mut!(lookup); + let lookup = futures::select! { + value = lookup => value?, + () = timeout => return Err("People-chain identity lookup timed out".to_string()), + }; + match lookup { + ConsumerRecordLookup::Found(value) => { + return decode_people_identity(&value).map(Some); + } + ConsumerRecordLookup::Missing => return Ok(None), + ConsumerRecordLookup::Inaccessible => {} + } - /// Monotonic salt for local identity lookup follow ids, avoiding - /// collisions between concurrent People-chain identity lookups. - static IDENTITY_LOOKUP_COUNTER: AtomicU64 = AtomicU64::new(1); + let retry = futures_timer::Delay::new(LOOKUP_RETRY_INTERVAL).fuse(); + pin_mut!(retry); + futures::select! { + () = retry => {}, + () = timeout => return Err("People-chain identity lookup timed out".to_string()), + } + } +} - IDENTITY_LOOKUP_COUNTER.fetch_add(1, Ordering::Relaxed) - }; - let follow_id = format!("truapi:identity:{}:{}", lookup_id, hex::encode(account_id),); +/// Read the raw `Resources.Consumers` record for `account_id` at a fresh +/// People-chain head. The key is built locally, so the read never needs the +/// People-chain metadata. +async fn fetch_consumer_record( + chain: &ChainRuntime, + people_chain_genesis_hash: [u8; 32], + account_id: [u8; 32], +) -> Result { + let genesis_hash = people_chain_genesis_hash.to_vec(); + let key = resources_consumers_storage_key(&account_id); + let lookup_id = IDENTITY_LOOKUP_COUNTER.fetch_add(1, Ordering::Relaxed); + let follow_id = format!("truapi:identity:{lookup_id}:{}", hex::encode(account_id)); let mut follow = chain.remote_chain_head_follow( follow_id.clone(), RemoteChainHeadFollowRequest { @@ -124,14 +168,14 @@ async fn lookup_people_identity( let hash = wait_for_chain_head_best_hash( &mut follow, "People-chain", - Duration::from_secs(10), - Duration::from_secs(2), + LOOKUP_TIMEOUT, + BEST_BLOCK_TIMEOUT, ) .await?; let response = chain .remote_chain_head_storage(RemoteChainHeadStorageRequest { - genesis_hash, - follow_subscription_id: follow_id, + genesis_hash: genesis_hash.clone(), + follow_subscription_id: follow_id.clone(), hash, items: vec![StorageQueryItem { key: key.clone(), @@ -141,23 +185,28 @@ async fn lookup_people_identity( }) .await .map_err(|failure| failure.reason())?; - let operation_id = match response.operation { OperationStartedResult::Started { operation_id } => operation_id, OperationStartedResult::LimitReached => { return Err("People-chain storage lookup limit reached".to_string()); } }; - let Some(value) = wait_for_chain_head_storage_value( + let value = wait_for_chain_head_storage_value( &mut follow, - &operation_id, - &key, - "People-chain", - Duration::from_secs(10), + ChainHeadStorageValueLookup { + chain, + genesis_hash: &genesis_hash, + follow_subscription_id: &follow_id, + operation_id: &operation_id, + key: &key, + label: "People-chain", + timeout: LOOKUP_TIMEOUT, + }, ) - .await? - else { - return Ok(None); - }; - decode_people_identity(&value).map(Some) + .await?; + Ok(match value { + ChainHeadStorageValue::Found(value) => ConsumerRecordLookup::Found(value), + ChainHeadStorageValue::Missing => ConsumerRecordLookup::Missing, + ChainHeadStorageValue::Inaccessible => ConsumerRecordLookup::Inaccessible, + }) } diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index 4969a42c..050c394f 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -32,7 +32,7 @@ use crate::host_logic::session_store::SessionStoreChangeNotifier; use crate::subscription::Spawner; use futures::StreamExt; -use tracing::instrument; +use tracing::{instrument, warn}; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, v01}; use truapi_platform::{CoreStorageKey, PairingHostConfig, Platform, ProductContext}; @@ -461,6 +461,70 @@ impl PairingHost { require_current_session(&self.session_state, session) } + async fn refresh_current_session_identity(&self) -> Option { + let current = self.session_state.current()?; + if current.has_username() || self.host_config.people_chain_genesis_hash == [0; 32] { + return Some(authority_session(¤t)); + } + + let resolved = resolve_session_identity_with_chain( + &self.chain, + self.host_config.people_chain_genesis_hash, + current.clone(), + ) + .await; + if !resolved.has_username() || resolved == current { + return self.current_session(); + } + + if !self + .session_state + .replace_session_if_current(¤t, resolved.clone()) + { + return self.current_session(); + } + self.auth_state + .connected(&connected_session_ui_info(&resolved)); + + if let Err(err) = self + .platform + .write_core_storage( + CoreStorageKey::AuthSession, + encode_persisted_session(&resolved), + ) + .await + { + warn!(reason = %err.reason, "refreshed session identity persist failed"); + } + + match self.session_state.current() { + Some(live) if live != resolved => { + if let Err(err) = self + .platform + .write_core_storage( + CoreStorageKey::AuthSession, + encode_persisted_session(&live), + ) + .await + { + warn!(reason = %err.reason, "live session identity persist repair failed"); + } + Some(authority_session(&live)) + } + None => { + if let Err(err) = self + .platform + .clear_core_storage(CoreStorageKey::AuthSession) + .await + { + warn!(reason = %err.reason, "cleared session identity persist repair failed"); + } + None + } + _ => Some(authority_session(&resolved)), + } + } + pub(super) async fn cache_statement_store_allowance_key( &self, session: &SessionInfo, @@ -589,6 +653,26 @@ impl PairingHost { Ok(Some(allowance)) } + /// Drop the cached and persisted Bulletin allowance key for one product. + pub(super) async fn evict_bulletin_allowance_key( + &self, + session: &SessionInfo, + product_id: &str, + ) -> Result<(), AuthorityError> { + let cache_key = AllowanceCacheKey::new(session, product_id, AllowanceResource::Bulletin)?; + self.bulletin_allowances + .lock() + .expect("bulletin allowance cache mutex poisoned") + .remove(&cache_key); + allowances::remove_allowance_key( + &*self.platform, + session, + product_id, + AllowanceResource::Bulletin, + ) + .await + } + pub(super) fn clear_statement_store_allowance_keys(&self, session: Option<&SessionInfo>) { let mut allowances = self .statement_store_allowances @@ -697,6 +781,17 @@ impl PairingHost { .await } + async fn refresh_bulletin_allowance_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_refresh_bulletin_allowance_key(cx, &session, product_id) + .await + } + async fn sign_statement_store_product_payload( &self, _cx: &CallContext, @@ -769,6 +864,10 @@ impl ProductAuthority for PairingHost { PairingHost::disconnect(self).await; } + async fn refresh_session_identity(&self) -> Option { + self.refresh_current_session_identity().await + } + async fn sign_payload( &self, cx: &CallContext, @@ -835,6 +934,15 @@ impl ProductAuthority for PairingHost { PairingHost::bulletin_allowance_key(self, cx, session, product_id).await } + async fn refresh_bulletin_allowance_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result { + PairingHost::refresh_bulletin_allowance_key(self, cx, session, product_id).await + } + async fn sign_statement_store_product_payload( &self, cx: &CallContext, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index 77668cba..cf5a04e4 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -516,6 +516,58 @@ impl PairingHost { } } + pub(super) async fn remote_refresh_bulletin_allowance_key( + &self, + cx: &CallContext, + session: &SessionInfo, + product_id: String, + ) -> Result { + // Drop the cached (and persisted) key so a stale/exhausted slot is not + // reused, then request a fresh allocation with `Increase` so the + // wallet grants a new allowance rather than echoing the old slot. + self.evict_bulletin_allowance_key(session, &product_id) + .await?; + + let message_id = sso_message_id(); + let message = resource_allocation_message( + message_id, + product_id.clone(), + vec![latest::AllocatableResource::BulletinAllowance], + OnExistingAllowancePolicy::Increase, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::ResourceAllocation, message) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::ResourceAllocation(response) = response else { + return Err(AuthorityError::Unknown { + reason: "Unexpected SSO response for bulletin allowance refresh".to_string(), + }); + }; + let mut outcomes = response + .payload + .map_err(remote_authority_error)? + .into_iter(); + let outcome = outcomes.next().ok_or_else(|| AuthorityError::Unknown { + reason: "Empty bulletin allowance refresh response".to_string(), + })?; + match outcome { + SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { + slot_account_key, + }) => { + self.cache_bulletin_allowance_key(session, &product_id, slot_account_key) + .await + } + SsoAllocationOutcome::Allocated(other) => Err(AuthorityError::Unknown { + reason: format!("Unexpected bulletin allowance refresh resource: {other:?}"), + }), + SsoAllocationOutcome::Rejected => Err(AuthorityError::Rejected), + SsoAllocationOutcome::NotAvailable => Err(AuthorityError::Unavailable { + reason: "bulletin allowance is not available".to_string(), + }), + } + } + async fn cache_allowance_outcomes( &self, session: &SessionInfo, diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 97511af5..35c0292b 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -4,30 +4,43 @@ //! and signing hosts. Pairing state, signing state, active sessions, and role //! controls live on the concrete role objects. -use std::sync::Arc; +use std::collections::VecDeque; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; use crate::chain_runtime::{ChainRuntime, RuntimeChainProvider, RuntimeFailure}; +use crate::runtime::bulletin_rpc::BulletinRpc; use crate::runtime::statement_store_rpc::StatementStoreRpc; use crate::subscription::Spawner; use async_trait::async_trait; use truapi_platform::{JsonRpcConnection, Platform}; +/// Upper bound on the in-core preimage cache. The cache is a bridge until +/// content propagates to the lookup backend, not a store, so it stays small. +const PREIMAGE_CACHE_MAX_BYTES: usize = 16 * 1024 * 1024; + /// Infrastructure shared by all product runtimes created from one host role. pub(crate) struct RuntimeServices { pub(crate) platform: Arc, pub(crate) chain: ChainRuntime, pub(crate) statement_store: StatementStoreRpc, + /// In-core Bulletin submission over the configured Bulletin chain. + pub(crate) bulletin: BulletinRpc, + /// Values from confirmed in-core submissions, served to `lookup_subscribe` + /// until the host's content backend has them. Byte-bounded, oldest-first. + preimage_cache: Mutex, pub(crate) spawner: Spawner, next_core_instance: AtomicU64, } impl RuntimeServices { - /// Build role-neutral runtime services from the platform and People-chain - /// genesis hash used by statement-store backed protocols. + /// Build role-neutral runtime services from the platform, the People-chain + /// genesis hash used by statement-store backed protocols, and the + /// Bulletin-chain genesis hash used for in-core preimage submission. pub(crate) fn new( platform: Arc, people_chain_genesis_hash: [u8; 32], + bulletin_chain_genesis_hash: [u8; 32], spawner: Spawner, ) -> Arc { let chain_provider = Arc::new(HostChainProvider { @@ -36,10 +49,13 @@ impl RuntimeServices { let chain = ChainRuntime::new(chain_provider, spawner.clone()); let statement_store = StatementStoreRpc::new(platform.clone(), people_chain_genesis_hash, spawner.clone()); + let bulletin = BulletinRpc::new(chain.clone(), bulletin_chain_genesis_hash); Arc::new(Self { platform, chain, statement_store, + bulletin, + preimage_cache: Mutex::new(PreimageCache::default()), spawner, next_core_instance: AtomicU64::new(1), }) @@ -48,6 +64,60 @@ impl RuntimeServices { pub(crate) fn next_core_instance(&self) -> u64 { self.next_core_instance.fetch_add(1, Ordering::Relaxed) } + + /// Store a preimage value under its key for later lookup hits. + pub(crate) fn cache_preimage(&self, key: [u8; 32], value: Vec) { + self.preimage_cache + .lock() + .expect("preimage cache mutex poisoned") + .insert(key, value); + } + + /// Return a cached preimage value for `key`, if present. + pub(crate) fn cached_preimage(&self, key: &[u8; 32]) -> Option> { + self.preimage_cache + .lock() + .expect("preimage cache mutex poisoned") + .get(key) + } +} + +/// Byte-bounded, insertion-ordered preimage cache. +#[derive(Default)] +struct PreimageCache { + entries: VecDeque<([u8; 32], Vec)>, + total_bytes: usize, +} + +impl PreimageCache { + fn insert(&mut self, key: [u8; 32], value: Vec) { + if value.len() > PREIMAGE_CACHE_MAX_BYTES { + return; + } + if let Some(index) = self + .entries + .iter() + .position(|(existing, _)| *existing == key) + { + let (_, old) = self.entries.remove(index).expect("index in range"); + self.total_bytes -= old.len(); + } + self.total_bytes += value.len(); + self.entries.push_back((key, value)); + while self.total_bytes > PREIMAGE_CACHE_MAX_BYTES { + let Some((_, evicted)) = self.entries.pop_front() else { + break; + }; + self.total_bytes -= evicted.len(); + } + } + + fn get(&self, key: &[u8; 32]) -> Option> { + self.entries + .iter() + .find(|(existing, _)| existing == key) + .map(|(_, value)| value.clone()) + } } /// Adapter from `truapi_platform::ChainProvider` into the @@ -72,6 +142,8 @@ impl RuntimeChainProvider for HostChainProvider { .connect(genesis_hash) .await .map(Arc::from) - .map_err(|_| RuntimeFailure::unavailable("remote_chain_connect")) + .map_err(|err| { + RuntimeFailure::unavailable_with_reason("remote_chain_connect", format!("{err:?}")) + }) } } diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 4ec98c67..14f3cb93 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -19,6 +19,7 @@ use super::authority::{ }; use super::connected_session_ui_info; use crate::host_logic::entropy::derive_product_entropy; +use crate::host_logic::extrinsic::{Sr25519Signer, build_signed_extrinsic_v4}; use crate::host_logic::product_account::{ ProductAccountError, SR25519_SIGNING_CONTEXT, derive_product_keypair, derive_root_keypair_from_entropy, @@ -166,14 +167,45 @@ impl ProductAuthority for SigningHost { async fn create_transaction( &self, _cx: &CallContext, - _session: &AuthoritySession, - _request: CreateTransactionAuthorityRequest, + session: &AuthoritySession, + request: CreateTransactionAuthorityRequest, ) -> Result { - Err(AuthorityError::Unavailable { - reason: "signing host: transaction construction needs chain metadata (not yet \ - implemented)" - .to_string(), - }) + require_current_session(&self.session_state, session)?; + match request { + CreateTransactionAuthorityRequest::Product(payload) => { + // The product account is authoritative and caller-scoping is + // enforced upstream, so the derived key defines the signer. + let keypair = self.product_keypair(&payload.signer)?; + build_local_transaction( + &keypair, + &payload.call_data, + &payload.extensions, + payload.tx_ext_version, + ) + } + CreateTransactionAuthorityRequest::LegacyAccount { + product_account, + request, + } => { + let keypair = self.product_keypair(&product_account)?; + // Defense-in-depth: the slot-zero key must match the legacy + // signer the caller asked for (also validated upstream). Never + // sign with a diverging key. + if keypair.public.to_bytes() != request.signer { + return Err(AuthorityError::Unknown { + reason: "signing host: legacy signer does not match the product \ + slot-zero account" + .to_string(), + }); + } + build_local_transaction( + &keypair, + &request.call_data, + &request.extensions, + request.tx_ext_version, + ) + } + } } async fn account_alias( @@ -225,6 +257,18 @@ impl ProductAuthority for SigningHost { }) } + async fn refresh_bulletin_allowance_key( + &self, + _cx: &CallContext, + session: &AuthoritySession, + _product_id: String, + ) -> Result { + require_current_session(&self.session_state, session)?; + Err(AuthorityError::Unavailable { + reason: "signing host: bulletin allowance allocation not yet implemented".to_string(), + }) + } + async fn sign_statement_store_product_payload( &self, _cx: &CallContext, @@ -262,6 +306,28 @@ fn product_authority_error(err: ProductAccountError) -> AuthorityError { } } +/// Assemble and sign a transaction locally from caller-supplied, pre-encoded +/// parts. Only Extrinsic V4 (`tx_ext_version == 0`) is supported; the caller's +/// extension bytes carry the whole chain binding, so no metadata is consulted. +fn build_local_transaction( + keypair: &schnorrkel::Keypair, + call_data: &[u8], + extensions: &[v01::TxPayloadExtension], + tx_ext_version: u8, +) -> Result { + if tx_ext_version != 0 { + return Err(AuthorityError::NotSupported { + reason: format!( + "signing host: unsupported tx_ext_version {tx_ext_version}; only V4 \ + (tx_ext_version = 0) is supported for local transaction construction" + ), + }); + } + let signer = Sr25519Signer::from_keypair(keypair); + let transaction = build_signed_extrinsic_v4(&signer, call_data, extensions); + Ok(v01::HostCreateTransactionResponse { transaction }) +} + /// Wrap raw sign-message bytes in the `` envelope unless /// already wrapped, matching the polkadot-app raw-signing convention. /// @@ -303,9 +369,12 @@ fn decode_payload_string(payload: String) -> Result, AuthorityError> { mod tests { use std::sync::Arc; - use super::super::authority::{AuthorityError, SignRawAuthorityRequest}; + use super::super::authority::{ + AuthorityError, CreateTransactionAuthorityRequest, SignRawAuthorityRequest, + }; use super::super::{ProductAuthority, ProductRuntimeHost, RuntimeServices, SigningHostRole}; use super::{BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, raw_payload_bytes}; + use crate::host_logic::extrinsic::tests::split_v4; use crate::host_logic::product_account::{ derive_product_keypair, derive_root_keypair_from_entropy, }; @@ -334,11 +403,13 @@ mod tests { }, PlatformInfo::default(), [0; 32], + [0xbb; 32], ) .expect("signing host config is valid"); let services = RuntimeServices::new( platform.clone(), config.people_chain_genesis_hash, + config.bulletin_chain_genesis_hash, test_spawner(), ); let signing_host = SigningHostRole::new(platform); @@ -421,6 +492,167 @@ mod tests { assert!(matches!(err, CallError::Domain(HostSignRawError::V1(_)))); } + fn product_account(index: u32) -> v01::ProductAccountId { + v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: index, + } + } + + fn tx_payload(tx_ext_version: u8) -> v01::ProductAccountTxPayload { + v01::ProductAccountTxPayload { + signer: product_account(0), + genesis_hash: [0xaa; 32], + call_data: vec![0x00, 0x00], + extensions: vec![v01::TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![1], + additional_signed: vec![2, 3], + }], + tx_ext_version, + } + } + + #[test] + fn create_transaction_product_builds_verifiable_v4() { + let (_services, activation) = signing_runtime(); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = activation.current_session().expect("active session"); + let cx = CallContext::new(); + + let response = futures::executor::block_on(activation.create_transaction( + &cx, + &session, + CreateTransactionAuthorityRequest::Product(tx_payload(0)), + )) + .expect("create_transaction ok"); + + let (account, signature, tail) = split_v4(&response.transaction); + assert_eq!(tail, vec![1, 0x00, 0x00], "body tail is extra ++ call_data"); + + let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + assert_eq!(account, keypair.public.to_bytes()); + + // Payload = call_data ++ extra ++ additional_signed (call first). + let payload = vec![0x00, 0x00, 1, 2, 3]; + let signature = schnorrkel::Signature::from_bytes(&signature).unwrap(); + assert!( + keypair + .public + .verify_simple(b"substrate", &payload, &signature) + .is_ok() + ); + } + + #[test] + fn create_transaction_rejects_nonzero_tx_ext_version() { + let (_services, activation) = signing_runtime(); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = activation.current_session().expect("active session"); + let cx = CallContext::new(); + + let err = futures::executor::block_on(activation.create_transaction( + &cx, + &session, + CreateTransactionAuthorityRequest::Product(tx_payload(1)), + )) + .expect_err("v5 unsupported"); + assert!( + matches!(err, AuthorityError::NotSupported { reason } if reason.contains("tx_ext_version 1")) + ); + } + + #[test] + fn create_transaction_legacy_signer_mismatch_errors() { + let (_services, activation) = signing_runtime(); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = activation.current_session().expect("active session"); + let cx = CallContext::new(); + + let payload = tx_payload(0); + let request = CreateTransactionAuthorityRequest::LegacyAccount { + product_account: product_account(0), + request: v01::LegacyAccountTxPayload { + signer: [0xff; 32], // does not match the derived slot-zero key + genesis_hash: payload.genesis_hash, + call_data: payload.call_data.clone(), + extensions: payload.extensions.clone(), + tx_ext_version: 0, + }, + }; + let err = + futures::executor::block_on(activation.create_transaction(&cx, &session, request)) + .expect_err("mismatched legacy signer"); + assert!( + matches!(err, AuthorityError::Unknown { reason } if reason.contains("does not match")) + ); + } + + #[test] + fn create_transaction_legacy_builds_verifiable_v4() { + let (_services, activation) = signing_runtime(); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = activation.current_session().expect("active session"); + let cx = CallContext::new(); + + let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + + let request = CreateTransactionAuthorityRequest::LegacyAccount { + product_account: product_account(0), + request: v01::LegacyAccountTxPayload { + signer: keypair.public.to_bytes(), // matches the derived slot-zero key + genesis_hash: [0xaa; 32], + call_data: vec![0x00, 0x00], + extensions: vec![v01::TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![1], + additional_signed: vec![2, 3], + }], + tx_ext_version: 0, + }, + }; + let response = + futures::executor::block_on(activation.create_transaction(&cx, &session, request)) + .expect("legacy create_transaction ok"); + + let (account, signature, tail) = split_v4(&response.transaction); + assert_eq!(account, keypair.public.to_bytes()); + assert_eq!(tail, vec![1, 0x00, 0x00]); + let signature = schnorrkel::Signature::from_bytes(&signature).unwrap(); + assert!( + keypair + .public + .verify_simple(b"substrate", &[0x00, 0x00, 1, 2, 3], &signature) + .is_ok() + ); + } + + #[test] + fn create_transaction_requires_active_session() { + let (_services, activation) = signing_runtime(); + // A session snapshot cannot exist without activation, so construct the + // request against a role that has never been activated. + let (_s2, other) = signing_runtime(); + futures::executor::block_on(other.activate_local_session(ENTROPY.to_vec())).unwrap(); + let stale_session = other.current_session().expect("session"); + futures::executor::block_on(other.disconnect()); + let cx = CallContext::new(); + + let err = futures::executor::block_on(activation.create_transaction( + &cx, + &stale_session, + CreateTransactionAuthorityRequest::Product(tx_payload(0)), + )) + .expect_err("no active session"); + assert_eq!(err, AuthorityError::Disconnected); + } + #[test] fn derive_entropy_matches_ios_vector_over_local_session() { let (services, activation) = signing_runtime(); diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index c2607a37..b55d34d4 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -419,7 +419,7 @@ mod tests { fn signing_host_runtime(product_id: &str) -> (ProductRuntimeHost, Arc) { let platform: Arc = Arc::new(StubPlatform::default()); - let services = RuntimeServices::new(platform.clone(), [0; 32], test_spawner()); + let services = RuntimeServices::new(platform.clone(), [0; 32], [0xbb; 32], test_spawner()); let signing_host = SigningHostRole::new(platform); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); @@ -850,9 +850,7 @@ mod tests { panic!("expected statement-store subscribe domain error"); }; assert!( - reason - .reason - .contains("statement-store connect failed: GenericError"), + reason.reason.contains("statement-store connect failed:"), "unexpected reason: {}", reason.reason ); diff --git a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs index bec77132..d54ac0c9 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs @@ -1,4 +1,10 @@ //! Runtime helper for People-chain statement-store JSON-RPC. +//! +//! Statement traffic opens short-lived host RPC connections for its own +//! subscription lifetimes instead of riding the shared +//! [`crate::chain_runtime::ChainRuntime`] chainHead runtime. If a host shares +//! same-topic statement subscriptions on one upstream connection, the host +//! broker must fan out and ref-count same-upstream-token notifications. use std::sync::Arc; diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 652b4e41..62bd9baf 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -33,7 +33,7 @@ use truapi::v01; use truapi::versioned::account::HostAccountGetAliasRequest; use truapi::versioned::resource_allocation::HostRequestResourceAllocationRequest; use truapi_platform::{ - AccountAccessReview, AuthPresenter, AuthState, BulletinAllowanceSigner, ChainProvider, + AccountAccessReview, AuthPresenter, AuthState, ChainProvider, CoreStorage as PlatformCoreStorage, CoreStorageKey, Features as PlatformFeatures, HostInfo, JsonRpcConnection, Navigation as PlatformNavigation, Notifications as PlatformNotifications, PairingHostConfig, Permissions as PlatformPermissions, PlatformInfo, PreimageHost, @@ -96,9 +96,6 @@ pub(crate) struct StubPlatform { /// Invoked after each recorded auth state, outside any stub lock, so a /// test can react to a transition (e.g. cancel the login it observes). pub(crate) on_auth_state: Arc>>, - /// Set when a `chain_connect_pending` connect future is dropped, which is - /// how a dropped login flow manifests on the stub. - pub(crate) pending_connect_dropped: Arc, /// When true, `subscribe_theme` returns a never-ending stream. pub(crate) theme_stream_pending: bool, /// Set when the pending theme stream is dropped. @@ -115,11 +112,15 @@ pub(crate) struct StubPlatform { pub(crate) sent_rpc: Arc>>, pub(crate) rpc_responses: Vec, pub(crate) sso_response_script: Option, + /// When set, `connect` fails with this reason. pub(crate) chain_connect_error: Option<&'static str>, + /// When true, `connect` stays pending forever. pub(crate) chain_connect_pending: bool, - pub(crate) preimage_submits: Arc>>>, - pub(crate) preimage_submit_allowance_public_keys: Arc>>>, - pub(crate) preimage_submit_signatures: Arc>>>, + /// Set when a `chain_connect_pending` connect future is dropped. + pub(crate) pending_connect_dropped: Arc, + /// Value returned by `lookup_preimage`, if any. Tests set this to a + /// forged value to exercise the in-core integrity check. + pub(crate) preimage_lookup_value: Option>, pub(crate) local_storage: Arc>>>, /// When set, product/core storage reads fail with this reason. pub(crate) local_storage_error: Option<&'static str>, @@ -136,14 +137,6 @@ pub(crate) enum SsoResponseScript { }, } -struct DropFlagGuard(Arc); - -impl Drop for DropFlagGuard { - fn drop(&mut self) { - self.0.store(true, Ordering::SeqCst); - } -} - struct PendingThemeStream { dropped: Arc, } @@ -193,6 +186,7 @@ pub(crate) fn runtime_config(product_id: &str) -> (PairingHostConfig, ProductCon }, PlatformInfo::default(), [0; 32], + [0xbb; 32], "polkadotapp".to_string(), ) .expect("test host runtime config is valid"), @@ -1187,6 +1181,15 @@ fn json_rpc_id(frame: &str) -> Option { } } +/// Sets its flag when dropped, marking a cancelled pending operation. +struct DropFlagGuard(Arc); + +impl Drop for DropFlagGuard { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::SeqCst); + } +} + #[truapi_platform::async_trait] impl ChainProvider for StubPlatform { async fn connect( @@ -1292,32 +1295,11 @@ impl ThemeHost for StubPlatform { #[truapi_platform::async_trait] impl PreimageHost for StubPlatform { - async fn submit_preimage( - &self, - value: Vec, - bulletin_allowance_signer: BulletinAllowanceSigner, - ) -> Result, v01::PreimageSubmitError> { - self.preimage_submits - .lock() - .expect("preimage submit list mutex poisoned") - .push(value.clone()); - self.preimage_submit_allowance_public_keys - .lock() - .expect("preimage allowance public key list mutex poisoned") - .push(bulletin_allowance_signer.public_key().to_vec()); - let signature = bulletin_allowance_signer - .sign(b"preimage-submit-test") - .map_err(|err| v01::PreimageSubmitError::Unknown { reason: err.reason })?; - self.preimage_submit_signatures - .lock() - .expect("preimage allowance signature list mutex poisoned") - .push(signature.to_vec()); - Ok(value) - } fn lookup_preimage( &self, _key: Vec, ) -> BoxStream<'static, Result>, v01::GenericError>> { - Box::pin(stream::once(async { Ok(Some(vec![9, 8, 7])) })) + let value = self.preimage_lookup_value.clone(); + Box::pin(stream::once(async move { Ok(value) })) } } diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index d5d89781..4c5c778f 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -18,13 +18,13 @@ use std::sync::atomic::{AtomicBool, Ordering}; use futures::channel::mpsc; use futures::stream::{self, BoxStream, Stream, StreamExt}; -use js_sys::{Array, Function, Object, Reflect, Uint8Array}; +use js_sys::{Array, Function, Reflect, Uint8Array}; use parity_scale_codec::Decode; use send_wrapper::SendWrapper; use truapi::v01; use truapi_platform::{ - BulletinAllowanceSigner, ChainProvider, HostInfo, JsonRpcConnection, PairingHostConfig, - PlatformInfo, ProductContext, RuntimeConfigValidationError, + ChainProvider, HostInfo, JsonRpcConnection, PairingHostConfig, PlatformInfo, ProductContext, + RuntimeConfigValidationError, }; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; @@ -371,32 +371,6 @@ fn invoke_optional_bytes_return( }) } -fn bulletin_allowance_signer_to_js(signer: BulletinAllowanceSigner) -> JsValue { - let object = Object::new(); - let public_key = signer.public_key(); - let public_key = Uint8Array::from(public_key.as_slice()); - Reflect::set(&object, &JsValue::from_str("publicKey"), &public_key) - .expect("setting publicKey on a new object should not fail"); - - let sign = Closure::wrap(Box::new(move |input: JsValue| -> js_sys::Promise { - let signer = signer.clone(); - wasm_bindgen_futures::future_to_promise(async move { - let input = input.dyn_into::().map_err(|_| { - JsValue::from_str("BulletinAllowanceSigner.sign expects Uint8Array") - })?; - let signature = signer - .sign(&input.to_vec()) - .map_err(|err| JsValue::from_str(&err.reason))?; - Ok(Uint8Array::from(signature.as_slice()).into()) - }) - }) as Box js_sys::Promise>); - let sign = sign.into_js_value(); - Reflect::set(&object, &JsValue::from_str("sign"), &sign) - .expect("setting sign on a new object should not fail"); - - object.into() -} - fn decode_bytes(bytes: Vec, message: &str) -> Result { T::decode(&mut bytes.as_slice()).map_err(|_| message.to_string()) } @@ -466,6 +440,7 @@ fn pairing_host_config_from_js(value: &JsValue) -> Result Result &str { "host_info.name" => "host.name", "pairing_deeplink_scheme" => "pairing.deeplinkScheme", "people_chain_genesis_hash" => "people.genesisHash", + "bulletin_chain_genesis_hash" => "bulletin.genesisHash", other => other, } } diff --git a/rust/crates/truapi-server/src/wasm/generated_bridge.rs b/rust/crates/truapi-server/src/wasm/generated_bridge.rs index e01cf767..c08a4868 100644 --- a/rust/crates/truapi-server/src/wasm/generated_bridge.rs +++ b/rust/crates/truapi-server/src/wasm/generated_bridge.rs @@ -11,9 +11,9 @@ use truapi::v01; use wasm_bindgen::JsValue; use super::{ - WasmPlatform, bulletin_allowance_signer_to_js, call_js_function, decode_bytes, decode_js_item, - generic, get_function, invoke_bool, invoke_bytes_return, invoke_js_subscription, - invoke_optional_bytes_return, invoke_unit, parse_optional_bytes_item, + WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, + invoke_bool, invoke_bytes_return, invoke_js_subscription, invoke_optional_bytes_return, + invoke_unit, parse_optional_bytes_item, }; /// JS-side callbacks invoked by the wasm platform bridge. Methods with @@ -32,7 +32,6 @@ pub(super) struct JsBridge { pub(super) cancel_notification: Function, pub(super) device_permission: Function, pub(super) remote_permission: Function, - pub(super) submit_preimage: Function, pub(super) lookup_preimage: Function, pub(super) read: Function, pub(super) write: Function, @@ -55,7 +54,6 @@ impl JsBridge { cancel_notification: get_function(callbacks, "cancelNotification")?, device_permission: get_function(callbacks, "devicePermission")?, remote_permission: get_function(callbacks, "remotePermission")?, - submit_preimage: get_function(callbacks, "submitPreimage")?, lookup_preimage: get_function(callbacks, "lookupPreimage")?, read: get_function(callbacks, "read")?, write: get_function(callbacks, "write")?, @@ -216,24 +214,7 @@ impl truapi_platform::Permissions for WasmPlatform { } } -#[truapi_platform::async_trait] impl truapi_platform::PreimageHost for WasmPlatform { - async fn submit_preimage( - &self, - value: Vec, - bulletin_allowance_signer: truapi_platform::BulletinAllowanceSigner, - ) -> Result, v01::PreimageSubmitError> { - invoke_bytes_return( - &self.bridge.submit_preimage, - vec![ - Uint8Array::from(value.as_slice()).into(), - bulletin_allowance_signer_to_js(bulletin_allowance_signer), - ], - ) - .await - .map_err(|reason| v01::PreimageSubmitError::Unknown { reason }) - } - fn lookup_preimage( &self, key: Vec, diff --git a/rust/crates/truapi-server/tests/common/mod.rs b/rust/crates/truapi-server/tests/common/mod.rs index 5819f53c..33a5b8cb 100644 --- a/rust/crates/truapi-server/tests/common/mod.rs +++ b/rust/crates/truapi-server/tests/common/mod.rs @@ -6,9 +6,9 @@ use std::sync::Mutex; use futures::stream::{self, BoxStream}; use truapi::v01; use truapi_platform::{ - AuthPresenter, BulletinAllowanceSigner, ChainProvider, CoreStorage, CoreStorageKey, Features, - HostInfo, JsonRpcConnection, Navigation, Notifications, PairingHostConfig, Permissions, - PlatformInfo, PreimageHost, ProductContext, ProductStorage, ThemeHost, UserConfirmation, + AuthPresenter, ChainProvider, CoreStorage, CoreStorageKey, Features, HostInfo, + JsonRpcConnection, Navigation, Notifications, PairingHostConfig, Permissions, PlatformInfo, + PreimageHost, ProductContext, ProductStorage, ThemeHost, UserConfirmation, UserConfirmationReview, }; use truapi_server::frame::ProtocolMessage; @@ -57,6 +57,7 @@ pub fn test_runtime_config() -> (PairingHostConfig, ProductContext) { }, PlatformInfo::default(), [0xa2; 32], + [0xbb; 32], "polkadotapp".to_string(), ) .expect("test host runtime config is valid"), @@ -188,15 +189,7 @@ impl ThemeHost for WireShapePlatform { } } -#[truapi_platform::async_trait] impl PreimageHost for WireShapePlatform { - async fn submit_preimage( - &self, - value: Vec, - _bulletin_allowance_signer: BulletinAllowanceSigner, - ) -> Result, v01::PreimageSubmitError> { - Ok(value) - } fn lookup_preimage( &self, _key: Vec, diff --git a/rust/crates/truapi-server/tests/fixtures/bulletin_paseo_metadata.scale b/rust/crates/truapi-server/tests/fixtures/bulletin_paseo_metadata.scale new file mode 100644 index 00000000..670b2431 Binary files /dev/null and b/rust/crates/truapi-server/tests/fixtures/bulletin_paseo_metadata.scale differ diff --git a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs index 80f42931..b98bdbb1 100644 --- a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs +++ b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs @@ -60,6 +60,7 @@ fn runtime_config() -> PairingHostConfig { version: Some("192.32".to_string()), }, [0xa2; 32], + [0xbb; 32], "polkadotapp".to_string(), ) .expect("test runtime config is valid")