diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a93461a3..1e41c488 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,12 +33,18 @@ jobs: - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # stable with: toolchain: stable + targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - name: cargo build run: cargo build --workspace --all-targets --all-features + - name: cargo build (wasm32) + # Guards the wasm host bridge and its offline subxt surface, which the + # host-target build never compiles. + run: cargo build -p truapi-server --target wasm32-unknown-unknown --no-default-features + - name: cargo +nightly fmt --check run: cargo +nightly fmt --check 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..f3c480cb 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" @@ -3273,6 +3684,7 @@ dependencies = [ "truapi", "unicode-normalization", "url", + "zeroize", ] [[package]] @@ -3281,13 +3693,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 +3705,7 @@ dependencies = [ "nanoid", "p256", "parity-scale-codec", - "pin-project", - "primitive-types", + "scale-info", "schnorrkel", "send_wrapper 0.6.0", "serde", @@ -3304,6 +3713,7 @@ dependencies = [ "sha2 0.10.9", "sp-crypto-hashing", "substrate-bip39", + "subxt", "subxt-rpcs", "thiserror 1.0.69", "tracing", @@ -3328,6 +3738,7 @@ checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ "cfg-if", "digest 0.10.7", + "rand", "static_assertions", ] @@ -3392,6 +3803,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 +4397,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 b3e20965..bdef6c07 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,8 @@ DOTLI_UI := $(DOTLI)/packages/ui DOTLI_HOST_WASM_LINK := $(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 @@ -101,6 +103,7 @@ 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 @@ -127,12 +130,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/docs/issue-drafts/bulletin-preimage-in-core.md b/docs/issue-drafts/bulletin-preimage-in-core.md new file mode 100644 index 00000000..0f841b26 --- /dev/null +++ b/docs/issue-drafts/bulletin-preimage-in-core.md @@ -0,0 +1,336 @@ +# Move Bulletin preimage submission into the Rust core + +## TL;DR + +Preimage submission to the Bulletin chain now happens **entirely inside the Rust +core** (`truapi-server`). The core builds, signs, and submits the +`TransactionStorage.store` extrinsic itself, routing the chain traffic through +the host's existing `chain.connect` byte-pipe — the same transport products +already use for any chain access. + +Previously the core handed the host a _signing capability_ and the host built +and submitted the transaction with PAPI. That seam is gone. As a result: + +- The wallet-delegated **allowance secret never leaves the core** (it no longer + crosses the host / FFI boundary as a signer handle). +- The host's only remaining preimage job is **content lookup** (Helia / IPFS). +- All hosts (web, and later mobile over uniffi) get identical submission + behaviour for free, because the logic lives in the shared core rather than in + each host's JS/native code. + +This issue explains the moving parts and, most importantly, **the messages +exchanged** between the product, the Rust core, the host, and the network. + +--- + +## Why + +The base branch already routes every chain call through one host callback, +`chain.connect(genesisHash)`, which returns a JSON-RPC byte pipe. Preimage +submission was the one chain write that did _not_ use it: instead the core +derived an allowance signer and passed a `sign(payload)` closure out to the +host, which reconstructed a PAPI transaction and submitted it. That meant: + +- Every host reimplemented extrinsic construction/submission (and dotli pulled + `@novasamatech` + PAPI signer deps back in to do it). +- The allowance secret's signing capability crossed the worker / FFI boundary. +- Submission behaviour (nonce, mortality, dispatch-error handling, retries) + diverged per host. + +Folding it into the core removes all three. + +--- + +## Before vs after: who owns what + +The topology is unchanged — the core still reaches the network only through the +host — what changes is **ownership**. Each box below lists what that side owns; +every arrow crossing the vertical boundary is labelled with exactly what +crosses it. + +Before, the *secret bytes* were core-owned, but the *capability to use them* — +and everything that decides what gets signed — was host-owned: + +``` +BEFORE + core-owned (WASM worker) host-owned (JS main thread) + +------------------------------+ || +------------------------------+ + | Rust core | || | dotli (PAPI) | + | | || | | + | owns: | || | owns: | + | - allowance secret (64 B) | || | - signer handle | + | - sign() execution | || | { publicKey, sign() } | + | | || | - tx builder (PAPI) | + | | || | - decides WHAT is signed | + | | || | - submission + watch | + +------------------------------+ || +------------------------------+ + + crosses ||: core --[ signer handle ]--> host (a live signing capability) + core <--[ sign(payload) ]-- host (one request per tx) + core --[ signature ]--> host --> Bulletin +``` + +After, everything signing-related sits on the core side; the only thing that +crosses the boundary during submission is opaque JSON-RPC text: + +``` +AFTER + core-owned (WASM worker) host-owned (JS main thread) + +------------------------------+ || +------------------------------+ + | Rust core | || | dotli | + | | || | | + | owns: | || | owns: | + | - allowance secret (64 B) | || | - permission/confirm modals | + | - signer (crate-private, | || | - chain.connect byte pipe | + | never exported) | || | (forwards verbatim) | + | - tx builder (subxt) | || | - lookupPreimage backend | + | - decides WHAT is signed | || | (Helia / IPFS) | + | - submission + watch | || | | + +------------------------------+ || +------------------------------+ + + crosses ||: core --[ JSON-RPC request ]--> host --> Bulletin + core <--[ JSON-RPC response ]-- host + opaque strings only: no key, no capability, no sign requests +``` + +The security consequence: before, host code held a live capability and chose +the payloads it signed; a compromised host could sign arbitrary Bulletin +transactions with the user's allowance. After, the host can at worst drop or +corrupt bytes — a lie in either direction produces a transaction the real +chain rejects, never a signature over attacker-chosen content. + +--- + +## Actors and responsibilities + +| Actor | Runs where | Owns | +| -------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------ | +| **Product** | sandboxed iframe / app | speaks TrUAPI; never sees chains or keys | +| **Rust core** (`truapi-server`) | WASM in a Web Worker (web); native lib (mobile) | wire protocol, tx build + sign, submit + watch, allowance key | +| **Host** (dotli / mobile shell) | main thread / OS shell | user modals, `chain.connect` transport, `lookupPreimage` content backend | +| **Wallet** (paired signing host) | phone | allocates the Bulletin allowance key over SSO | +| **Network** | — | Bulletin chain (writes) + People chain (allowance allocation / SSO) | + +--- + +## Message flow: preimage submission (happy path) + +This is the core of the change. The product calls one wire method, +`preimage.submit(value)`; everything below it is new in-core work. + +The columns are ownership lanes. A boundary glyph shows what crosses it on that +row: `>` left-to-right, `<` right-to-left, `:` nothing. Everything in the CORE +lane stays in the WASM worker unless a glyph carries it out. + +``` + PRODUCT | CORE (holds keys, builds+signs) : HOST (modals+pipe) : NET + ---------+------------------------------------+--------------------+--- + submit > receive : : + | -- gate (unchanged) -- : : + | remotePermission > prompt user : + | confirmUserAction > prompt user : + | -- allowance key (cached) -- : : + | requestResourceAllocation > relay SSO > wlt + | slotAccountKey < passthrough < key + | [[ 64-B secret stays in core ]] : : + | -- build + submit via pipe -- : : + | chain.connect(bulletin) > open pipe : + | chainHead_v1_follow > forward > N + | Metadata_metadata_at_version > forward > N + | AccountNonceApi_account_nonce > forward > N + | [[ build + SIGN store(value) ]] : : + | validate_transaction (dry-run) > forward > N + | transaction_v1_broadcast > forward > N + | chainHead_v1_body (watch) > forward > N + | chainHead_v1_storage(Events) > forward > N + key< return blake2_256(value) : : + | [[ prime lookup cache ]] : : + + boundary glyphs: > message crosses left->right < crosses right->left + : boundary, no message on this row + CORE:HOST is the WASM-worker edge; [[..]] work never leaves the core; NET is + the Bulletin chain, reached only through the host's pipe (wlt = paired wallet) +``` + +Key points on this flow: + +- The **gate** (permission + confirmation) is unchanged from the base protocol; + it still guards the write. +- The **dry-run** is load-bearing: `transaction_v1_broadcast` is + spec-guaranteed to be _silent_ on invalid transactions, so without a + `validate_transaction` call the core could never distinguish "rejected" from + "still pending" and would only ever see a timeout. +- Inclusion is confirmed by **matching the extrinsic hash in a block body**, + then the **dispatch outcome** is read from `System.Events` — matching the + fidelity the old PAPI `signSubmitAndWatch` path had. + +--- + +## Message flow: allowance key (the one secret) + +The allowance key is a wallet-delegated, scoped signing capability. The wallet +owns *minting* it; the core owns *holding and using* it; the host only relays +the SSO messages and never sees the resulting secret used. It is allocated once +per (session, product), cached in the core, and used only to sign the `store` +call. + +``` + CORE (holds + uses the key) : HOST (relays SSO) : WALLET (mints) + ---------------------------------------+--------------------+--- + -- first submit for this product -- : : + requestResourceAllocation > relay (Ignore) > allocate + slotAccountKey < passthrough < { key } + [[ store in memory + persisted ]] : : + [[ used only to sign store() calls ]] : : + : : + -- later: allowance is exhausted -- : : + [[ evict the cached key ]] : : + requestResourceAllocation > relay (Increase) > mint fresh, + slotAccountKey < passthrough < larger one + [[ retry the submit exactly once ]] : : + + > crosses core -> host -> wallet < the reply crossing back + the key lands in CORE and is used there; the host never sees it signing +``` + +The retry is bounded: the core refreshes the allowance and retries **at most +once**, and only when the failure is a typed "allowance rejected" signal (from +the dry-run or from a `TransactionStorage` authorization error in the events) — +never on transport errors or nonce races. + +--- + +## Message flow: lookup (unchanged owner, new integrity check) + +The *content backend* is host-owned (the host chooses Helia P2P or an IPFS +gateway); the *cache* and the *integrity gate* are core-owned. So even though +the bytes originate on the host side, the core decides whether they reach the +product. + +``` + CORE (cache + integrity gate) : HOST (content backend) + -----------------------------------------+----------------------- + lookup_subscribe(key) from product : + : + cache hit (primed by in-core submit): : + emit value once, keep open : + : + cache miss: : + lookupPreimage(key) > poll Helia / IPFS + value | miss < host-owned bytes + [[ gate: blake2_256(value)==key? ]] : + match: forward mismatch: -> miss : + + > request crosses into the host < host's bytes cross back + the backend is host-owned; the cache + integrity gate are core-owned, + so the core decides what reaches the product (a mismatch is warn-logged) +``` + +A host that returns bytes not matching the requested key can no longer feed a +product forged content: the core's integrity gate downgrades the mismatch to a +miss before it reaches the product. + +--- + +## Failure handling and the inclusion watch + +The submit flow returns a typed error that maps to a stable reason string on the +wire (`PreimageSubmitError::Unknown { reason }` — the product wire is +unchanged). The decision the core makes at each failure: + +``` + dry-run: validate_transaction + | + +-- Valid ................................. broadcast + watch (see below) + +-- Invalid::Payment / Custom / BadSigner . allowance rejected -> refresh + retry once + +-- Invalid::Future / Stale ............... nonce race (no retry) + +-- other Invalid ......................... invalid (no retry) + + broadcast + watch: included? + | + +-- ExtrinsicSuccess ...................... return the preimage key + +-- TransactionStorage auth error ......... allowance rejected -> refresh + retry once + +-- other dispatch error .................. dispatch failed + +-- 120s elapsed / follow stopped ......... broadcast, inclusion unverified +``` + +"Allowance rejected" is the only outcome that refreshes the key +(`onExisting=Increase`) and retries — at most once, in both the dry-run and the +dispatch branches. + +The inclusion watch is a single event loop over one ephemeral chainHead follow. +It fetches a block body only after the allowance account's nonce is seen to +advance (so it does not download every block), matches by extrinsic hash, and +releases pins as it goes. Because the follow is dropped when the 120 s budget or +a cancellation fires, its pins and connection lease are released automatically. + +--- + +## Security invariants + +Because the chain provider is an untrusted byte pipe (especially in RPC-gateway +mode), the core enforces four invariants so a hostile or buggy provider cannot +subvert the allowance key: + +1. **Genesis is config-pinned.** The signed payload's genesis hash always comes + from host _configuration_, never from provider-echoed chain data — so a + provider cannot redirect the allowance-key signature to a different chain. + Every other provider-supplied input (spec/tx version, nonce, mortality + anchor, metadata) is then fail-closed: lying about it yields a signature the + real chain rejects, never one valid elsewhere. +2. **Call is pinned.** Name resolution of `TransactionStorage.store` is + hard-asserted against audited pallet/call indices, and the metadata-built + call bytes are checked byte-for-byte against a canonically built copy — so + provider metadata cannot make the key sign a different call. +3. **Signer is confined.** The only allowance-key -> signer conversion is + crate-private to the bulletin module; the signer is transient, never stored, + never logged, and the key type is zeroized on drop. +4. **Lookup is content-addressed.** Host-returned bytes are verified against the + requested key before reaching the product. + +A crafted `NewBlock` parent link (self-referential or cyclic) is also guarded in +the inclusion watch so the provider cannot spin the worker. + +--- + +## What changed (high-level map) + +- `truapi-server/src/host_logic/extrinsic.rs` — offline subxt assembler: + config-pinned `SubstrateConfig`, sr25519 signer, metadata / validity / events + / header decoders. +- `truapi-server/src/host_logic/bulletin.rs` — `store{data}` build + sign, call + pinning, byte-level call-data encoder. +- `truapi-server/src/runtime/bulletin_rpc.rs` — the submit flow (follow -> + metadata -> nonce -> dry-run -> broadcast -> watch -> events) and its typed + errors. +- `truapi-server/src/runtime.rs` — `Preimage::submit` ordering + refresh/retry; + `lookup_subscribe` cache + integrity check. +- `truapi-platform` — `PreimageHost` keeps only `lookupPreimage`; + `BulletinAllowanceKey` is zeroized on drop; configs gain an optional Bulletin + genesis hash. +- Host TS (`@parity/truapi-host`) + dotli — the signer bridge is removed; dotli + keeps only lookup, threads the Bulletin genesis into its runtime config, and + routes the Bulletin genesis through `chain.connect` on both backends. + +The **product-facing wire is unchanged** — `preimage.submit` / +`preimage.lookup_subscribe` keep their wire ids and shapes, so products need no +changes. + +--- + +## Verification + +- Rust: build / fmt / clippy (`-D warnings`) / tests all green; wasm32 compiles; + `cargo deny` licenses ok. New unit tests cover extrinsic construction against + real Bulletin metadata, genesis-binding, call pinning, the extension-encoding + rules, and the inclusion-watch cycle guard. +- Host TS: `tsc` + `bun` tests green. WASM bundle grows ~0.5 MB (subxt offline). +- **Manual / e2e gates** (network + signer-bot, not in CI): the live-chain + dry-run encoding proof and `make e2e-dotli` preimage flow. + +## Follow-ups + +- Signing-host (mobile local-key) role: derive the Bulletin allowance key from + root entropy so submission works there too — everything downstream of the + allowance-key fetch is already shared. +- Reuse `host_logic/extrinsic.rs` for the local `create_transaction` path. diff --git a/hosts/dotli b/hosts/dotli index bcd3a424..07519a4e 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit bcd3a424e5e40ed030adf79360d0fb714ae341da +Subproject commit 07519a4e3c70921d5272cea622ad264e4c91bb5e 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 053e806b..b28cc4e2 100644 --- a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -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(); await settle(); @@ -342,7 +335,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..7e06329e 100644 --- a/js/packages/truapi-host/src/runtime.ts +++ b/js/packages/truapi-host/src/runtime.ts @@ -68,6 +68,12 @@ export interface ProductRuntimeConfig { people: { genesisHash: string | Uint8Array; }; + /** + * Bulletin-chain genesis hash used for in-core preimage submission. + */ + bulletin: { + genesisHash: string | Uint8Array; + }; pairing: { deeplinkScheme: string; }; 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 82d4da11..3116816d 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 @@ -91,7 +91,6 @@ interface RuntimeState { number, { resolve: () => void; reject: (error: Error) => void } >; - pendingBulletinAllowanceSigns: Map>; closedError: Error | null; logLevel: LogLevel; disposed: boolean; @@ -104,12 +103,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, @@ -159,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({ @@ -182,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: { @@ -441,7 +379,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); } @@ -541,7 +478,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, @@ -603,15 +539,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 aa1240a6..7a0cc086 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: + "0x2e7ffab63d8e86f6828a42445c7e3036f6bb47dd437e52a65457015f0605d8be", + }, pairing: { deeplinkScheme: "polkadotapp", }, @@ -521,77 +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(); - 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 736aa71f..2b1a73c2 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: "chainConnectAck"; connId: number; ok: true } | { kind: "chainConnectAck"; connId: number; ok: false; error: string } @@ -158,18 +152,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 3962d21d..8d360932 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -72,19 +72,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 subscriptionItemListeners = new Map void>(); @@ -99,11 +86,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)); }); @@ -111,28 +94,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, @@ -210,7 +171,6 @@ function chainConnect( function buildRawCallbacks() { return createWorkerRawCallbacks({ callbackRequest, - registerBulletinAllowanceSigner, startSubscription, chainConnect, }); @@ -349,9 +309,6 @@ ctx.addEventListener("message", (ev: MessageEvent) => { } break; } - case "signBulletinAllowance": - void handleSignBulletinAllowance(msg.requestId, msg.signerId, msg.input); - break; case "subscriptionItem": { const listener = subscriptionItemListeners.get(msg.subId); if (listener) listener(msg.value); @@ -382,7 +339,6 @@ ctx.addEventListener("message", (ev: MessageEvent) => { } catch (err) { postToMain({ kind: "disposeError", error: errorMessage(err) }); } - bulletinAllowanceSigners.clear(); runtime = null; break; default: { @@ -406,40 +362,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..18c4a987 100644 --- a/playground/src/lib/auto-test.ts +++ b/playground/src/lib/auto-test.ts @@ -37,10 +37,11 @@ const LONG_TIMEOUT_METHODS = new Set([ ]); const METHOD_TIMEOUT_MS = new Map([ + ["Account/get_user_id", SSO_TIMEOUT_MS], ["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], @@ -53,6 +54,11 @@ type RunOneOpts = { onUpdate: (id: string, entry: TestEntry) => void; }; +type DiagnosisItem = { + serviceName: string; + method: MethodInfo; +}; + async function runOne({ serviceName, method, @@ -142,19 +148,48 @@ export async function runSingleTest( await runOne({ serviceName, method, onUpdate }); } -// Full diagnosis: run every method one at a time, in service order. Methods -// that prompt the user (signing, permission/resource requests) block on their -// host dialog before the run continues. Produces a complete worked / failed / -// not-wired matrix suitable for the copy-pasteable report. +// Full diagnosis: run every method one at a time. Methods that prompt the user +// (signing, permission/resource requests) block on their host dialog before +// the run continues. Produces a complete worked / failed / not-wired matrix +// suitable for the copy-pasteable report. export async function runDiagnosis( services: ServiceInfo[], onUpdate: (id: string, entry: TestEntry) => void, signal?: AbortSignal, ): Promise { - for (const svc of services) { - for (const method of svc.methods) { - if (signal?.aborted) return; - await runOne({ serviceName: svc.name, method, onUpdate }); - } + const items = diagnosisRunItems(services); + for (const item of items) { + if (signal?.aborted) return; + await runOne({ ...item, onUpdate }); } } + +function diagnosisRunItems(services: ServiceInfo[]): DiagnosisItem[] { + const items = services.flatMap((svc) => + svc.methods.map((method) => ({ serviceName: svc.name, method })), + ); + moveBefore(items, "Resource Allocation/request", "Preimage/lookup_subscribe"); + return items; +} + +function moveBefore( + items: DiagnosisItem[], + itemId: string, + beforeId: string, +): void { + const currentIndex = items.findIndex( + (item) => diagnosisItemId(item) === itemId, + ); + const beforeIndex = items.findIndex( + (item) => diagnosisItemId(item) === beforeId, + ); + if (currentIndex < 0 || beforeIndex < 0 || currentIndex < beforeIndex) { + return; + } + const [item] = items.splice(currentIndex, 1); + items.splice(beforeIndex, 0, item); +} + +function diagnosisItemId(item: DiagnosisItem): string { + return `${item.serviceName}/${item.method.name}`; +} diff --git a/playground/tests/e2e/dotli-diagnosis.ts b/playground/tests/e2e/dotli-diagnosis.ts index 3e9521d6..9ee9e4ce 100644 --- a/playground/tests/e2e/dotli-diagnosis.ts +++ b/playground/tests/e2e/dotli-diagnosis.ts @@ -537,6 +537,14 @@ async function runDiagnosisOnce(page: Page): Promise<{ return { summary, report, copyReportClicked: true }; } +function diagnosisFailCount(summary: string): number { + const match = /(\d+)\s+failed\b/.exec(summary); + if (match === null) { + throw new Error(`could not parse diagnosis summary: ${summary}`); + } + return Number(match[1]); +} + async function waitForDiagnosisReportReady(frame: Frame): Promise { const deadline = Date.now() + 20 * 60_000; let lastLogAt = 0; @@ -704,6 +712,12 @@ async function main(): Promise { const { summary, report, copyReportClicked } = await runDiagnosis(page); const reportPath = resolve(outputDir, "diagnosis-report.md"); writeFileSync(reportPath, report); + const failedRows = diagnosisFailCount(summary); + if (failedRows > 0) { + throw new Error( + `diagnosis completed with ${failedRows} failed row(s): ${summary}; report: ${reportPath}`, + ); + } pairResult = await assertHostSignOutAndReconnect(page); const metadataPath = resolve(outputDir, "diagnosis-run.json"); writeFileSync( diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index 3d476692..a0e3a954 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -36,7 +36,7 @@ pub fn generate_wasm_bridge( use wasm_bindgen::JsValue; use super::{{ - WasmPlatform, bulletin_allowance_signer_to_js, call_js_function, decode_bytes, + 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, @@ -525,12 +525,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 +823,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 +841,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 9be0437f..02b52c38 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -116,7 +116,7 @@ fn emit_host_callbacks( out.push('\n'); } - let imports = collect_named_types(definition) + let imports = collect_named_types(definition, local_codec_types) .into_iter() .filter(|name| !codec_imports.contains(name)) .collect::>(); @@ -125,15 +125,10 @@ 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)) + .filter(|ty| local_codec_types.contains(&ty.name)) { let rendered = match &type_def.kind { TypeDefKind::Enum(_) => emit_enum_type(type_def)?, @@ -265,7 +260,6 @@ fn emit_wasm_adapter( out, r#" import type {{ - BulletinAllowanceSigner, RequiredHostCallbacks, }} from "./host-callbacks.js"; @@ -362,21 +356,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'); @@ -391,13 +370,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"); @@ -518,15 +490,7 @@ fn emit_worker_callback_entry(method: &PlatformMethod) -> Result { 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 - } - }) + .map(|p| to_camel_case(&p.name)) .collect::>() .join(", "); let arg_array = if arg_exprs.is_empty() { @@ -733,10 +697,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(), @@ -761,10 +721,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(), @@ -846,7 +802,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) => { @@ -1073,7 +1028,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 { @@ -1473,7 +1428,10 @@ fn emit_host_callback_composites(composes: &[String], docs: Option<&str>) -> Str } } -fn collect_named_types(definition: &PlatformDefinition) -> BTreeSet { +fn collect_named_types( + definition: &PlatformDefinition, + local_codec_types: &BTreeSet, +) -> BTreeSet { let mut out: BTreeSet = BTreeSet::new(); for trait_def in &definition.traits { for method in &trait_def.methods { @@ -1497,10 +1455,11 @@ fn collect_named_types(definition: &PlatformDefinition) -> BTreeSet { } } } - for type_def in &definition.types { - if is_callback_special_type_name(&type_def.name) { - continue; - } + for type_def in definition + .types + .iter() + .filter(|ty| local_codec_types.contains(&ty.name)) + { collect_from_type_def(type_def, &mut out); } // Filter out names defined locally (the capability trait interfaces and @@ -1554,12 +1513,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 { @@ -1597,36 +1550,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 a389ed9c..9d44e561 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -25,7 +25,6 @@ import { UserConfirmationReview, } from "./host-callbacks.js"; import type { - BulletinAllowanceSigner, RequiredHostCallbacks, } from "./host-callbacks.js"; @@ -49,7 +48,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): (() => void) | void; read(key: string): Promise; write(key: string, value: Uint8Array): Promise; @@ -74,7 +72,6 @@ export function createWasmRawCallbacks( cancelNotification: async (id) => await callbacks.notifications.cancelNotification(id), devicePermission: async (request) => HostDevicePermissionResponse.enc(await callbacks.permissions.devicePermission(HostDevicePermissionRequest.dec(request))), remotePermission: async (request) => RemotePermissionResponse.enc(await callbacks.permissions.remotePermission(RemotePermissionRequest.dec(request))), - submitPreimage: async (value, bulletinAllowanceSigner) => await callbacks.preimage.submitPreimage(value, bulletinAllowanceSigner), lookupPreimage: (key, sendItem) => driveResultStream(callbacks.preimage.lookupPreimage(key), sendItem), read: async (key) => await callbacks.productStorage.read(key), write: async (key, value) => await callbacks.productStorage.write(key, value), diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 94b7814a..dbe1c664 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. */ @@ -524,15 +519,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 eedd768b..6b149047 100644 --- a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs @@ -11,7 +11,7 @@ use truapi::v01; use wasm_bindgen::JsValue; use super::{ - WasmPlatform, bulletin_allowance_signer_to_js, call_js_function, decode_bytes, + 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, @@ -33,7 +33,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, @@ -56,7 +55,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")?, @@ -217,24 +215,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 969695f3..8a582f93 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -7,13 +7,6 @@ import type { RawCallbacks } from "./host-callbacks-adapter.js"; -import type { BulletinAllowanceSigner } from "./host-callbacks.js"; - -export interface WorkerBulletinAllowanceSigner { - publicKey: Uint8Array; - signerId: number; -} - import type { ChainConnect, } from "../runtime.js"; @@ -29,7 +22,6 @@ export const CALLBACK_NAMES = [ "cancelNotification", "devicePermission", "remotePermission", - "submitPreimage", "read", "write", "clear", @@ -45,7 +37,6 @@ export type SubscriptionName = typeof SUBSCRIPTION_NAMES[number]; export interface WorkerCallbackBridge { callbackRequest(name: CallbackName, args: readonly unknown[]): Promise; - registerBulletinAllowanceSigner(signer: BulletinAllowanceSigner): WorkerBulletinAllowanceSigner; startSubscription( name: SubscriptionName, payload: Uint8Array | null, @@ -76,8 +67,6 @@ function rawCallbacks(bridge: WorkerCallbackBridge): Required, remotePermission: (request) => bridge.callbackRequest("remotePermission", [request]) as ReturnType, - 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/Cargo.toml b/rust/crates/truapi-platform/Cargo.toml index cfbd0fc4..a61ea4c4 100644 --- a/rust/crates/truapi-platform/Cargo.toml +++ b/rust/crates/truapi-platform/Cargo.toml @@ -13,6 +13,7 @@ futures = "0.3" parity-scale-codec = { version = "3", features = ["derive"] } unicode-normalization = "0.1" url = "2" +zeroize = { version = "1", default-features = false, features = ["derive"] } [lints.rust] unsafe_code = "forbid" diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index c0351268..520d3fca 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, }) } } @@ -613,9 +618,12 @@ pub trait ThemeHost: Send + Sync { } /// Secret key allocated for Bulletin preimage submission. -#[derive(Clone, derive_more::Debug, PartialEq, Eq)] +/// +/// The core is the sole holder: the secret never crosses the host boundary. +/// Zeroized on drop, and its `Debug` redacts the material. +#[derive(Clone, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop, derive_more::Debug)] pub struct BulletinAllowanceKey { - #[debug("{:?}", "")] + #[debug("\"\"")] secret: [u8; 64], } @@ -630,15 +638,10 @@ impl BulletinAllowanceKey { Ok(Self { secret }) } - /// Raw secret bytes for bridge and storage adapters. - pub fn as_secret_bytes(&self) -> &[u8] { + /// Borrow the raw secret bytes for in-core signing. + pub fn as_secret_bytes(&self) -> &[u8; 64] { &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. @@ -652,71 +655,11 @@ pub enum BulletinAllowanceKeyError { }, } -/// 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..67f36e1c 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,9 +46,8 @@ 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"] } @@ -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..8069be14 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,25 @@ 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 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 +105,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. @@ -137,8 +158,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 +188,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 +255,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 +272,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() } @@ -493,6 +515,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 +567,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 +593,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 +654,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 +690,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 +805,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 +941,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 +960,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 +1020,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 +1052,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 +1318,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 +1326,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 +1340,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 +1372,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 +1469,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![ @@ -1613,6 +1791,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..cbdabae7 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/bulletin.rs @@ -0,0 +1,476 @@ +//! 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 parity_scale_codec::{Compact, Encode}; +use subxt::client::{ClientAtBlock, OnlineClientAtBlockT}; +use subxt::config::DefaultExtrinsicParamsBuilder; +use subxt::config::substrate::SubstrateConfig; +use subxt::error::ExtrinsicError; +use subxt::ext::scale_encode::{self, EncodeAsFields, FieldIter, TypeResolver}; +use subxt::ext::scale_type_resolver::{Primitive, visitor}; +use subxt::tx::{StaticPayload, SubmittableTransaction}; +use truapi_platform::BulletinAllowanceKey; + +use crate::host_logic::extrinsic::Sr25519Signer; + +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 a byte-level fast path. +/// +/// scale-encode has no `u8` specialization, so encoding the preimage as a +/// `Vec` value would pay a registry resolution and visitor dispatch per +/// byte, twice per transaction. This implementation verifies once that the +/// `data` field resolves to a sequence of `u8` (hard error otherwise, so +/// metadata lies about the argument type are rejected) and then memcpys. +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)?; + + Compact(self.0.len() as u32).encode_to(out); + out.extend_from_slice(self.0); + 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"), "{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() { + // The store call data must not encode per byte (scale-encode has no u8 + // fast path); the StoreCallData memcpy keeps an 8 MiB preimage cheap. + // A generous bound catches an O(n^2)/per-byte-visitor regression + // without being flaky under CI load. + 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/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 7004a228..4debdd2a 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; @@ -24,7 +25,7 @@ use core::time::Duration; use std::sync::Arc; 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,6 +35,7 @@ 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; @@ -145,6 +147,10 @@ pub(super) const REMOTE_PERMISSION_DENIED_REASON: &str = "Permission denied"; /// after 180 seconds: /// const DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT: Duration = Duration::from_secs(180); +/// Whole-submission budget for an in-core Bulletin preimage submit, covering +/// build + dry-run + broadcast + best-block inclusion. Passed explicitly to the +/// chain layer, which uses the call context only for cancellation. +const PREIMAGE_SUBMIT_BUDGET: Duration = Duration::from_secs(180); fn remote_authority_context(cx: &CallContext) -> CallContext { let mut cx = cx.clone(); @@ -247,11 +253,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()), @@ -259,9 +262,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, @@ -281,6 +306,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); @@ -891,24 +917,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(), })) } @@ -947,9 +971,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 }, } } @@ -964,9 +988,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 }, })) } @@ -981,6 +1005,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 } } @@ -1732,19 +1759,53 @@ 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 { - // TODO: preserve platform stream errors as terminal - // subscription interrupts once subscription items can carry - // in-stream failures. - item.ok().map(|value| { - RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { - value, - }) - }) + .lookup_preimage(key.clone()) + .filter_map(move |item| { + let key = key.clone(); + async move { + // TODO: preserve platform stream errors as terminal + // subscription interrupts once subscription items can carry + // in-stream failures. + let value = item.ok()?; + let value = value.filter(|value| { + let matches = preimage_key(value)[..] == key[..]; + if !matches { + tracing::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 }, + )) + } }); Subscription::new(Box::pin(stream)) } @@ -1757,12 +1818,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 { @@ -1779,46 +1837,72 @@ 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(cx); + + let authority_cx = remote_authority_context(cx); 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, PREIMAGE_SUBMIT_BUDGET, &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 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, PREIMAGE_SUBMIT_BUDGET, &allowance, &value) + .await + .map_err(|err| preimage_submit_error(err.reason()))? + } + Err(err) => return Err(preimage_submit_error(err.reason())), + }; + // 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 // --------------------------------------------------------------------------- @@ -1892,10 +1976,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; @@ -1950,6 +2031,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); @@ -2751,137 +2833,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]); @@ -2893,25 +2847,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]); @@ -2923,9 +2867,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() ); } @@ -2960,19 +2905,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..16b6959d 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -53,6 +53,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 +81,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 }, @@ -224,6 +239,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 +303,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..c19de06b --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs @@ -0,0 +1,603 @@ +//! 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 transaction validation +//! (broadcast is spec-guaranteed silent on invalid transactions, so the +//! dry-run is the only deterministic error signal), 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 futures::{FutureExt, pin_mut}; +use subxt::client::{Block, Blocks, OnlineClientAtBlockImpl}; +use subxt::config::substrate::SubstrateConfig; +use subxt::error::{DispatchError, ExtrinsicError, TransactionEventsError}; +use subxt::tx::{ + SubmittableTransaction, TransactionInBlock, TransactionInvalid, TransactionStatus, + TransactionUnknown, ValidationResult, +}; +use tracing::{instrument, warn}; +use truapi::CallContext; +use truapi_platform::BulletinAllowanceKey; + +use crate::chain_runtime::ChainRuntime; +use crate::host_logic::bulletin::{ + STORE_PALLET_NAME, allowance_signer, build_signed_store_transaction, preimage_key, +}; +use crate::host_logic::extrinsic::Sr25519Signer; + +/// 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. Wallet allocation can return before the freshly granted +/// Bulletin authorization is visible to the chain state used by dry-run. +const ALLOWANCE_DRY_RUN_PROPAGATION_BLOCKS: usize = 20; +/// Budget for the stream to produce the next best block used by a dry-run +/// retry. +const ALLOWANCE_DRY_RUN_BLOCK_TIMEOUT: Duration = Duration::from_secs(10); +/// 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. +const BEST_BLOCK_TIMEOUT: Duration = Duration::from_secs(2); + +/// `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 failed, for phase-tagged timeout reasons. +type Phase = &'static str; + +/// 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, Clone, PartialEq, Eq)] +pub(crate) enum BulletinSubmitError { + /// Connection, block stream, metadata, or nonce plumbing failed. + ChainUnavailable { reason: String }, + /// The dry-run rejected the transaction for a non-allowance reason. + InvalidTransaction { kind: String }, + /// The allowance account was rejected; refresh + one retry may help. + AllowanceRejected { phase: AllowanceRejectionPhase }, + /// The dry-run saw a nonce race (`Future`/`Stale`); no refresh. + NonceRace, + /// The submission budget elapsed; `phase` names the step reached. + Timeout { phase: Phase }, + /// The transaction was broadcast but inclusion could not be verified. + BroadcastUnverified { reason: String }, + /// The extrinsic landed but its dispatch failed for a non-allowance + /// reason. + IncludedButFailed { pallet: String, error: String }, + /// The calling context was cancelled. + Cancelled, +} + +/// Which stage rejected the allowance account. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AllowanceRejectionPhase { + DryRun, + Dispatch, +} + +impl BulletinSubmitError { + fn is_retryable_submission_uncertain(&self, phase: Phase) -> bool { + matches!(self, Self::Timeout { phase } if *phase == "watch") + || (phase == "watch" && matches!(self, Self::BroadcastUnverified { .. })) + } + + /// Structured reason string carried in the wire error. + pub(crate) fn reason(&self) -> String { + match self { + Self::ChainUnavailable { reason } => { + format!("bulletin chain unavailable: {reason}") + } + Self::InvalidTransaction { kind } => format!("invalid: {kind}"), + Self::AllowanceRejected { phase } => { + let phase = match phase { + AllowanceRejectionPhase::DryRun => "dry-run", + AllowanceRejectionPhase::Dispatch => "dispatch", + }; + format!("allowance rejected: {phase}") + } + Self::NonceRace => "nonce race: retry".to_string(), + Self::Timeout { phase } => format!("timeout: {phase}, inclusion unverified"), + Self::BroadcastUnverified { reason } => format!("inclusion unverified: {reason}"), + Self::IncludedButFailed { pallet, error } => { + format!("dispatch error: {pallet}.{error}") + } + Self::Cancelled => "cancelled".to_string(), + } + } +} + +/// 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("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, + budget: Duration, + 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 whole-submission budget is set by the Preimage::submit boundary + // and passed in explicitly; the context is used only for cancellation. + // The budget starts once the lock is held; dropping the flow on + // timeout/cancel drops its in-flight chain work. + let started = Instant::now(); + let mut attempt = 0; + loop { + attempt += 1; + let Some(remaining) = budget.checked_sub(started.elapsed()) 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.reason(), + "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("connect"); + let client = self + .chain + .online_client(&self.genesis_hash) + .await + .map_err(|failure| BulletinSubmitError::ChainUnavailable { + reason: failure.reason(), + })?; + let mut best_blocks = client.stream_best_blocks().await.map_err(|error| { + BulletinSubmitError::ChainUnavailable { + reason: format!("best-block stream unavailable: {error}"), + } + })?; + let head = initial_best_block(&mut best_blocks).await?; + + self.enter_phase("build"); + let signer = allowance_signer(allowance) + .map_err(|reason| BulletinSubmitError::InvalidTransaction { kind: reason })?; + let signed = self + .build_signed_and_dry_run(&mut best_blocks, head, &signer, value) + .await?; + drop(best_blocks); + + self.enter_phase("watch"); + let in_block = watch_until_included(&signed).await?; + + self.enter_phase("events"); + require_dispatch_success(&in_block).await?; + + Ok(key.to_vec()) + } + + /// Build, sign, and dry-run the extrinsic against the chosen best block. + /// Broadcast never reports invalid transactions, so dry-run is the only + /// deterministic signal for stale allowances, nonce races, and encoding + /// errors. + 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; + loop { + self.enter_phase("build"); + let at_block = + block + .at() + .await + .map_err(|error| BulletinSubmitError::ChainUnavailable { + reason: format!("block {} unavailable: {error}", block.number()), + })?; + let signed = build_signed_store_transaction(&at_block, signer, value) + .await + .map_err(map_store_transaction_build_error)?; + + self.enter_phase("dry-run"); + let validity = + signed + .validate() + .await + .map_err(|error| BulletinSubmitError::ChainUnavailable { + reason: format!("transaction dry-run unavailable: {error}"), + })?; + match Self::classify_dry_run_validity(validity)? { + DryRunStatus::Valid => return Ok(signed), + DryRunStatus::AllowanceRejected + if allowance_rejections < ALLOWANCE_DRY_RUN_PROPAGATION_BLOCKS => + { + allowance_rejections += 1; + warn!( + attempt = allowance_rejections, + "Bulletin allowance not visible to dry-run yet; rebuilding at next block" + ); + block = + next_best_block(best_blocks, ALLOWANCE_DRY_RUN_BLOCK_TIMEOUT, "dry-run") + .await?; + } + DryRunStatus::AllowanceRejected => { + return Err(BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::DryRun, + }); + } + } + } + } + + fn classify_dry_run_validity( + validity: ValidationResult, + ) -> Result { + match validity { + ValidationResult::Valid(_) => Ok(DryRunStatus::Valid), + ValidationResult::Invalid( + TransactionInvalid::Payment + | TransactionInvalid::Custom(_) + | TransactionInvalid::BadSigner, + ) => Ok(DryRunStatus::AllowanceRejected), + ValidationResult::Invalid(TransactionInvalid::Future | TransactionInvalid::Stale) => { + Err(BulletinSubmitError::NonceRace) + } + ValidationResult::Invalid(other) => Err(BulletinSubmitError::InvalidTransaction { + kind: format!("{other:?}"), + }), + ValidationResult::Unknown(TransactionUnknown::CannotLookup) => { + Err(BulletinSubmitError::ChainUnavailable { + reason: "transaction validity could not be looked up".to_string(), + }) + } + ValidationResult::Unknown(other) => Err(BulletinSubmitError::InvalidTransaction { + kind: format!("{other:?}"), + }), + } + } + + fn enter_phase(&self, phase: Phase) { + *self.phase.lock().expect("phase slot poisoned") = phase; + } + + fn current_phase(&self) -> Phase { + *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 before a quiet [`BEST_BLOCK_TIMEOUT`] window; +/// falling back to the finalized block is safe for cold starts. +async fn initial_best_block( + blocks: &mut Blocks, +) -> Result, BulletinSubmitError> { + let mut block = next_best_block(blocks, INITIALIZATION_TIMEOUT, "connect").await?; + loop { + match next_best_block(blocks, BEST_BLOCK_TIMEOUT, "connect").await { + Ok(newer) => block = newer, + Err(BulletinSubmitError::Timeout { .. }) => return Ok(block), + Err(other) => return Err(other), + } + } +} + +async fn next_best_block( + blocks: &mut Blocks, + timeout: Duration, + phase: Phase, +) -> 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::ChainUnavailable { + reason: format!("Bulletin best-block stream failed: {error}"), + }), + None => Err(BulletinSubmitError::ChainUnavailable { + reason: "Bulletin best-block stream ended".to_string(), + }), + }, + () = timeout => Err(BulletinSubmitError::Timeout { phase }), + } +} + +fn map_store_transaction_build_error(error: ExtrinsicError) -> BulletinSubmitError { + match error { + ExtrinsicError::AccountNonceError { reason, .. } => BulletinSubmitError::ChainUnavailable { + reason: format!("account nonce unavailable: {reason}"), + }, + other => BulletinSubmitError::InvalidTransaction { + kind: format!("store transaction assembly failed: {other}"), + }, + } +} + +/// 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 unverified = |reason: String| BulletinSubmitError::BroadcastUnverified { reason }; + let mut progress = signed + .submit_and_watch() + .await + .map_err(|error| unverified(format!("transaction submit failed: {error}")))?; + while let Some(status) = progress.next().await { + let status = + status.map_err(|error| unverified(format!("transaction watch failed: {error}")))?; + match status { + TransactionStatus::InBestBlock(block) | TransactionStatus::InFinalizedBlock(block) => { + return Ok(block); + } + TransactionStatus::Invalid { message } => { + return Err(unverified(format!( + "transaction invalid after successful dry-run: {message}" + ))); + } + TransactionStatus::Dropped { message } => { + return Err(unverified(format!("transaction dropped: {message}"))); + } + TransactionStatus::Error { message } => { + return Err(unverified(format!("transaction error: {message}"))); + } + TransactionStatus::Validated + | TransactionStatus::Broadcasted + | TransactionStatus::NoLongerInBestBlock => {} + } + } + Err(unverified( + "transaction watch stream ended before inclusion".to_string(), + )) +} + +/// 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> { + let unverified = |reason: String| BulletinSubmitError::BroadcastUnverified { reason }; + match in_block.wait_for_success().await { + Ok(events) => { + for event in events.iter() { + let event = + event.map_err(|err| unverified(format!("invalid transaction event: {err}")))?; + if event.pallet_name() == "System" && event.event_name() == "ExtrinsicSuccess" { + return Ok(()); + } + } + Err(unverified( + "included, but the block reported no dispatch outcome".to_string(), + )) + } + Err(TransactionEventsError::ExtrinsicFailed(error)) => Err(classify_dispatch_error(error)), + Err(other) => Err(unverified(format!( + "transaction events unavailable: {other}" + ))), + } +} + +/// 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::IncludedButFailed { + pallet: "unknown".to_string(), + error: error.to_string(), + }; + }; + match module_error.details() { + Ok(details) => { + let pallet = details.pallet.name().to_string(); + let error = details.variant.name.clone(); + if pallet == STORE_PALLET_NAME + && ALLOWANCE_REJECTED_MODULE_ERRORS.contains(&error.as_str()) + { + BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::Dispatch, + } + } else { + BulletinSubmitError::IncludedButFailed { pallet, error } + } + } + Err(_) => BulletinSubmitError::IncludedButFailed { + pallet: "unknown".to_string(), + error: module_error.details_string(), + }, + } +} + +#[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::BroadcastUnverified { + reason: "transaction invalid after successful dry-run".to_string(), + }; + assert!(unverified.is_retryable_submission_uncertain("watch")); + assert!(!unverified.is_retryable_submission_uncertain("events")); + + assert!( + BulletinSubmitError::Timeout { phase: "watch" } + .is_retryable_submission_uncertain("connect") + ); + assert!( + !BulletinSubmitError::Timeout { phase: "dry-run" } + .is_retryable_submission_uncertain("dry-run") + ); + } + + /// 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_eq!( + classify_dispatch_error(module_error("AuthorizationNotFound")), + BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::Dispatch + } + ); + + let other = non_allowance_error_name(); + assert_eq!( + classify_dispatch_error(module_error(&other)), + BulletinSubmitError::IncludedButFailed { + pallet: STORE_PALLET_NAME.to_string(), + error: other + } + ); + } + + #[test] + fn error_reason_strings_are_stable() { + assert_eq!( + BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::DryRun + } + .reason(), + "allowance rejected: dry-run" + ); + assert_eq!(BulletinSubmitError::NonceRace.reason(), "nonce race: retry"); + assert_eq!( + BulletinSubmitError::Timeout { phase: "watch" }.reason(), + "timeout: watch, inclusion unverified" + ); + assert_eq!( + BulletinSubmitError::IncludedButFailed { + pallet: "TransactionStorage".to_string(), + error: "BadContext".to_string() + } + .reason(), + "dispatch error: TransactionStorage.BadContext" + ); + } +} 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 63f1735d..195ea379 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::*; @@ -344,34 +344,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>); - Reflect::set(&object, &JsValue::from_str("sign"), sign.as_ref()) - .expect("setting sign on a new object should not fail"); - // The host callback owns the JS signer for the async tx submission. The - // object exposes no raw secret, only this Rust-backed signing capability. - sign.forget(); - - object.into() -} - fn decode_bytes(bytes: Vec, message: &str) -> Result { T::decode(&mut bytes.as_slice()).map_err(|_| message.to_string()) } @@ -442,6 +414,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") diff --git a/rust/crates/truapi/src/api/preimage.rs b/rust/crates/truapi/src/api/preimage.rs index 455d54df..5e818de3 100644 --- a/rust/crates/truapi/src/api/preimage.rs +++ b/rust/crates/truapi/src/api/preimage.rs @@ -15,7 +15,8 @@ pub trait Preimage: Send + Sync { /// import { firstValueFrom, from } from "rxjs"; /// /// // Submit a preimage first so the lookup resolves to a value. - /// const submitted = await truapi.preimage.submit("0xdeadbeef"); + /// const value = crypto.getRandomValues(new Uint8Array(4)).toHex() as `0x${string}`; + /// const submitted = await truapi.preimage.submit(value); /// assert(submitted.isOk(), "submit failed:", submitted); /// /// const item = await firstValueFrom( @@ -35,7 +36,8 @@ pub trait Preimage: Send + Sync { /// Submit a preimage. Returns the preimage key (hash) on success. /// /// ```ts - /// const result = await truapi.preimage.submit("0xdeadbeef"); + /// const value = crypto.getRandomValues(new Uint8Array(4)).toHex() as `0x${string}`; + /// const result = await truapi.preimage.submit(value); /// assert(result.isOk(), "submit failed:", result); /// console.log("preimage submitted:", result.value); /// ```