From 8d548ef69c9cfc581f3aa61d58caf8e2cebaed73 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 8 Jul 2026 18:47:03 +0200 Subject: [PATCH 1/9] feat(server): build, sign, and submit Bulletin preimages in the core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the entire Bulletin TransactionStorage.store submission into truapi-server. The core builds the extrinsic offline (subxt 0.50.2), signs it with the wallet-delegated allowance key, dry-runs it, broadcasts, and watches for inclusion over the existing chainHead runtime — replacing the host signer-callback seam so the allowance secret never crosses the host/FFI boundary. Core: - host_logic/extrinsic.rs: offline SubstrateConfig assembler with a config-pinned genesis hash, an sr25519 Signer, and metadata / transaction validity / events / header decoders. - host_logic/bulletin.rs: store{data} construction signed with the allowance key, with audited pallet/call-index pinning plus a canonical-bytes guard so provider metadata cannot redirect the signature, and a memcpy call-data encoder that avoids scale-encode's per-byte cost. - runtime/bulletin_rpc.rs: serialized submit flow (ephemeral with_runtime follow, metadata, nonce, validate_transaction dry-run, broadcast, single event-loop inclusion watch gated on nonce advance, System.Events dispatch check), typed error taxonomy, and broadcast stop on every exit. - runtime.rs: Preimage::submit gates on bulletin availability before any prompt and refreshes the allowance (Increase policy) with one retry on an allowance rejection; lookup_subscribe verifies blake2_256(value)==key and serves an in-core content-addressed cache. - BulletinAllowanceKey is zeroized on drop; PreimageHost keeps only lookup_preimage; both host configs gain an optional Bulletin genesis hash. Codegen/TS: regenerate goldens (product wire unchanged) and drop the signer bridge from the handwritten host worker. CI compiles the crate for wasm32-unknown-unknown; .gitignore ignores the renamed wasm bundle path. Bumps the hosts/dotli gitlink to the matching submodule commit. --- .github/workflows/ci.yml | 6 + .gitignore | 4 +- CLAUDE.md | 6 +- Cargo.lock | 427 +++++++++ hosts/dotli | 2 +- js/packages/truapi-host/src/runtime.ts | 8 + .../src/web/create-worker-host-runtime.ts | 75 +- .../truapi-host/src/worker-protocol.ts | 18 - js/packages/truapi-host/src/worker-runtime.ts | 78 -- .../truapi-codegen/src/rust/wasm_bridge.rs | 21 +- .../tests/golden/host-callbacks.ts | 15 +- .../tests/golden/wasm_bridge.rs | 21 +- .../tests/golden/worker-callbacks.ts | 11 - rust/crates/truapi-platform/Cargo.toml | 1 + rust/crates/truapi-platform/src/lib.rs | 124 ++- rust/crates/truapi-server/Cargo.toml | 5 + .../crates/truapi-server/src/chain_runtime.rs | 83 ++ rust/crates/truapi-server/src/core.rs | 1 + rust/crates/truapi-server/src/host_core.rs | 8 +- rust/crates/truapi-server/src/host_logic.rs | 3 +- .../src/host_logic/allowance_signer.rs | 92 -- .../truapi-server/src/host_logic/bulletin.rs | 611 +++++++++++++ .../truapi-server/src/host_logic/extrinsic.rs | 452 ++++++++++ .../host_logic/statement_store/statement.rs | 11 +- rust/crates/truapi-server/src/runtime.rs | 417 +++++---- .../truapi-server/src/runtime/allowances.rs | 18 + .../truapi-server/src/runtime/authority.rs | 12 + .../truapi-server/src/runtime/bulletin_rpc.rs | 830 ++++++++++++++++++ .../truapi-server/src/runtime/pairing_host.rs | 40 + .../src/runtime/pairing_host/sso_channel.rs | 52 ++ .../truapi-server/src/runtime/services.rs | 74 +- .../truapi-server/src/runtime/signing_host.rs | 13 + .../src/runtime/statement_store.rs | 2 +- rust/crates/truapi-server/src/test_support.rs | 33 +- rust/crates/truapi-server/src/wasm.rs | 49 +- .../src/wasm/generated_bridge.rs | 25 +- rust/crates/truapi-server/tests/common/mod.rs | 10 +- .../fixtures/bulletin_paseo_metadata.scale | Bin 0 -> 166563 bytes 38 files changed, 2973 insertions(+), 685 deletions(-) delete mode 100644 rust/crates/truapi-server/src/host_logic/allowance_signer.rs create mode 100644 rust/crates/truapi-server/src/host_logic/bulletin.rs create mode 100644 rust/crates/truapi-server/src/host_logic/extrinsic.rs create mode 100644 rust/crates/truapi-server/src/runtime/bulletin_rpc.rs create mode 100644 rust/crates/truapi-server/tests/fixtures/bulletin_paseo_metadata.scale 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..12604539 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]] @@ -3297,6 +3709,7 @@ dependencies = [ "parity-scale-codec", "pin-project", "primitive-types", + "scale-info", "schnorrkel", "send_wrapper 0.6.0", "serde", @@ -3304,6 +3717,7 @@ dependencies = [ "sha2 0.10.9", "sp-crypto-hashing", "substrate-bip39", + "subxt", "subxt-rpcs", "thiserror 1.0.69", "tracing", @@ -3328,6 +3742,7 @@ checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ "cfg-if", "digest 0.10.7", + "rand", "static_assertions", ] @@ -3392,6 +3807,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 +4401,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/hosts/dotli b/hosts/dotli index bcd3a424..762370c3 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit bcd3a424e5e40ed030adf79360d0fb714ae341da +Subproject commit 762370c3111ff2891b5f845435ce988005300fdf diff --git a/js/packages/truapi-host/src/runtime.ts b/js/packages/truapi-host/src/runtime.ts index d91872eb..748d4e10 100644 --- a/js/packages/truapi-host/src/runtime.ts +++ b/js/packages/truapi-host/src/runtime.ts @@ -68,6 +68,14 @@ export interface ProductRuntimeConfig { people: { genesisHash: string | Uint8Array; }; + /** + * Bulletin-chain genesis hash used for in-core preimage submission. Omit + * when the host does not expose the Bulletin chain; preimage submission then + * fails before prompting the user. + */ + 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/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/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index 3d476692..5ca2b9c1 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -20,6 +20,11 @@ pub fn generate_wasm_bridge( let trait_names = platform_trait_names(definition); validate_errors(&traits, &ctx)?; let mut out = String::new(); + let signer_import = if traits_use_callback_signer(&traits) { + "bulletin_allowance_signer_to_js, " + } else { + "" + }; writedoc!( out, r#" @@ -36,7 +41,7 @@ pub fn generate_wasm_bridge( use wasm_bindgen::JsValue; use super::{{ - WasmPlatform, bulletin_allowance_signer_to_js, call_js_function, decode_bytes, + WasmPlatform, {signer_import}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, @@ -856,6 +861,20 @@ fn is_callback_signer_type(ty: &TypeRef) -> bool { matches!(ty, TypeRef::Named { name, .. } if is_callback_signer_type_name(name)) } +/// Whether any composed trait method takes a callback signer parameter, which +/// determines whether the generated bridge references +/// `bulletin_allowance_signer_to_js`. +fn traits_use_callback_signer(traits: &[&PlatformTrait]) -> bool { + traits.iter().any(|trait_def| { + trait_def.methods.iter().any(|method| { + method + .params + .iter() + .any(|param| is_callback_signer_type(¶m.type_ref)) + }) + }) +} + fn is_callback_special_type_name(name: &str) -> bool { is_callback_byte_type_name(name) || is_callback_signer_type_name(name) } 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..fbdb1a0e 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,11 @@ 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. + /// + /// `None` when the host does not expose the Bulletin chain; preimage + /// submission then fails before prompting the user. + pub bulletin_chain_genesis_hash: Option<[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 +70,11 @@ 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. + /// + /// `None` when the host does not expose the Bulletin chain; preimage + /// submission then fails before prompting the user. + pub bulletin_chain_genesis_hash: Option<[u8; 32]>, } /// Product identity attached to one product-facing TrUAPI connection. @@ -149,10 +156,18 @@ impl PairingHostConfig { let config = Self { host: HostRuntimeConfig::new(host_info, platform_info)?, people_chain_genesis_hash, + bulletin_chain_genesis_hash: None, pairing_deeplink_scheme, }; Ok(config) } + + /// Set the Bulletin-chain genesis hash used for in-core preimage + /// submission. + pub fn with_bulletin_chain_genesis_hash(mut self, genesis_hash: [u8; 32]) -> Self { + self.bulletin_chain_genesis_hash = Some(genesis_hash); + self + } } impl SigningHostConfig { @@ -166,8 +181,16 @@ impl SigningHostConfig { Ok(Self { host: HostRuntimeConfig::new(host_info, platform_info)?, people_chain_genesis_hash, + bulletin_chain_genesis_hash: None, }) } + + /// Set the Bulletin-chain genesis hash used for in-core preimage + /// submission. + pub fn with_bulletin_chain_genesis_hash(mut self, genesis_hash: [u8; 32]) -> Self { + self.bulletin_chain_genesis_hash = Some(genesis_hash); + self + } } impl ProductContext { @@ -613,9 +636,11 @@ 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, zeroize::Zeroize, zeroize::ZeroizeOnDrop)] pub struct BulletinAllowanceKey { - #[debug("{:?}", "")] secret: [u8; 64], } @@ -630,14 +655,25 @@ 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 } +} + +impl PartialEq for BulletinAllowanceKey { + fn eq(&self, other: &Self) -> bool { + self.secret == other.secret + } +} - /// Consume the wrapper and return raw secret bytes. - pub fn into_secret_bytes(self) -> [u8; 64] { - self.secret +impl Eq for BulletinAllowanceKey {} + +impl core::fmt::Debug for BulletinAllowanceKey { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("BulletinAllowanceKey") + .field("secret", &"") + .finish() } } @@ -652,71 +688,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-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 4a2e9e72..a1b4500d 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -64,10 +64,12 @@ 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] 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" @@ -85,5 +87,8 @@ web-sys = { version = "0.3", features = [ "WebSocket", ] } +[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..0bd0cacf 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -1270,6 +1270,89 @@ pub(crate) async fn wait_for_chain_head_storage_value( } } +/// Wait for `Initialized` on a fresh `with_runtime: true` follow, returning +/// the newest finalized block hash and the finalized-block runtime spec. +pub(crate) async fn wait_for_chain_head_initialized( + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + label: &'static str, + timeout: Duration, +) -> Result<(Vec, RuntimeSpec), String> { + let timeout = futures_timer::Delay::new(timeout).fuse(); + pin_mut!(timeout); + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::Initialized { + finalized_block_hashes, + finalized_block_runtime, + }) => { + let hash = finalized_block_hashes + .last() + .cloned() + .ok_or_else(|| format!("{label} follow initialized without finalized blocks"))?; + let spec = match finalized_block_runtime { + Some(RuntimeType::Valid(spec)) => spec, + Some(RuntimeType::Invalid { error }) => { + return Err(format!("{label} follow reported an invalid runtime: {error}")); + } + None => { + return Err(format!("{label} follow initialized without runtime metadata")); + } + }; + return Ok((hash, spec)); + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return Err(format!("{label} follow stopped before initialization")); + } + _ => {} + }, + () = timeout => return Err(format!("{label} follow initialization timed out")), + } + } +} + +/// Wait for one runtime-call operation's output from a `chainHead_v1_follow` +/// stream. +pub(crate) async fn wait_for_chain_head_call_output( + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + operation_id: &str, + label: &'static str, + timeout: Duration, +) -> Result, String> { + let timeout = futures_timer::Delay::new(timeout).fuse(); + pin_mut!(timeout); + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::OperationCallDone { operation_id: item_operation_id, output }) + if item_operation_id == operation_id => + { + return Ok(output); + } + Some(RemoteChainHeadFollowItem::OperationInaccessible { operation_id: item_operation_id }) + if item_operation_id == operation_id => + { + return Err(format!("{label} runtime call was inaccessible")); + } + Some(RemoteChainHeadFollowItem::OperationError { operation_id: item_operation_id, error }) + if item_operation_id == operation_id => + { + return Err(error); + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return Err(format!("{label} follow stopped during runtime call")); + } + _ => {} + }, + () = timeout => return Err(format!("{label} runtime call timed out")), + } + } +} + #[cfg(test)] fn decode_hex(value: &str) -> Result, String> { hex::decode(value.strip_prefix("0x").unwrap_or(value)).map_err(|_| "invalid hex".to_string()) 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..b999f212 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); @@ -201,7 +202,12 @@ impl SigningHostRuntime { { let platform: Arc = platform; let services = - RuntimeServices::new(platform.clone(), config.people_chain_genesis_hash, spawner); + 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..12369636 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/bulletin.rs @@ -0,0 +1,611 @@ +//! 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::config::DefaultExtrinsicParamsBuilder; +use subxt::ext::scale_encode::{self, EncodeAsFields, FieldIter, TypeResolver}; +use subxt::ext::scale_type_resolver::{Primitive, visitor}; +use subxt::config::substrate::SubstrateConfig; +use subxt::tx::StaticPayload; +use subxt::utils::H256; +use truapi_platform::BulletinAllowanceKey; + +use crate::host_logic::extrinsic::{OfflineChainState, Sr25519Signer}; + +/// Audited pallet index of `TransactionStorage` on the Bulletin chain. A +/// legitimate runtime renumber must bump this reviewed constant; provider +/// metadata pointing the name elsewhere is rejected. +const TRANSACTION_STORAGE_PALLET_INDEX: u8 = 40; +/// Audited call index of `TransactionStorage.store`. +const STORE_CALL_INDEX: u8 = 0; +const STORE_PALLET_NAME: &str = "TransactionStorage"; +const STORE_CALL_NAME: &str = "store"; + +/// Mortality window for store transactions. Must stay <= 4096 so the era +/// phase quantization is a no-op and the anchor block is the era birth block +/// (larger periods make the encoded anchor hash wrong and the signature +/// fails as BadProof). +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) +} + +/// Storage key of the plain `System.Events` value. +pub(crate) fn system_events_storage_key() -> Vec { + let mut key = Vec::with_capacity(32); + key.extend_from_slice(&sp_crypto_hashing::twox_128(b"System")); + key.extend_from_slice(&sp_crypto_hashing::twox_128(b"Events")); + key +} + +/// Finalized block the transaction's mortality is anchored at. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MortalityAnchor { + /// Anchor block number (era birth block). + pub(crate) number: u64, + /// Anchor block hash (the `CheckMortality` implicit). + pub(crate) hash: [u8; 32], +} + +/// A signed `TransactionStorage.store` extrinsic ready to broadcast. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SignedStoreExtrinsic { + /// Full length-prefixed extrinsic bytes. + pub(crate) extrinsic: Vec, + /// blake2b-256 of the extrinsic bytes, used for inclusion matching. + pub(crate) extrinsic_hash: [u8; 32], + /// Public key of the allowance account that signed. + pub(crate) account: [u8; 32], +} + +/// Build and sign a `TransactionStorage.store { data }` extrinsic with the +/// Bulletin allowance key. +pub(crate) fn build_signed_store_extrinsic( + state: &OfflineChainState, + anchor: &MortalityAnchor, + allowance: &BulletinAllowanceKey, + nonce: u64, + data: Vec, +) -> Result { + if !state.metadata.extrinsic().supported_versions().contains(&4) { + return Err(format!( + "bulletin runtime no longer supports v4 extrinsics (supported: {:?})", + state.metadata.extrinsic().supported_versions() + )); + } + require_pinned_store_call(state)?; + + let signer = allowance_signer(allowance)?; + let account = signer.public_key(); + let client = state.client_at(anchor.number)?; + + let canonical_call = canonical_store_call(&data); + let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(data)); + let call_data = client + .tx() + .call_data(&payload) + .map_err(|err| format!("store call encoding failed: {err}"))?; + if call_data != canonical_call { + return Err( + "metadata-encoded store call diverges from the canonical encoding".to_string(), + ); + } + drop(canonical_call); + + let params = DefaultExtrinsicParamsBuilder::::new() + .nonce(nonce) + .mortal_from_unchecked(MORTAL_PERIOD_BLOCKS, anchor.number, H256(anchor.hash)) + .build(); + let extrinsic = 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}"))? + .into_encoded(); + + let extrinsic_hash = sp_crypto_hashing::blake2_256(&extrinsic); + Ok(SignedStoreExtrinsic { + extrinsic, + extrinsic_hash, + account, + }) +} + +/// The only [`BulletinAllowanceKey`] -> signer conversion in the crate. The +/// returned signer is a transient per-call value; callers must not store it. +fn allowance_signer(allowance: &BulletinAllowanceKey) -> Result { + Sr25519Signer::from_secret_bytes(allowance.as_secret_bytes()) + .map_err(|reason| format!("invalid bulletin allowance key: {reason}")) +} + +/// Hard-assert that name resolution of `TransactionStorage.store` still lands +/// on the audited pallet/call indices, so provider metadata cannot redirect +/// the allowance-key signature to a different call. +fn require_pinned_store_call(state: &OfflineChainState) -> Result<(), String> { + let pallet = state + .metadata + .pallet_by_name(STORE_PALLET_NAME) + .ok_or_else(|| "bulletin metadata has no TransactionStorage pallet".to_string())?; + if pallet.call_index() != TRANSACTION_STORAGE_PALLET_INDEX { + return Err(format!( + "TransactionStorage pallet index {} does not match the audited index {}", + pallet.call_index(), + TRANSACTION_STORAGE_PALLET_INDEX + )); + } + let call = pallet + .call_variant_by_name(STORE_CALL_NAME) + .ok_or_else(|| "bulletin metadata has no TransactionStorage.store call".to_string())?; + if call.index != STORE_CALL_INDEX { + return Err(format!( + "TransactionStorage.store call index {} does not match the audited index {}", + call.index, STORE_CALL_INDEX + )); + } + Ok(()) +} + +/// The canonical `store` call bytes: audited indices followed by the +/// SCALE-encoded preimage (`Compact(len) ++ bytes`). Independent of any +/// provider-supplied metadata. +fn canonical_store_call(data: &[u8]) -> Vec { + let mut call = Vec::with_capacity(2 + 5 + data.len()); + call.push(TRANSACTION_STORAGE_PALLET_INDEX); + call.push(STORE_CALL_INDEX); + Compact(data.len() as u32).encode_to(&mut call); + call.extend_from_slice(data); + call +} + +/// `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(Vec); + +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::bulletin_chain_state; + use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; + use parity_scale_codec::{Compact, Decode}; + use schnorrkel::{PublicKey, Signature}; + use subxt::ext::frame_metadata::{RuntimeMetadata, RuntimeMetadataPrefixed, v14}; + use subxt::metadata::{ArcMetadata, Metadata}; + + fn allowance_fixture() -> BulletinAllowanceKey { + let secret = hex::decode( + "0eef5183411d40c32446bb1cbaabd70004a17af6012a577c735d054f04059208\ + 573dfc9b6ffeb1c786a16349e70f9836876a743c31c0a7a2a70727a852eec372", + ) + .unwrap(); + BulletinAllowanceKey::from_secret_bytes(secret).unwrap() + } + + fn anchor_fixture() -> MortalityAnchor { + MortalityAnchor { + number: 4200, + hash: [0xaa; 32], + } + } + + /// 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() + } + + /// Split a length-prefixed v4 signed extrinsic into (account, signature, + /// trailing bytes after the signature). + fn split_v4_signed(extrinsic: &[u8]) -> ([u8; 32], [u8; 64], Vec) { + let mut input = extrinsic; + let length = Compact::::decode(&mut input).unwrap().0 as usize; + assert_eq!(input.len(), length); + assert_eq!(input[0], 0x84, "expected a v4 signed extrinsic"); + assert_eq!(input[1], 0x00, "expected a MultiAddress::Id address"); + let account: [u8; 32] = input[2..34].try_into().unwrap(); + assert_eq!(input[34], 0x01, "expected a MultiSignature::Sr25519"); + let signature: [u8; 64] = input[35..99].try_into().unwrap(); + (account, signature, input[99..].to_vec()) + } + + #[test] + fn preimage_key_is_blake2b_256() { + assert_eq!( + hex::encode(preimage_key(b"")), + "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8" + ); + } + + #[test] + fn system_events_storage_key_is_pinned() { + assert_eq!( + hex::encode(system_events_storage_key()), + "26aa394eea5630e07c48ae0c9558cef780d41e5e16056765bc8461851072c9d7" + ); + } + + #[test] + fn builds_and_signs_store_extrinsic_against_fixture() { + let state = bulletin_chain_state(); + let data = b"hello bulletin".to_vec(); + let signed = build_signed_store_extrinsic( + &state, + &anchor_fixture(), + &allowance_fixture(), + 7, + data.clone(), + ) + .unwrap(); + + assert_eq!( + signed.extrinsic_hash, + sp_crypto_hashing::blake2_256(&signed.extrinsic) + ); + let (account, signature, tail) = split_v4_signed(&signed.extrinsic); + assert_eq!(account, signed.account); + assert!(tail.ends_with(&canonical_store_call(&data))); + + // The signature must verify over the reconstructed signer payload. + let client = state.client_at(anchor_fixture().number).unwrap(); + 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 signed = build_signed_store_extrinsic( + &bulletin_chain_state(), + &anchor_fixture(), + &allowance_fixture(), + 0, + data.clone(), + ) + .unwrap(); + let (account, signature, _) = split_v4_signed(&signed.extrinsic); + + 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_relocated_store_call() { + let mut metadata = bulletin_metadata_v14(); + for pallet in &mut metadata.pallets { + if pallet.name == "TransactionStorage" { + pallet.index = 41; + } + } + let state = state_with_metadata(metadata_from_v14(metadata)); + + let error = build_signed_store_extrinsic( + &state, + &anchor_fixture(), + &allowance_fixture(), + 0, + vec![1, 2, 3], + ) + .unwrap_err(); + assert!(error.contains("does not match the audited index"), "{error}"); + } + + #[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 error = build_signed_store_extrinsic( + &state, + &anchor_fixture(), + &allowance_fixture(), + 0, + vec![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 error = build_signed_store_extrinsic( + &state, + &anchor_fixture(), + &allowance_fixture(), + 0, + vec![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 error = build_signed_store_extrinsic( + &state, + &anchor_fixture(), + &allowance_fixture(), + 0, + vec![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 = build_signed_store_extrinsic( + &bulletin_chain_state(), + &anchor_fixture(), + &allowance_fixture(), + 0, + vec![1], + ) + .unwrap(); + let with_fake = build_signed_store_extrinsic( + &state, + &anchor_fixture(), + &allowance_fixture(), + 0, + vec![1], + ) + .unwrap(); + assert_eq!( + with_fake.extrinsic.len(), + baseline.extrinsic.len() + 1, + "the Option-typed extra should contribute exactly one None byte" + ); + } + + #[test] + fn canonical_call_matches_metadata_encoding() { + let state = bulletin_chain_state(); + let client = state.client_at(1).unwrap(); + let data = vec![9u8; 300]; + let payload = StaticPayload::new( + STORE_PALLET_NAME, + STORE_CALL_NAME, + StoreCallData(data.clone()), + ); + assert_eq!( + client.tx().call_data(&payload).unwrap(), + canonical_store_call(&data) + ); + } + + #[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 start = std::time::Instant::now(); + let signed = build_signed_store_extrinsic( + &bulletin_chain_state(), + &anchor_fixture(), + &allowance_fixture(), + 0, + data.clone(), + ) + .unwrap(); + let elapsed = start.elapsed(); + assert!(signed.extrinsic.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 = build_signed_store_extrinsic( + &bulletin_chain_state(), + &anchor_fixture(), + &BulletinAllowanceKey::from_secret_bytes(vec![0xff; 64]).unwrap(), + 0, + vec![1], + ) + .unwrap_err(); + assert!(error.contains("invalid bulletin allowance key"), "{error}"); + } +} 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..38dabdc2 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/extrinsic.rs @@ -0,0 +1,452 @@ +//! Offline, metadata-driven extrinsic construction shared by chain-facing +//! runtime services. +//! +//! Built on subxt's offline client: the caller supplies fetched runtime +//! metadata, spec/transaction versions, and an explicit nonce/mortality +//! anchor; nothing here performs I/O. Bulletin preimage submission is the +//! first consumer; local `create_transaction` assembly is the planned second. + +use parity_scale_codec::{Compact, Decode}; +use schnorrkel::{PublicKey, SecretKey}; +use subxt::client::{OfflineClient, OfflineClientAtBlock}; +use subxt::config::substrate::{ + SpecVersionForRange, SubstrateConfig, SubstrateConfigBuilder, SubstrateHeader, +}; +use subxt::error::DispatchError; +use subxt::ext::frame_metadata::RuntimeMetadataPrefixed; +use subxt::metadata::{ArcMetadata, Metadata}; +use subxt::tx::Signer; +use subxt::tx::{TransactionInvalid, TransactionUnknown, TransactionValid}; +use subxt::utils::{AccountId32, H256, MultiSignature}; + +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}") + }), + } +} + +/// Derive the sr25519 public key for a 64-byte allowance secret. +pub(crate) fn public_key_from_secret_bytes(secret: &[u8; 64]) -> Result<[u8; 32], String> { + Ok(sr25519_secret_from_bytes(secret)?.to_public().to_bytes()) +} + +/// sr25519 [`Signer`] over a parsed schnorrkel key. +/// +/// Holds only the parsed key (schnorrkel zeroizes it on drop), never the raw +/// secret bytes. Deliberately no `Debug` derive: schnorrkel's `Debug` prints +/// raw scalar material. +pub(crate) struct Sr25519Signer { + secret: SecretKey, + 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 }) + } + + /// Public key of the signing account. + pub(crate) fn public_key(&self) -> [u8; 32] { + self.public.to_bytes() + } +} + +impl core::fmt::Debug for Sr25519Signer { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("Sr25519Signer") + .field("public", &hex::encode(self.public_key())) + .field("secret", &"") + .finish() + } +} + +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()) + } +} + +/// Everything needed to build transactions offline for one chain at one +/// runtime version. +/// +/// `genesis_hash` MUST come from host configuration, never from the chain +/// provider: it binds signatures to the configured chain, which is what makes +/// every other provider-supplied input here fail-closed (a lie yields a +/// signature the real chain rejects, never one valid elsewhere). +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}")) + } +} + +/// Decode the output of the `Metadata_metadata_at_version` runtime call: +/// `Option` where the opaque wrapper is a compact-length +/// prefixed `RuntimeMetadataPrefixed`. +pub(crate) fn decode_runtime_metadata(bytes: &[u8]) -> Result { + let (_, prefixed) = , RuntimeMetadataPrefixed)>>::decode(&mut &bytes[..]) + .map_err(|err| format!("invalid Metadata_metadata_at_version response: {err}"))? + .ok_or_else(|| "runtime returned no metadata for the requested version".to_string())?; + Metadata::try_from(prefixed).map_err(|err| format!("unsupported runtime metadata: {err}")) +} + +/// Decode the supported metadata versions from `Metadata_metadata_versions`, +/// returning the highest version this crate can consume (v14-v16), skipping +/// the unstable `u32::MAX` marker. +pub(crate) fn best_supported_metadata_version(bytes: &[u8]) -> Result { + let versions = >::decode(&mut &bytes[..]) + .map_err(|err| format!("invalid Metadata_metadata_versions response: {err}"))?; + versions + .into_iter() + .filter(|version| (14..=16).contains(version)) + .max() + .ok_or_else(|| "runtime advertises no supported metadata version (14-16)".to_string()) +} + +/// Decode a SCALE block header and return its block number. +pub(crate) fn decode_header_block_number(header: &[u8]) -> Result { + let header = SubstrateHeader::::decode(&mut &header[..]) + .map_err(|err| format!("invalid block header: {err}"))?; + Ok(header.number) +} + +/// Decoded `TaggedTransactionQueue_validate_transaction` output. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum TransactionValidity { + Valid(TransactionValid), + Invalid(TransactionInvalid), + Unknown(TransactionUnknown), +} + +/// SCALE-encode the `TaggedTransactionQueue_validate_transaction` parameters: +/// `TransactionSource::External` ++ opaque extrinsic ++ 32-byte block hash. +pub(crate) fn validate_transaction_call_parameters( + extrinsic: &[u8], + block_hash: &[u8], +) -> Vec { + /// `sp_runtime::transaction_validity::TransactionSource::External`, the + /// source the pool assigns to transactions arriving over the network, + /// matching how a broadcast transaction is later validated. + const TRANSACTION_SOURCE_EXTERNAL: u8 = 2; + let mut parameters = Vec::with_capacity(1 + extrinsic.len() + block_hash.len()); + parameters.push(TRANSACTION_SOURCE_EXTERNAL); + parameters.extend_from_slice(extrinsic); + parameters.extend_from_slice(block_hash); + parameters +} + +/// Decode `TransactionValidity = Result` from a validate-transaction runtime call. +/// +/// Only the outer discriminants are hand-decoded; payloads reuse subxt's +/// public transaction-validity types so variant indices stay canonical. +pub(crate) fn decode_transaction_validity(bytes: &[u8]) -> Result { + let map_err = |err: parity_scale_codec::Error| format!("invalid TransactionValidity: {err}"); + match (bytes.first(), bytes.get(1)) { + (Some(0), _) => TransactionValid::decode(&mut &bytes[1..]) + .map(TransactionValidity::Valid) + .map_err(map_err), + (Some(1), Some(0)) => TransactionInvalid::decode(&mut &bytes[2..]) + .map(TransactionValidity::Invalid) + .map_err(map_err), + (Some(1), Some(1)) => TransactionUnknown::decode(&mut &bytes[2..]) + .map(TransactionValidity::Unknown) + .map_err(map_err), + _ => Err(format!( + "invalid TransactionValidity discriminant: 0x{}", + hex::encode(bytes.iter().take(2).copied().collect::>()) + )), + } +} + +/// Dispatch error decoded from a `System.ExtrinsicFailed` event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DecodedDispatchError { + /// A pallet module error, resolved to pallet + error variant names. + Module { pallet: String, error: String }, + /// Any other dispatch error, rendered as text. + Other(String), +} + +impl core::fmt::Display for DecodedDispatchError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Module { pallet, error } => write!(f, "{pallet}.{error}"), + Self::Other(reason) => write!(f, "{reason}"), + } + } +} + +/// Outcome of one extrinsic according to a block's `System.Events`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ExtrinsicOutcome { + /// `System.ExtrinsicSuccess` was emitted for the extrinsic index. + Success, + /// `System.ExtrinsicFailed` was emitted for the extrinsic index. + Failed(DecodedDispatchError), + /// The events decoded but carried no outcome for the extrinsic index. + NotFound, +} + +/// Read the dispatch outcome for `extrinsic_index` out of raw `System.Events` +/// storage bytes. +pub(crate) fn extrinsic_outcome_from_events( + client: &OfflineClientAtBlock, + events_bytes: Vec, + extrinsic_index: u32, +) -> Result { + use subxt::events::Phase; + + let events = client.events().from_bytes(events_bytes); + for event in events.iter() { + let event = event.map_err(|err| format!("invalid System.Events entry: {err}"))?; + if event.phase() != Phase::ApplyExtrinsic(extrinsic_index) + || event.pallet_name() != "System" + { + continue; + } + match event.event_name() { + "ExtrinsicSuccess" => return Ok(ExtrinsicOutcome::Success), + "ExtrinsicFailed" => { + let error = decode_dispatch_error(event.field_bytes(), client.metadata()); + return Ok(ExtrinsicOutcome::Failed(error)); + } + _ => {} + } + } + Ok(ExtrinsicOutcome::NotFound) +} + +/// Decode the leading `DispatchError` out of an `ExtrinsicFailed` event's +/// field bytes (the trailing `DispatchInfo` is ignored by the decoder). +fn decode_dispatch_error(field_bytes: &[u8], metadata: ArcMetadata) -> DecodedDispatchError { + match DispatchError::decode_from(field_bytes, metadata) { + Ok(DispatchError::Module(module_error)) => match module_error.details() { + Ok(details) => DecodedDispatchError::Module { + pallet: details.pallet.name().to_string(), + error: details.variant.name.clone(), + }, + Err(_) => DecodedDispatchError::Other(module_error.details_string()), + }, + Ok(other) => DecodedDispatchError::Other(other.to_string()), + Err(err) => DecodedDispatchError::Other(format!("undecodable dispatch error: {err}")), + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use parity_scale_codec::Encode; + + /// 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.public_key()).unwrap(); + assert!( + public + .verify_simple( + SR25519_SIGNING_CONTEXT, + payload, + &schnorrkel::Signature::from_bytes(&signature).unwrap() + ) + .is_ok() + ); + } + + #[test] + fn decodes_fixture_metadata_via_runtime_call_wrapper() { + // Re-wrap the raw fixture the way `Metadata_metadata_at_version` + // returns it: Option<(compact wrapper length, prefixed metadata)>. + let wrapped = Some(BULLETIN_METADATA_BYTES.to_vec()).encode(); + let metadata = decode_runtime_metadata(&wrapped).unwrap(); + assert_eq!( + metadata + .pallet_by_name("TransactionStorage") + .unwrap() + .call_index(), + 40 + ); + } + + #[test] + fn rejects_missing_metadata_version() { + let wrapped = Option::>::None.encode(); + assert!(decode_runtime_metadata(&wrapped).is_err()); + } + + #[test] + fn picks_best_supported_metadata_version() { + let versions = vec![13u32, 14, 15, u32::MAX].encode(); + assert_eq!(best_supported_metadata_version(&versions).unwrap(), 15); + let unsupported = vec![13u32, u32::MAX].encode(); + assert!(best_supported_metadata_version(&unsupported).is_err()); + } + + #[test] + fn decodes_header_block_number() { + let header = SubstrateHeader:: { + parent_hash: H256([1; 32]), + number: 1_234_567, + state_root: H256([2; 32]), + extrinsics_root: H256([3; 32]), + digest: Default::default(), + }; + assert_eq!( + decode_header_block_number(&header.encode()).unwrap(), + 1_234_567 + ); + assert!(decode_header_block_number(&[0x00, 0x01]).is_err()); + } + + #[test] + fn decodes_transaction_validity_variants() { + // The subxt validity types are Decode-only; build the SCALE bytes by + // hand from the sp-runtime layout. + let mut bytes = vec![0u8]; + bytes.extend( + ( + 5u64, + Vec::>::new(), + vec![vec![1u8, 2]], + 32u64, + true, + ) + .encode(), + ); + assert_eq!( + decode_transaction_validity(&bytes).unwrap(), + TransactionValidity::Valid(TransactionValid { + priority: 5, + requires: vec![], + provides: vec![vec![1, 2]], + longevity: 32, + propagate: true, + }) + ); + + // Invalid::Payment is variant index 1. + assert_eq!( + decode_transaction_validity(&[1, 0, 1]).unwrap(), + TransactionValidity::Invalid(TransactionInvalid::Payment) + ); + + // Unknown::CannotLookup is variant index 0. + assert_eq!( + decode_transaction_validity(&[1, 1, 0]).unwrap(), + TransactionValidity::Unknown(TransactionUnknown::CannotLookup) + ); + + assert!(decode_transaction_validity(&[9]).is_err()); + } + + #[test] + fn validate_transaction_parameters_use_external_source() { + let parameters = validate_transaction_call_parameters(&[0xaa, 0xbb], &[0xcc; 32]); + assert_eq!(parameters[0], 2); + assert_eq!(¶meters[1..3], &[0xaa, 0xbb]); + assert_eq!(¶meters[3..], &[0xcc; 32]); + } +} 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..b1a742c7 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 @@ -3,6 +3,7 @@ use schnorrkel::{PublicKey, SecretKey, 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; @@ -203,13 +204,9 @@ pub fn statement_public_key_from_secret(ss_secret: [u8; 64]) -> Result<[u8; 32], 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}") - }), - } + // Legacy JS signers may send scure/ed25519-style scalar bytes instead; + // the shared parser keeps the fallback. + sr25519_secret_from_bytes(&ss_secret).map_err(|reason| format!("invalid ss_secret: {reason}")) } /// Build the statement proof payload for unsigned fields. diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 7004a228..4684dedf 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,8 +25,9 @@ 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::runtime::bulletin_rpc::BulletinSubmitError; use crate::host_logic::features::feature_supported; use crate::host_logic::permissions::PermissionsService; use crate::host_logic::product_account::{ @@ -247,11 +249,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()), @@ -261,7 +260,30 @@ impl ProductRuntimeHost { [0; 32], "polkadotapp".to_string(), ) - .expect("compat runtime config is valid"); + .expect("compat runtime config is valid") + } + + /// Compat host with a Bulletin genesis configured, so preimage submission + /// reaches the permission/confirmation gates instead of the pre-prompt + /// unconfigured check. + #[cfg(test)] + fn new_compat_with_bulletin(platform: Arc, spawner: Spawner) -> Self { + Self::new_pairing_for_tests( + platform, + Self::compat_host_config().with_bulletin_chain_genesis_hash([0xbb; 32]), + 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 +303,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); @@ -1732,19 +1755,43 @@ 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| preimage_key(value)[..] == key[..]); + Some(RemotePreimageLookupSubscribeItem::V1( + v01::RemotePreimageLookupSubscribeItem { value }, + )) + } }); Subscription::new(Box::pin(stream)) } @@ -1757,11 +1804,14 @@ 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())); + }; + // Fail before any user-facing prompt when the host cannot submit + // bulletin transactions at all (no bulletin genesis configured). + let Some(bulletin) = self.services.bulletin.as_ref() else { + return Err(preimage_submit_error( + "bulletin chain unavailable on this host".to_string(), + )); }; self.require_remote_permission( v01::RemotePermission::PreimageSubmit, @@ -1779,46 +1829,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) - .await - .map(RemotePreimageSubmitResponse::V1) - .map_err(|err| CallError::Domain(RemotePreimageSubmitError::V1(err))) + .map_err(|err| preimage_submit_error(err.reason()))?; + + match bulletin.submit_preimage(cx, &allowance, value.clone()).await { + Ok(key) => { + self.prime_preimage_cache(&value, &key); + Ok(RemotePreimageSubmitResponse::V1(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()))?; + match bulletin.submit_preimage(cx, &allowance, value.clone()).await { + Ok(key) => { + self.prime_preimage_cache(&value, &key); + Ok(RemotePreimageSubmitResponse::V1(key)) + } + Err(err) => Err(preimage_submit_error(err.reason())), + } + } + Err(err) => Err(preimage_submit_error(err.reason())), + } } } +impl ProductRuntimeHost { + /// Prime the in-core lookup cache with a just-submitted preimage so an + /// immediate product lookup hits before the content backend has it. + fn prime_preimage_cache(&self, value: &[u8], key: &[u8]) { + 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.to_vec()); + } + } +} + +/// 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 +1968,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 +2023,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 +2825,33 @@ 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()); + 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]); - 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:?}"), + + let err = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap_err(); + + match err { + CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { reason }, + )) => assert_eq!(reason, "No active session"), + other => panic!("expected preimage session error, 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())); + fn preimage_submit_unconfigured_bulletin_fails_before_prompts() { + // Default compat host has no bulletin genesis, so submission must fail + // before any permission prompt or user confirmation, and before any + // chain traffic. let platform = Arc::new(StubPlatform { - preimage_submit_allowance_public_keys: preimage_submit_allowance_public_keys.clone(), - preimage_submit_signatures: preimage_submit_signatures.clone(), + remote_permission_denied: true, ..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(), - ); + host.test_session_state().set_session(session_info()); let cx = CallContext::new(); let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); @@ -2890,28 +2860,27 @@ mod tests { match err { CallError::Domain(RemotePreimageSubmitError::V1( v01::PreimageSubmitError::Unknown { reason }, - )) => assert_eq!(reason, "No active session"), - other => panic!("expected preimage session error, got {other:?}"), + )) => assert_eq!(reason, "bulletin chain unavailable on this host"), + other => panic!("expected bulletin-unavailable error, got {other:?}"), } + // The pre-prompt gate wins over the (denied) permission check, and no + // chain request was sent. assert!( - preimage_submits + platform + .sent_rpc .lock() - .expect("preimage submit list mutex poisoned") + .expect("rpc 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 +2892,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 +2930,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..54126aca 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -282,6 +282,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..8c6f6660 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs @@ -0,0 +1,830 @@ +//! In-core Bulletin preimage submission over the shared chainHead runtime. +//! +//! One submission at a time: build + sign the `TransactionStorage.store` +//! extrinsic offline, dry-run it via `TaggedTransactionQueue_validate_transaction` +//! (broadcast is spec-guaranteed silent on invalid transactions, so the +//! dry-run is the only deterministic error signal), broadcast, then watch +//! best blocks for inclusion and read the dispatch outcome from +//! `System.Events`. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Mutex as StdMutex; +use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; + +use futures::stream::BoxStream; +use futures::{FutureExt, StreamExt, pin_mut}; +use parity_scale_codec::{Decode, Encode}; +use subxt::metadata::ArcMetadata; +use subxt::tx::{TransactionInvalid, TransactionUnknown}; +use tracing::{instrument, warn}; +use truapi::CallContext; +use truapi::v01::{ + OperationStartedResult, RemoteChainHeadBodyRequest, RemoteChainHeadCallRequest, + RemoteChainHeadFollowItem, RemoteChainHeadFollowRequest, RemoteChainHeadHeaderRequest, + RemoteChainHeadStorageRequest, RemoteChainHeadUnpinRequest, + RemoteChainTransactionBroadcastRequest, RemoteChainTransactionStopRequest, RuntimeSpec, + StorageQueryItem, StorageQueryType, +}; +use truapi_platform::BulletinAllowanceKey; + +use crate::chain_runtime::{ + ChainRuntime, wait_for_chain_head_call_output, wait_for_chain_head_initialized, + wait_for_chain_head_storage_value, +}; +use crate::host_logic::bulletin::{ + MortalityAnchor, build_signed_store_extrinsic, preimage_key, system_events_storage_key, +}; +use crate::host_logic::extrinsic::{ + DecodedDispatchError, ExtrinsicOutcome, OfflineChainState, TransactionValidity, + best_supported_metadata_version, decode_header_block_number, decode_runtime_metadata, + decode_transaction_validity, extrinsic_outcome_from_events, + validate_transaction_call_parameters, +}; + +/// Whole-submission budget, matching the host-side timeout this flow replaces. +const SUBMIT_TIMEOUT: Duration = Duration::from_secs(120); +/// Budget for the follow to deliver `Initialized`. +const INITIALIZATION_TIMEOUT: Duration = Duration::from_secs(10); +/// Budget for one pre-broadcast runtime call or storage read. +const OPERATION_TIMEOUT: Duration = Duration::from_secs(20); +/// Delay before retrying an operation start that hit `LimitReached`, and +/// before retrying an inaccessible `System.Events` read. +const OPERATION_RETRY_DELAY: Duration = Duration::from_millis(300); +/// Attempts for reading `System.Events` at the inclusion block. +const EVENTS_READ_ATTEMPTS: usize = 3; + +/// Monotonic salt for per-submit bulletin follow ids. +static BULLETIN_FOLLOW_COUNTER: AtomicU64 = AtomicU64::new(1); + +/// `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; + +/// 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, follow, metadata, nonce, or anchor 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 server had no free broadcast slot; retryable, nothing was sent. + BroadcastSlotUnavailable, + /// The 120 s 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 { + /// 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::BroadcastSlotUnavailable => "broadcast slot unavailable: 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: one broadcast + one ephemeral follow at a + /// time, and no same-account nonce races between concurrent submits. + submit_lock: futures::lock::Mutex<()>, + /// Decoded metadata for the current spec version. + metadata_cache: StdMutex>, + /// Live broadcast operation id, stopped on every exit path. + active_broadcast: StdMutex>, + /// 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(()), + metadata_cache: StdMutex::new(None), + active_broadcast: StdMutex::new(None), + 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, + allowance: &BulletinAllowanceKey, + value: Vec, + ) -> 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 budget starts once the lock is held; dropping the flow on + // timeout/cancel drops its follow (releasing pins), and the explicit + // stop below covers any live broadcast on every exit path. + let flow = self.submit_flow(allowance, value).fuse(); + let timeout = futures_timer::Delay::new(SUBMIT_TIMEOUT).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), + }; + self.stop_active_broadcast().await; + result + } + + async fn submit_flow( + &self, + allowance: &BulletinAllowanceKey, + value: Vec, + ) -> Result, BulletinSubmitError> { + let key = preimage_key(&value); + + self.enter_phase("connect"); + let follow_id = format!( + "truapi:bulletin:{}", + BULLETIN_FOLLOW_COUNTER.fetch_add(1, Ordering::Relaxed) + ); + let mut follow = self.chain.remote_chain_head_follow( + follow_id.clone(), + RemoteChainHeadFollowRequest { + genesis_hash: self.genesis_hash.to_vec(), + with_runtime: true, + }, + ); + let (finalized_hash, runtime_spec) = + wait_for_chain_head_initialized(&mut follow, "Bulletin", INITIALIZATION_TIMEOUT) + .await + .map_err(|reason| BulletinSubmitError::ChainUnavailable { reason })?; + + self.enter_phase("metadata"); + let state = self + .offline_chain_state(&follow_id, &mut follow, &finalized_hash, runtime_spec) + .await?; + + self.enter_phase("build"); + let anchor = self.mortality_anchor(&follow_id, &finalized_hash).await?; + let nonce = self + .account_nonce(&follow_id, &mut follow, &finalized_hash, allowance) + .await?; + let signed = build_signed_store_extrinsic(&state, &anchor, allowance, nonce, value) + .map_err(|reason| BulletinSubmitError::InvalidTransaction { kind: reason })?; + + self.enter_phase("dry-run"); + self.dry_run(&follow_id, &mut follow, &finalized_hash, &signed.extrinsic) + .await?; + + self.enter_phase("broadcast"); + let extrinsic_hash = signed.extrinsic_hash; + let broadcast = self + .chain + .remote_chain_transaction_broadcast(RemoteChainTransactionBroadcastRequest { + genesis_hash: self.genesis_hash.to_vec(), + transaction: signed.extrinsic, + }) + .await + .map_err(|failure| BulletinSubmitError::ChainUnavailable { + reason: failure.reason(), + })?; + let Some(operation_id) = broadcast.operation_id else { + return Err(BulletinSubmitError::BroadcastSlotUnavailable); + }; + *self.active_broadcast.lock().expect("broadcast slot poisoned") = Some(operation_id); + + self.enter_phase("watch"); + let (inclusion_block, extrinsic_index) = self + .watch_for_inclusion(&follow_id, &mut follow, extrinsic_hash, nonce, allowance) + .await?; + + self.enter_phase("events"); + self.require_dispatch_success( + &follow_id, + &mut follow, + &state, + &anchor, + &inclusion_block, + extrinsic_index, + ) + .await?; + + Ok(key.to_vec()) + } + + /// Fetch (or reuse) decoded metadata for the follow's runtime spec and + /// assemble the offline chain state. The genesis hash is deliberately the + /// configured one, never provider-echoed. + async fn offline_chain_state( + &self, + follow_id: &str, + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + finalized_hash: &[u8], + runtime_spec: RuntimeSpec, + ) -> Result { + let spec_version = runtime_spec.spec_version; + let transaction_version = + runtime_spec + .transaction_version + .ok_or_else(|| BulletinSubmitError::ChainUnavailable { + reason: "runtime spec lacks a transaction version".to_string(), + })?; + + let cached = self + .metadata_cache + .lock() + .expect("metadata cache poisoned") + .clone(); + let metadata = match cached { + Some((cached_spec, metadata)) if cached_spec == spec_version => metadata, + _ => { + let versions = self + .runtime_call( + follow_id, + follow, + finalized_hash, + "Metadata_metadata_versions", + Vec::new(), + ) + .await?; + let version = best_supported_metadata_version(&versions) + .map_err(|reason| BulletinSubmitError::ChainUnavailable { reason })?; + let response = self + .runtime_call( + follow_id, + follow, + finalized_hash, + "Metadata_metadata_at_version", + version.encode(), + ) + .await?; + let metadata = ArcMetadata::from( + decode_runtime_metadata(&response) + .map_err(|reason| BulletinSubmitError::ChainUnavailable { reason })?, + ); + *self + .metadata_cache + .lock() + .expect("metadata cache poisoned") = Some((spec_version, metadata.clone())); + metadata + } + }; + + Ok(OfflineChainState { + genesis_hash: self.genesis_hash, + spec_version, + transaction_version, + metadata, + }) + } + + /// Resolve the finalized anchor block's number for the mortal era. + async fn mortality_anchor( + &self, + follow_id: &str, + finalized_hash: &[u8], + ) -> Result { + let response = self + .chain + .remote_chain_head_header(RemoteChainHeadHeaderRequest { + genesis_hash: self.genesis_hash.to_vec(), + follow_subscription_id: follow_id.to_string(), + hash: finalized_hash.to_vec(), + }) + .await + .map_err(|failure| BulletinSubmitError::ChainUnavailable { + reason: failure.reason(), + })?; + let header = response + .header + .ok_or_else(|| BulletinSubmitError::ChainUnavailable { + reason: "anchor block header unavailable".to_string(), + })?; + let number = decode_header_block_number(&header) + .map_err(|reason| BulletinSubmitError::ChainUnavailable { reason })?; + let hash = + finalized_hash + .try_into() + .map_err(|_| BulletinSubmitError::ChainUnavailable { + reason: "anchor block hash is not 32 bytes".to_string(), + })?; + Ok(MortalityAnchor { number, hash }) + } + + /// Read the allowance account's next nonce at the anchor block. + async fn account_nonce( + &self, + follow_id: &str, + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + finalized_hash: &[u8], + allowance: &BulletinAllowanceKey, + ) -> Result { + let account = crate::host_logic::extrinsic::public_key_from_secret_bytes( + allowance.as_secret_bytes(), + ) + .map_err(|reason| BulletinSubmitError::InvalidTransaction { kind: reason })?; + let output = self + .runtime_call( + follow_id, + follow, + finalized_hash, + "AccountNonceApi_account_nonce", + account.to_vec(), + ) + .await?; + let nonce = + u32::decode(&mut &output[..]).map_err(|err| BulletinSubmitError::ChainUnavailable { + reason: format!("invalid account nonce response: {err}"), + })?; + Ok(nonce.into()) + } + + /// Dry-run the signed extrinsic against the anchor block. Broadcast never + /// reports invalid transactions, so this is the only deterministic signal + /// for stale allowances, nonce races, and encoding errors. + async fn dry_run( + &self, + follow_id: &str, + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + finalized_hash: &[u8], + extrinsic: &[u8], + ) -> Result<(), BulletinSubmitError> { + let parameters = validate_transaction_call_parameters(extrinsic, finalized_hash); + let output = self + .runtime_call( + follow_id, + follow, + finalized_hash, + "TaggedTransactionQueue_validate_transaction", + parameters, + ) + .await?; + let validity = decode_transaction_validity(&output) + .map_err(|reason| BulletinSubmitError::ChainUnavailable { reason })?; + match validity { + TransactionValidity::Valid(_) => Ok(()), + TransactionValidity::Invalid( + TransactionInvalid::Payment + | TransactionInvalid::Custom(_) + | TransactionInvalid::BadSigner, + ) => Err(BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::DryRun, + }), + TransactionValidity::Invalid( + TransactionInvalid::Future | TransactionInvalid::Stale, + ) => Err(BulletinSubmitError::NonceRace), + TransactionValidity::Invalid(other) => Err(BulletinSubmitError::InvalidTransaction { + kind: format!("{other:?}"), + }), + TransactionValidity::Unknown(TransactionUnknown::CannotLookup) => { + Err(BulletinSubmitError::ChainUnavailable { + reason: "transaction validity could not be looked up".to_string(), + }) + } + TransactionValidity::Unknown(other) => Err(BulletinSubmitError::InvalidTransaction { + kind: format!("{other:?}"), + }), + } + } + + /// Watch best/finalized blocks until the broadcast extrinsic appears in a + /// body. Runs as one event loop over the follow stream so no block event + /// is lost while a chainHead operation is in flight; block bodies are + /// only fetched once the allowance account's nonce is seen to advance. + async fn watch_for_inclusion( + &self, + follow_id: &str, + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + extrinsic_hash: [u8; 32], + our_nonce: u64, + allowance: &BulletinAllowanceKey, + ) -> Result<(Vec, u32), BulletinSubmitError> { + let account = crate::host_logic::extrinsic::public_key_from_secret_bytes( + allowance.as_secret_bytes(), + ) + .expect("validated earlier in the submit flow"); + + let stopped = || BulletinSubmitError::BroadcastUnverified { + reason: "chain follow stopped".to_string(), + }; + + // Parent links learned from NewBlock events, used to walk from a + // nonce-advanced block back to already-checked ancestors without + // extra header fetches. + let mut parents: HashMap, Vec> = HashMap::new(); + // Blocks whose bodies were checked (or skipped as inaccessible). + let mut checked: HashSet> = HashSet::new(); + // Blocks waiting for a nonce gate check. + let mut gate_queue: VecDeque> = VecDeque::new(); + // Blocks that passed the gate and await a body check. + let mut body_queue: VecDeque> = VecDeque::new(); + let mut pending_nonce: Option<(String, Vec)> = None; + let mut pending_body: Option<(String, Vec)> = None; + + loop { + // Start at most one operation at a time, bodies first. + if pending_nonce.is_none() && pending_body.is_none() { + if let Some(block) = body_queue.pop_front() { + match self.start_body(follow_id, &block).await? { + Some(operation_id) => pending_body = Some((operation_id, block)), + None => body_queue.push_front(block), + } + } else if let Some(block) = gate_queue.pop_front() { + match self + .start_nonce_probe(follow_id, &block, &account) + .await? + { + Some(operation_id) => pending_nonce = Some((operation_id, block)), + None => gate_queue.push_front(block), + } + } + } + + let item = follow.next().await.ok_or_else(stopped)?; + match item { + RemoteChainHeadFollowItem::Stop => return Err(stopped()), + RemoteChainHeadFollowItem::NewBlock { + block_hash, + parent_block_hash, + .. + } => { + parents.insert(block_hash, parent_block_hash); + } + RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash } => { + if !checked.contains(&best_block_hash) { + gate_queue.push_back(best_block_hash); + } + } + RemoteChainHeadFollowItem::Finalized { + finalized_block_hashes, + pruned_block_hashes, + } => { + for hash in finalized_block_hashes { + if !checked.contains(&hash) && !gate_queue.contains(&hash) { + gate_queue.push_back(hash); + } + } + self.unpin(follow_id, pruned_block_hashes).await; + } + RemoteChainHeadFollowItem::OperationCallDone { + operation_id, + output, + } if pending_nonce + .as_ref() + .is_some_and(|(id, _)| *id == operation_id) => + { + let (_, block) = pending_nonce.take().expect("checked above"); + let nonce = u32::decode(&mut &output[..]).map_err(|err| { + BulletinSubmitError::BroadcastUnverified { + reason: format!("invalid nonce probe response: {err}"), + } + })?; + if u64::from(nonce) > our_nonce { + // The account advanced at or before `block`: walk back + // through unchecked ancestors and check their bodies. + let mut cursor = block.clone(); + let mut walk = vec![block]; + while let Some(parent) = parents.get(&cursor) { + if checked.contains(parent) { + break; + } + walk.push(parent.clone()); + cursor = parent.clone(); + } + for hash in walk { + if !body_queue.contains(&hash) { + body_queue.push_back(hash); + } + } + } else { + checked.insert(block); + } + } + RemoteChainHeadFollowItem::OperationBodyDone { + operation_id, + value, + } if pending_body + .as_ref() + .is_some_and(|(id, _)| *id == operation_id) => + { + let (_, block) = pending_body.take().expect("checked above"); + let matched = value.iter().position(|extrinsic| { + sp_crypto_hashing::blake2_256(extrinsic) == extrinsic_hash + }); + if let Some(index) = matched { + return Ok((block, index as u32)); + } + checked.insert(block.clone()); + self.unpin(follow_id, vec![block]).await; + } + RemoteChainHeadFollowItem::OperationInaccessible { operation_id } + | RemoteChainHeadFollowItem::OperationError { operation_id, .. } => { + if pending_nonce + .as_ref() + .is_some_and(|(id, _)| *id == operation_id) + { + let (_, block) = pending_nonce.take().expect("checked above"); + checked.insert(block); + } else if pending_body + .as_ref() + .is_some_and(|(id, _)| *id == operation_id) + { + let (_, block) = pending_body.take().expect("checked above"); + checked.insert(block); + } + } + _ => {} + } + } + } + + /// Read `System.Events` at the inclusion block and require an + /// `ExtrinsicSuccess` for our extrinsic index. + async fn require_dispatch_success( + &self, + follow_id: &str, + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + state: &OfflineChainState, + anchor: &MortalityAnchor, + inclusion_block: &[u8], + extrinsic_index: u32, + ) -> Result<(), BulletinSubmitError> { + let unverified = |reason: String| BulletinSubmitError::BroadcastUnverified { reason }; + + let key = system_events_storage_key(); + let mut events_bytes = None; + for _attempt in 0..EVENTS_READ_ATTEMPTS { + let response = self + .chain + .remote_chain_head_storage(RemoteChainHeadStorageRequest { + genesis_hash: self.genesis_hash.to_vec(), + follow_subscription_id: follow_id.to_string(), + hash: inclusion_block.to_vec(), + items: vec![StorageQueryItem { + key: key.clone(), + query_type: StorageQueryType::Value, + }], + child_trie: None, + }) + .await + .map_err(|failure| unverified(failure.reason()))?; + let operation_id = match response.operation { + OperationStartedResult::Started { operation_id } => operation_id, + OperationStartedResult::LimitReached => { + futures_timer::Delay::new(OPERATION_RETRY_DELAY).await; + continue; + } + }; + // The block executed our extrinsic, so System.Events is never + // absent there: a missing value means the read was inaccessible. + match wait_for_chain_head_storage_value( + follow, + &operation_id, + &key, + "Bulletin", + OPERATION_TIMEOUT, + ) + .await + .map_err(unverified)? + { + Some(bytes) => { + events_bytes = Some(bytes); + break; + } + None => futures_timer::Delay::new(OPERATION_RETRY_DELAY).await, + } + } + let Some(events_bytes) = events_bytes else { + return Err(unverified( + "included, dispatch outcome unavailable".to_string(), + )); + }; + + let client = state + .client_at(anchor.number) + .map_err(|reason| unverified(format!("events decoding unavailable: {reason}")))?; + match extrinsic_outcome_from_events(&client, events_bytes, extrinsic_index) + .map_err(unverified)? + { + ExtrinsicOutcome::Success => Ok(()), + ExtrinsicOutcome::Failed(DecodedDispatchError::Module { pallet, error }) + if pallet == "TransactionStorage" + && ALLOWANCE_REJECTED_MODULE_ERRORS.contains(&error.as_str()) => + { + Err(BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::Dispatch, + }) + } + ExtrinsicOutcome::Failed(DecodedDispatchError::Module { pallet, error }) => { + Err(BulletinSubmitError::IncludedButFailed { pallet, error }) + } + ExtrinsicOutcome::Failed(DecodedDispatchError::Other(reason)) => { + Err(BulletinSubmitError::IncludedButFailed { + pallet: "unknown".to_string(), + error: reason, + }) + } + ExtrinsicOutcome::NotFound => Err(unverified( + "included, but the block reported no dispatch outcome".to_string(), + )), + } + } + + /// Start one runtime-call operation at `hash`, retrying `LimitReached` + /// once, and wait for its output. + async fn runtime_call( + &self, + follow_id: &str, + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + hash: &[u8], + function: &str, + call_parameters: Vec, + ) -> Result, BulletinSubmitError> { + let mut attempts = 0; + let operation_id = loop { + attempts += 1; + let response = self + .chain + .remote_chain_head_call(RemoteChainHeadCallRequest { + genesis_hash: self.genesis_hash.to_vec(), + follow_subscription_id: follow_id.to_string(), + hash: hash.to_vec(), + function: function.to_string(), + call_parameters: call_parameters.clone(), + }) + .await + .map_err(|failure| BulletinSubmitError::ChainUnavailable { + reason: failure.reason(), + })?; + match response.operation { + OperationStartedResult::Started { operation_id } => break operation_id, + OperationStartedResult::LimitReached if attempts < 2 => { + futures_timer::Delay::new(OPERATION_RETRY_DELAY).await; + } + OperationStartedResult::LimitReached => { + return Err(BulletinSubmitError::ChainUnavailable { + reason: format!("{function}: chainHead operation limit reached"), + }); + } + } + }; + wait_for_chain_head_call_output(follow, &operation_id, "Bulletin", OPERATION_TIMEOUT) + .await + .map_err(|reason| BulletinSubmitError::ChainUnavailable { reason }) + } + + /// Start a nonce probe at `block`; `Ok(None)` means the operation limit + /// was reached and the caller should retry on the next event. + async fn start_nonce_probe( + &self, + follow_id: &str, + block: &[u8], + account: &[u8; 32], + ) -> Result, BulletinSubmitError> { + let response = self + .chain + .remote_chain_head_call(RemoteChainHeadCallRequest { + genesis_hash: self.genesis_hash.to_vec(), + follow_subscription_id: follow_id.to_string(), + hash: block.to_vec(), + function: "AccountNonceApi_account_nonce".to_string(), + call_parameters: account.to_vec(), + }) + .await + .map_err(|failure| BulletinSubmitError::BroadcastUnverified { + reason: failure.reason(), + })?; + Ok(match response.operation { + OperationStartedResult::Started { operation_id } => Some(operation_id), + OperationStartedResult::LimitReached => None, + }) + } + + /// Start a body fetch at `block`; `Ok(None)` means the operation limit + /// was reached and the caller should retry on the next event. + async fn start_body( + &self, + follow_id: &str, + block: &[u8], + ) -> Result, BulletinSubmitError> { + let response = self + .chain + .remote_chain_head_body(RemoteChainHeadBodyRequest { + genesis_hash: self.genesis_hash.to_vec(), + follow_subscription_id: follow_id.to_string(), + hash: block.to_vec(), + }) + .await + .map_err(|failure| BulletinSubmitError::BroadcastUnverified { + reason: failure.reason(), + })?; + Ok(match response.operation { + OperationStartedResult::Started { operation_id } => Some(operation_id), + OperationStartedResult::LimitReached => None, + }) + } + + /// Release pins for blocks the watch no longer needs; failures only warn. + async fn unpin(&self, follow_id: &str, hashes: Vec>) { + if hashes.is_empty() { + return; + } + if let Err(failure) = self + .chain + .remote_chain_head_unpin(RemoteChainHeadUnpinRequest { + genesis_hash: self.genesis_hash.to_vec(), + follow_subscription_id: follow_id.to_string(), + hashes, + }) + .await + { + warn!(reason = %failure.reason(), "Bulletin block unpin failed"); + } + } + + /// Stop the live broadcast, if any. Called on every submit exit path. + async fn stop_active_broadcast(&self) { + let operation_id = self + .active_broadcast + .lock() + .expect("broadcast slot poisoned") + .take(); + let Some(operation_id) = operation_id else { + return; + }; + if let Err(failure) = self + .chain + .remote_chain_transaction_stop(RemoteChainTransactionStopRequest { + genesis_hash: self.genesis_hash.to_vec(), + operation_id, + }) + .await + { + warn!(reason = %failure.reason(), "Bulletin broadcast stop failed"); + } + } + + 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") + } +} diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index 4969a42c..5e207cf1 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -589,6 +589,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 +717,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, @@ -835,6 +866,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..9c60b476 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -4,30 +4,44 @@ //! 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, `None` when no bulletin genesis was + /// configured by the host. + pub(crate) bulletin: Option, + /// 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 optional + /// 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: Option<[u8; 32]>, spawner: Spawner, ) -> Arc { let chain_provider = Arc::new(HostChainProvider { @@ -36,10 +50,14 @@ 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 = bulletin_chain_genesis_hash + .map(|genesis_hash| BulletinRpc::new(chain.clone(), 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 +66,56 @@ 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 diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 4ec98c67..7e39dde1 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -225,6 +225,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, @@ -339,6 +351,7 @@ mod tests { let services = RuntimeServices::new( platform.clone(), config.people_chain_genesis_hash, + config.bulletin_chain_genesis_hash, test_spawner(), ); let signing_host = SigningHostRole::new(platform); diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index c2607a37..85d163a7 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], None, test_spawner()); let signing_host = SigningHostRole::new(platform); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 652b4e41..e8621c3d 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, @@ -117,9 +117,9 @@ pub(crate) struct StubPlatform { pub(crate) sso_response_script: Option, pub(crate) chain_connect_error: Option<&'static str>, 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>>>, + /// 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>, @@ -1292,32 +1292,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..1c7bd896 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,8 +414,9 @@ fn pairing_host_config_from_js(value: &JsValue) -> Result Result config.with_bulletin_chain_genesis_hash(genesis), + None => config, + }) } fn product_context_from_js(value: &JsValue) -> Result { 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..efeb7cd1 100644 --- a/rust/crates/truapi-server/tests/common/mod.rs +++ b/rust/crates/truapi-server/tests/common/mod.rs @@ -6,7 +6,7 @@ use std::sync::Mutex; use futures::stream::{self, BoxStream}; use truapi::v01; use truapi_platform::{ - AuthPresenter, BulletinAllowanceSigner, ChainProvider, CoreStorage, CoreStorageKey, Features, + AuthPresenter, ChainProvider, CoreStorage, CoreStorageKey, Features, HostInfo, JsonRpcConnection, Navigation, Notifications, PairingHostConfig, Permissions, PlatformInfo, PreimageHost, ProductContext, ProductStorage, ThemeHost, UserConfirmation, UserConfirmationReview, @@ -188,15 +188,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 0000000000000000000000000000000000000000..670b2431be4e212f43c90592f72026abe14dc4a5 GIT binary patch literal 166563 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 Date: Wed, 8 Jul 2026 18:57:57 +0200 Subject: [PATCH 2/9] fix(server): harden bulletin inclusion watch (cycle guard + pin release) Adversarial review of the watch loop found two defects: - A crafted or buggy chain provider could send a self-referential or cyclic `NewBlock` parent link. The nonce-advance ancestor walk had no visited-set guard and no `.await`, so such a link spun forever, freezing the worker and permanently holding the submit lock across all products. The walk is extracted into `ancestors_to_check`, which guards against self-parent and cyclic links with a visited set, and is unit-tested. - Blocks that failed the nonce gate were marked checked but never unpinned, leaking chainHead pins over the watch's lifetime and risking a false BroadcastUnverified once the server's pin limit was hit. They are now unpinned like body-negative blocks. Also log a warning when a host lookup value is downgraded to a miss for failing the blake2_256(value)==key integrity check. --- rust/crates/truapi-server/src/host_core.rs | 13 +- .../truapi-server/src/host_logic/bulletin.rs | 20 +-- .../truapi-server/src/host_logic/extrinsic.rs | 22 +-- rust/crates/truapi-server/src/runtime.rs | 30 +++- .../truapi-server/src/runtime/bulletin_rpc.rs | 156 ++++++++++++++---- .../truapi-server/src/runtime/services.rs | 6 +- rust/crates/truapi-server/tests/common/mod.rs | 6 +- 7 files changed, 171 insertions(+), 82 deletions(-) diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index b999f212..1dbae792 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -201,13 +201,12 @@ impl SigningHostRuntime { P: Platform + 'static, { let platform: Arc = platform; - let services = - RuntimeServices::new( - platform.clone(), - config.people_chain_genesis_hash, - config.bulletin_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/bulletin.rs b/rust/crates/truapi-server/src/host_logic/bulletin.rs index 12369636..d1c3df83 100644 --- a/rust/crates/truapi-server/src/host_logic/bulletin.rs +++ b/rust/crates/truapi-server/src/host_logic/bulletin.rs @@ -7,9 +7,9 @@ use parity_scale_codec::{Compact, Encode}; use subxt::config::DefaultExtrinsicParamsBuilder; +use subxt::config::substrate::SubstrateConfig; use subxt::ext::scale_encode::{self, EncodeAsFields, FieldIter, TypeResolver}; use subxt::ext::scale_type_resolver::{Primitive, visitor}; -use subxt::config::substrate::SubstrateConfig; use subxt::tx::StaticPayload; use subxt::utils::H256; use truapi_platform::BulletinAllowanceKey; @@ -92,9 +92,7 @@ pub(crate) fn build_signed_store_extrinsic( .call_data(&payload) .map_err(|err| format!("store call encoding failed: {err}"))?; if call_data != canonical_call { - return Err( - "metadata-encoded store call diverges from the canonical encoding".to_string(), - ); + return Err("metadata-encoded store call diverges from the canonical encoding".to_string()); } drop(canonical_call); @@ -263,10 +261,8 @@ mod tests { } fn metadata_from_v14(metadata: v14::RuntimeMetadataV14) -> ArcMetadata { - let prefixed = RuntimeMetadataPrefixed( - u32::from_le_bytes(*b"meta"), - RuntimeMetadata::V14(metadata), - ); + let prefixed = + RuntimeMetadataPrefixed(u32::from_le_bytes(*b"meta"), RuntimeMetadata::V14(metadata)); ArcMetadata::from(Metadata::try_from(prefixed).unwrap()) } @@ -433,7 +429,10 @@ mod tests { vec![1, 2, 3], ) .unwrap_err(); - assert!(error.contains("does not match the audited index"), "{error}"); + assert!( + error.contains("does not match the audited index"), + "{error}" + ); } #[test] @@ -523,8 +522,7 @@ mod tests { // 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 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(); diff --git a/rust/crates/truapi-server/src/host_logic/extrinsic.rs b/rust/crates/truapi-server/src/host_logic/extrinsic.rs index 38dabdc2..9203eaa1 100644 --- a/rust/crates/truapi-server/src/host_logic/extrinsic.rs +++ b/rust/crates/truapi-server/src/host_logic/extrinsic.rs @@ -80,9 +80,9 @@ impl Signer for Sr25519Signer { } fn sign(&self, signer_payload: &[u8]) -> MultiSignature { - let signature = self - .secret - .sign_simple(SR25519_SIGNING_CONTEXT, signer_payload, &self.public); + let signature = + self.secret + .sign_simple(SR25519_SIGNING_CONTEXT, signer_payload, &self.public); MultiSignature::Sr25519(signature.to_bytes()) } } @@ -162,10 +162,7 @@ pub(crate) enum TransactionValidity { /// SCALE-encode the `TaggedTransactionQueue_validate_transaction` parameters: /// `TransactionSource::External` ++ opaque extrinsic ++ 32-byte block hash. -pub(crate) fn validate_transaction_call_parameters( - extrinsic: &[u8], - block_hash: &[u8], -) -> Vec { +pub(crate) fn validate_transaction_call_parameters(extrinsic: &[u8], block_hash: &[u8]) -> Vec { /// `sp_runtime::transaction_validity::TransactionSource::External`, the /// source the pool assigns to transactions arriving over the network, /// matching how a broadcast transaction is later validated. @@ -406,16 +403,7 @@ pub(crate) mod tests { // The subxt validity types are Decode-only; build the SCALE bytes by // hand from the sp-runtime layout. let mut bytes = vec![0u8]; - bytes.extend( - ( - 5u64, - Vec::>::new(), - vec![vec![1u8, 2]], - 32u64, - true, - ) - .encode(), - ); + bytes.extend((5u64, Vec::>::new(), vec![vec![1u8, 2]], 32u64, true).encode()); assert_eq!( decode_transaction_validity(&bytes).unwrap(), TransactionValidity::Valid(TransactionValid { diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 4684dedf..0172dfbf 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -27,7 +27,6 @@ use std::sync::Arc; use crate::chain_runtime::RuntimeFailure; use crate::host_logic::bulletin::preimage_key; use crate::host_logic::dotns::{NavigateDecision, parse_navigate}; -use crate::runtime::bulletin_rpc::BulletinSubmitError; use crate::host_logic::features::feature_supported; use crate::host_logic::permissions::PermissionsService; use crate::host_logic::product_account::{ @@ -36,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; @@ -1763,9 +1763,10 @@ impl Preimage for ProductRuntimeHost { 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 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)); @@ -1787,7 +1788,16 @@ impl Preimage for ProductRuntimeHost { // subscription interrupts once subscription items can carry // in-stream failures. let value = item.ok()?; - let value = value.filter(|value| preimage_key(value)[..] == key[..]); + 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 }, )) @@ -1845,7 +1855,10 @@ impl Preimage for ProductRuntimeHost { .await .map_err(|err| preimage_submit_error(err.reason()))?; - match bulletin.submit_preimage(cx, &allowance, value.clone()).await { + match bulletin + .submit_preimage(cx, &allowance, value.clone()) + .await + { Ok(key) => { self.prime_preimage_cache(&value, &key); Ok(RemotePreimageSubmitResponse::V1(key)) @@ -1864,7 +1877,10 @@ impl Preimage for ProductRuntimeHost { ) .await .map_err(|err| preimage_submit_error(err.reason()))?; - match bulletin.submit_preimage(cx, &allowance, value.clone()).await { + match bulletin + .submit_preimage(cx, &allowance, value.clone()) + .await + { Ok(key) => { self.prime_preimage_cache(&value, &key); Ok(RemotePreimageSubmitResponse::V1(key)) diff --git a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs index 8c6f6660..09269aab 100644 --- a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs @@ -247,7 +247,10 @@ impl BulletinRpc { let Some(operation_id) = broadcast.operation_id else { return Err(BulletinSubmitError::BroadcastSlotUnavailable); }; - *self.active_broadcast.lock().expect("broadcast slot poisoned") = Some(operation_id); + *self + .active_broadcast + .lock() + .expect("broadcast slot poisoned") = Some(operation_id); self.enter_phase("watch"); let (inclusion_block, extrinsic_index) = self @@ -279,12 +282,11 @@ impl BulletinRpc { runtime_spec: RuntimeSpec, ) -> Result { let spec_version = runtime_spec.spec_version; - let transaction_version = - runtime_spec - .transaction_version - .ok_or_else(|| BulletinSubmitError::ChainUnavailable { - reason: "runtime spec lacks a transaction version".to_string(), - })?; + let transaction_version = runtime_spec.transaction_version.ok_or_else(|| { + BulletinSubmitError::ChainUnavailable { + reason: "runtime spec lacks a transaction version".to_string(), + } + })?; let cached = self .metadata_cache @@ -318,10 +320,8 @@ impl BulletinRpc { decode_runtime_metadata(&response) .map_err(|reason| BulletinSubmitError::ChainUnavailable { reason })?, ); - *self - .metadata_cache - .lock() - .expect("metadata cache poisoned") = Some((spec_version, metadata.clone())); + *self.metadata_cache.lock().expect("metadata cache poisoned") = + Some((spec_version, metadata.clone())); metadata } }; @@ -375,10 +375,9 @@ impl BulletinRpc { finalized_hash: &[u8], allowance: &BulletinAllowanceKey, ) -> Result { - let account = crate::host_logic::extrinsic::public_key_from_secret_bytes( - allowance.as_secret_bytes(), - ) - .map_err(|reason| BulletinSubmitError::InvalidTransaction { kind: reason })?; + let account = + crate::host_logic::extrinsic::public_key_from_secret_bytes(allowance.as_secret_bytes()) + .map_err(|reason| BulletinSubmitError::InvalidTransaction { kind: reason })?; let output = self .runtime_call( follow_id, @@ -455,10 +454,9 @@ impl BulletinRpc { our_nonce: u64, allowance: &BulletinAllowanceKey, ) -> Result<(Vec, u32), BulletinSubmitError> { - let account = crate::host_logic::extrinsic::public_key_from_secret_bytes( - allowance.as_secret_bytes(), - ) - .expect("validated earlier in the submit flow"); + let account = + crate::host_logic::extrinsic::public_key_from_secret_bytes(allowance.as_secret_bytes()) + .expect("validated earlier in the submit flow"); let stopped = || BulletinSubmitError::BroadcastUnverified { reason: "chain follow stopped".to_string(), @@ -486,10 +484,7 @@ impl BulletinRpc { None => body_queue.push_front(block), } } else if let Some(block) = gate_queue.pop_front() { - match self - .start_nonce_probe(follow_id, &block, &account) - .await? - { + match self.start_nonce_probe(follow_id, &block, &account).await? { Some(operation_id) => pending_nonce = Some((operation_id, block)), None => gate_queue.push_front(block), } @@ -536,24 +531,17 @@ impl BulletinRpc { } })?; if u64::from(nonce) > our_nonce { - // The account advanced at or before `block`: walk back - // through unchecked ancestors and check their bodies. - let mut cursor = block.clone(); - let mut walk = vec![block]; - while let Some(parent) = parents.get(&cursor) { - if checked.contains(parent) { - break; - } - walk.push(parent.clone()); - cursor = parent.clone(); - } - for hash in walk { + // The account advanced at or before `block`: check its + // body and those of its unchecked ancestors. + for hash in ancestors_to_check(&parents, &checked, block) { if !body_queue.contains(&hash) { body_queue.push_back(hash); } } } else { - checked.insert(block); + // This block does not contain our tx; release its pin. + checked.insert(block.clone()); + self.unpin(follow_id, vec![block]).await; } } RemoteChainHeadFollowItem::OperationBodyDone { @@ -828,3 +816,99 @@ impl BulletinRpc { *self.phase.lock().expect("phase slot poisoned") } } + +/// Collect `start` and its not-yet-checked ancestors from a provider-supplied +/// `parents` map, oldest lookups first. +/// +/// `parents` comes from untrusted `NewBlock` follow events, so the walk guards +/// against self-parent and cyclic links with a visited set: without it a +/// crafted `parent == block` link would spin forever, and since the enclosing +/// watch loop never `.await`s inside the walk, the whole worker would freeze +/// and hold the submit lock permanently. +fn ancestors_to_check( + parents: &HashMap, Vec>, + checked: &HashSet>, + start: Vec, +) -> Vec> { + let mut cursor = start.clone(); + let mut visited: HashSet> = HashSet::from([start.clone()]); + let mut walk = vec![start]; + while let Some(parent) = parents.get(&cursor) { + if checked.contains(parent) || !visited.insert(parent.clone()) { + break; + } + walk.push(parent.clone()); + cursor = parent.clone(); + } + walk +} + +#[cfg(test)] +mod tests { + use super::*; + + fn h(byte: u8) -> Vec { + vec![byte] + } + + #[test] + fn ancestors_walk_collects_unchecked_chain() { + // c -> b -> a, none checked: walk collects all three newest-first. + let parents = HashMap::from([(h(3), h(2)), (h(2), h(1))]); + let checked = HashSet::new(); + assert_eq!( + ancestors_to_check(&parents, &checked, h(3)), + vec![h(3), h(2), h(1)] + ); + } + + #[test] + fn ancestors_walk_stops_at_checked_parent() { + let parents = HashMap::from([(h(3), h(2)), (h(2), h(1))]); + let checked = HashSet::from([h(2)]); + assert_eq!(ancestors_to_check(&parents, &checked, h(3)), vec![h(3)]); + } + + #[test] + fn ancestors_walk_terminates_on_self_parent() { + // A crafted self-referential parent must not loop forever. + let parents = HashMap::from([(h(5), h(5))]); + let checked = HashSet::new(); + assert_eq!(ancestors_to_check(&parents, &checked, h(5)), vec![h(5)]); + } + + #[test] + fn ancestors_walk_terminates_on_cycle() { + // A -> B -> A cycle must terminate once it wraps around. + let parents = HashMap::from([(h(1), h(2)), (h(2), h(1))]); + let checked = HashSet::new(); + assert_eq!( + ancestors_to_check(&parents, &checked, h(1)), + vec![h(1), h(2)] + ); + } + + #[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/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 9c60b476..293bf264 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -96,7 +96,11 @@ impl PreimageCache { if value.len() > PREIMAGE_CACHE_MAX_BYTES { return; } - if let Some(index) = self.entries.iter().position(|(existing, _)| *existing == key) { + 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(); } diff --git a/rust/crates/truapi-server/tests/common/mod.rs b/rust/crates/truapi-server/tests/common/mod.rs index efeb7cd1..a5548b2f 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, 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; From e25503b0c6ce46b3650aed515cc470c4c620f02b Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 9 Jul 2026 09:52:32 +0200 Subject: [PATCH 3/9] fix(server): stabilize bulletin preimage submit --- Makefile | 1 + .../issue-drafts/bulletin-preimage-in-core.md | 336 ++++++++++++++++++ .../truapi/scripts/ensure-generated.sh | 30 +- playground/src/lib/auto-test.ts | 4 +- .../crates/truapi-server/src/chain_runtime.rs | 43 --- .../truapi-server/src/runtime/bulletin_rpc.rs | 198 ++++++++++- rust/crates/truapi/src/api/preimage.rs | 6 +- 7 files changed, 553 insertions(+), 65 deletions(-) create mode 100644 docs/issue-drafts/bulletin-preimage-in-core.md diff --git a/Makefile b/Makefile index b3e20965..32ae3162 100644 --- a/Makefile +++ b/Makefile @@ -101,6 +101,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 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/js/packages/truapi/scripts/ensure-generated.sh b/js/packages/truapi/scripts/ensure-generated.sh index 6518aee5..43c4d9a2 100755 --- a/js/packages/truapi/scripts/ensure-generated.sh +++ b/js/packages/truapi/scripts/ensure-generated.sh @@ -5,7 +5,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" cd "$ROOT" -required=( +codegen_required=( "js/packages/truapi/src/generated/client.ts" "js/packages/truapi/src/generated/types.ts" "js/packages/truapi/src/generated/wire-table.ts" @@ -13,16 +13,28 @@ required=( "js/packages/truapi/src/explorer/codegen/types.ts" "js/packages/truapi/src/explorer/versions.ts" ) +truapi_dts="js/packages/truapi/src/playground/codegen/truapi-dts.ts" missing=0 -for path in "${required[@]}"; do +for path in "${codegen_required[@]}"; do if [ ! -f "$path" ]; then missing=1 break fi done -if [ "$missing" -eq 0 ] && find playground/test/generated/examples -name '*.ts' -print -quit >/dev/null 2>&1; then +if [ "$missing" -eq 1 ] || ! find playground/test/generated/examples -name '*.ts' -print -quit >/dev/null 2>&1; then + if [ "${TRUAPI_REQUIRE_GENERATED:-0}" = "1" ]; then + echo "ensure-generated: generated files are missing and TRUAPI_REQUIRE_GENERATED=1, so codegen will not run." >&2 + echo "These files are expected to be restored from the 'codegen-output' CI artifact." >&2 + echo "If you added a generated output, add its path to the upload-artifact step in .github/workflows/ci.yml." >&2 + exit 1 + fi + + TRUAPI_SKIP_PACKAGE_BUILD=1 ./scripts/codegen.sh +fi + +if [ -f "$truapi_dts" ]; then exit 0 fi @@ -33,4 +45,14 @@ if [ "${TRUAPI_REQUIRE_GENERATED:-0}" = "1" ]; then exit 1 fi -TRUAPI_SKIP_PACKAGE_BUILD=1 ./scripts/codegen.sh +if [ -x node_modules/.bin/tsc ]; then + tsc_bin="node_modules/.bin/tsc" +elif [ -x js/packages/truapi/node_modules/.bin/tsc ]; then + tsc_bin="js/packages/truapi/node_modules/.bin/tsc" +else + echo "ensure-generated: cannot find tsc. Run npm install from the repo root first." >&2 + exit 1 +fi + +"$tsc_bin" -b js/packages/truapi --force +node scripts/bundle-truapi-dts.mjs diff --git a/playground/src/lib/auto-test.ts b/playground/src/lib/auto-test.ts index 12d901a1..56ed0bbb 100644 --- a/playground/src/lib/auto-test.ts +++ b/playground/src/lib/auto-test.ts @@ -39,8 +39,8 @@ const LONG_TIMEOUT_METHODS = new Set([ const METHOD_TIMEOUT_MS = new Map([ ["Account/get_account_alias", SSO_TIMEOUT_MS], ["Resource Allocation/request", LIVE_ALLOCATION_TIMEOUT_MS], - ["Preimage/lookup_subscribe", SSO_TIMEOUT_MS], - ["Preimage/submit", SSO_TIMEOUT_MS], + ["Preimage/lookup_subscribe", LIVE_ALLOCATION_TIMEOUT_MS], + ["Preimage/submit", LIVE_ALLOCATION_TIMEOUT_MS], ["Signing/create_transaction", SSO_TIMEOUT_MS], ["Statement Store/create_proof_authorized", LIVE_ALLOCATION_TIMEOUT_MS], ["Statement Store/submit", LIVE_ALLOCATION_TIMEOUT_MS], diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 0bd0cacf..1147e5b2 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -1270,49 +1270,6 @@ pub(crate) async fn wait_for_chain_head_storage_value( } } -/// Wait for `Initialized` on a fresh `with_runtime: true` follow, returning -/// the newest finalized block hash and the finalized-block runtime spec. -pub(crate) async fn wait_for_chain_head_initialized( - follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, - label: &'static str, - timeout: Duration, -) -> Result<(Vec, RuntimeSpec), String> { - let timeout = futures_timer::Delay::new(timeout).fuse(); - pin_mut!(timeout); - loop { - let next = follow.next().fuse(); - pin_mut!(next); - futures::select! { - item = next => match item { - Some(RemoteChainHeadFollowItem::Initialized { - finalized_block_hashes, - finalized_block_runtime, - }) => { - let hash = finalized_block_hashes - .last() - .cloned() - .ok_or_else(|| format!("{label} follow initialized without finalized blocks"))?; - let spec = match finalized_block_runtime { - Some(RuntimeType::Valid(spec)) => spec, - Some(RuntimeType::Invalid { error }) => { - return Err(format!("{label} follow reported an invalid runtime: {error}")); - } - None => { - return Err(format!("{label} follow initialized without runtime metadata")); - } - }; - return Ok((hash, spec)); - } - Some(RemoteChainHeadFollowItem::Stop) | None => { - return Err(format!("{label} follow stopped before initialization")); - } - _ => {} - }, - () = timeout => return Err(format!("{label} follow initialization timed out")), - } - } -} - /// Wait for one runtime-call operation's output from a `chainHead_v1_follow` /// stream. pub(crate) async fn wait_for_chain_head_call_output( diff --git a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs index 09269aab..ec9a69a7 100644 --- a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs @@ -27,13 +27,12 @@ use truapi::v01::{ RemoteChainHeadFollowItem, RemoteChainHeadFollowRequest, RemoteChainHeadHeaderRequest, RemoteChainHeadStorageRequest, RemoteChainHeadUnpinRequest, RemoteChainTransactionBroadcastRequest, RemoteChainTransactionStopRequest, RuntimeSpec, - StorageQueryItem, StorageQueryType, + RuntimeType, StorageQueryItem, StorageQueryType, }; use truapi_platform::BulletinAllowanceKey; use crate::chain_runtime::{ - ChainRuntime, wait_for_chain_head_call_output, wait_for_chain_head_initialized, - wait_for_chain_head_storage_value, + ChainRuntime, wait_for_chain_head_call_output, wait_for_chain_head_storage_value, }; use crate::host_logic::bulletin::{ MortalityAnchor, build_signed_store_extrinsic, preimage_key, system_events_storage_key, @@ -45,10 +44,14 @@ use crate::host_logic::extrinsic::{ validate_transaction_call_parameters, }; -/// Whole-submission budget, matching the host-side timeout this flow replaces. -const SUBMIT_TIMEOUT: Duration = Duration::from_secs(120); +/// Whole-submission budget, including best-block inclusion. +const SUBMIT_TIMEOUT: Duration = Duration::from_secs(180); /// Budget for the follow to deliver `Initialized`. const INITIALIZATION_TIMEOUT: Duration = Duration::from_secs(10); +/// Budget for a fresh follow to report the current best block after +/// `Initialized`. Falling back to finalized is safe for cold starts; active +/// chains normally report best immediately. +const BEST_BLOCK_TIMEOUT: Duration = Duration::from_secs(2); /// Budget for one pre-broadcast runtime call or storage read. const OPERATION_TIMEOUT: Duration = Duration::from_secs(20); /// Delay before retrying an operation start that hit `LimitReached`, and @@ -69,6 +72,11 @@ const ALLOWANCE_REJECTED_MODULE_ERRORS: &[&str] = /// Where a submission failed, for phase-tagged timeout reasons. type Phase = &'static str; +struct SubmissionHead { + best_hash: Vec, + runtime_spec: RuntimeSpec, +} + /// Typed submission failure driving the retry decision at the runtime call /// site. Wire mapping stays `v01::PreimageSubmitError::Unknown { reason }`. #[derive(Debug, Clone, PartialEq, Eq)] @@ -83,7 +91,7 @@ pub(crate) enum BulletinSubmitError { NonceRace, /// The server had no free broadcast slot; retryable, nothing was sent. BroadcastSlotUnavailable, - /// The 120 s budget elapsed; `phase` names the step reached. + /// 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 }, @@ -210,26 +218,23 @@ impl BulletinRpc { with_runtime: true, }, ); - let (finalized_hash, runtime_spec) = - wait_for_chain_head_initialized(&mut follow, "Bulletin", INITIALIZATION_TIMEOUT) - .await - .map_err(|reason| BulletinSubmitError::ChainUnavailable { reason })?; + let head = wait_for_submission_head(&mut follow).await?; self.enter_phase("metadata"); let state = self - .offline_chain_state(&follow_id, &mut follow, &finalized_hash, runtime_spec) + .offline_chain_state(&follow_id, &mut follow, &head.best_hash, head.runtime_spec) .await?; self.enter_phase("build"); - let anchor = self.mortality_anchor(&follow_id, &finalized_hash).await?; + let anchor = self.mortality_anchor(&follow_id, &head.best_hash).await?; let nonce = self - .account_nonce(&follow_id, &mut follow, &finalized_hash, allowance) + .account_nonce(&follow_id, &mut follow, &head.best_hash, allowance) .await?; let signed = build_signed_store_extrinsic(&state, &anchor, allowance, nonce, value) .map_err(|reason| BulletinSubmitError::InvalidTransaction { kind: reason })?; self.enter_phase("dry-run"); - self.dry_run(&follow_id, &mut follow, &finalized_hash, &signed.extrinsic) + self.dry_run(&follow_id, &mut follow, &head.best_hash, &signed.extrinsic) .await?; self.enter_phase("broadcast"); @@ -556,6 +561,9 @@ impl BulletinRpc { sp_crypto_hashing::blake2_256(extrinsic) == extrinsic_hash }); if let Some(index) = matched { + // Match the prior PAPI host behavior: resolve on + // best-block inclusion. A future API option can let + // callers prefer finalized inclusion. return Ok((block, index as u32)); } checked.insert(block.clone()); @@ -817,6 +825,132 @@ impl BulletinRpc { } } +async fn wait_for_submission_head( + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, +) -> Result { + let timeout = futures_timer::Delay::new(INITIALIZATION_TIMEOUT).fuse(); + pin_mut!(timeout); + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::Initialized { + finalized_block_hashes, + finalized_block_runtime, + }) => { + let finalized_hash = finalized_block_hashes + .last() + .cloned() + .ok_or_else(|| BulletinSubmitError::ChainUnavailable { + reason: "Bulletin follow initialized without finalized blocks" + .to_string(), + })?; + let runtime_spec = valid_runtime( + finalized_block_runtime, + "Bulletin follow initialized without runtime metadata", + )?; + return wait_for_submission_best_after_initialization( + follow, + finalized_hash, + runtime_spec, + ) + .await; + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return Err(BulletinSubmitError::ChainUnavailable { + reason: "Bulletin follow stopped before initialization".to_string(), + }); + } + _ => {} + }, + () = timeout => { + return Err(BulletinSubmitError::ChainUnavailable { + reason: "Bulletin follow initialization timed out".to_string(), + }); + }, + } + } +} + +async fn wait_for_submission_best_after_initialization( + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + finalized_hash: Vec, + finalized_runtime: RuntimeSpec, +) -> Result { + let timeout = futures_timer::Delay::new(BEST_BLOCK_TIMEOUT).fuse(); + pin_mut!(timeout); + let mut known_runtimes = HashMap::from([(finalized_hash.clone(), finalized_runtime.clone())]); + let mut candidate_hash = finalized_hash; + let mut candidate_runtime = finalized_runtime.clone(); + + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { + let runtime_spec = known_runtimes + .get(&best_block_hash) + .cloned() + .unwrap_or_else(|| candidate_runtime.clone()); + return Ok(SubmissionHead { + best_hash: best_block_hash, + runtime_spec, + }); + } + Some(RemoteChainHeadFollowItem::NewBlock { + block_hash, + parent_block_hash, + new_runtime, + }) => { + let runtime_spec = if let Some(runtime) = new_runtime { + valid_runtime( + Some(runtime), + "Bulletin follow reported a new block without runtime metadata", + )? + } else { + known_runtimes + .get(&parent_block_hash) + .cloned() + .unwrap_or_else(|| candidate_runtime.clone()) + }; + known_runtimes.insert(block_hash.clone(), runtime_spec.clone()); + candidate_hash = block_hash; + candidate_runtime = runtime_spec; + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return Err(BulletinSubmitError::ChainUnavailable { + reason: "Bulletin follow stopped before best block".to_string(), + }); + } + _ => {} + }, + () = timeout => { + return Ok(SubmissionHead { + best_hash: candidate_hash, + runtime_spec: candidate_runtime, + }); + }, + } + } +} + +fn valid_runtime( + runtime: Option, + missing_reason: &'static str, +) -> Result { + match runtime { + Some(RuntimeType::Valid(spec)) => Ok(spec), + Some(RuntimeType::Invalid { error }) => Err(BulletinSubmitError::ChainUnavailable { + reason: format!("Bulletin follow reported an invalid runtime: {error}"), + }), + None => Err(BulletinSubmitError::ChainUnavailable { + reason: missing_reason.to_string(), + }), + } +} + /// Collect `start` and its not-yet-checked ancestors from a provider-supplied /// `parents` map, oldest lookups first. /// @@ -851,6 +985,42 @@ mod tests { vec![byte] } + fn runtime_spec(spec_version: u32) -> RuntimeSpec { + RuntimeSpec { + spec_name: "test".to_string(), + impl_name: "test".to_string(), + spec_version, + impl_version: 1, + transaction_version: Some(1), + apis: Vec::new(), + } + } + + #[test] + fn submission_head_uses_best_block_runtime_update() { + let mut follow = futures::stream::iter(vec![ + RemoteChainHeadFollowItem::Initialized { + finalized_block_hashes: vec![h(1)], + finalized_block_runtime: Some(RuntimeType::Valid(runtime_spec(1))), + }, + RemoteChainHeadFollowItem::NewBlock { + block_hash: h(2), + parent_block_hash: h(1), + new_runtime: Some(RuntimeType::Valid(runtime_spec(2))), + }, + RemoteChainHeadFollowItem::BestBlockChanged { + best_block_hash: h(2), + }, + ]) + .boxed(); + + let head = + futures::executor::block_on(wait_for_submission_head(&mut follow)).expect("head"); + + assert_eq!(head.best_hash, h(2)); + assert_eq!(head.runtime_spec.spec_version, 2); + } + #[test] fn ancestors_walk_collects_unchecked_chain() { // c -> b -> a, none checked: walk collects all three newest-first. diff --git a/rust/crates/truapi/src/api/preimage.rs b/rust/crates/truapi/src/api/preimage.rs index 455d54df..296748e9 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(); + /// 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(); + /// const result = await truapi.preimage.submit(value); /// assert(result.isOk(), "submit failed:", result); /// console.log("preimage submitted:", result.value); /// ``` From e9cf92034d14d286f955aa73a5b665d9567f99b3 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 9 Jul 2026 14:52:50 +0200 Subject: [PATCH 4/9] feat(server): implement local create_transaction via the shared assembler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signing-host role now builds and signs transactions locally instead of returning Unavailable, reusing host_logic/extrinsic.rs. Because ProductAccountTxPayload carries each extension's `extra` and `additional_signed` already SCALE-encoded in canonical order, assembly is a pure offline concatenation — no metadata, no RPC: - extrinsic.rs gains build_signed_extrinsic_v4 + v4_signer_payload and an Sr25519Signer::from_keypair constructor. Body = Compact(len) ++ 0x84 ++ MultiAddress::Id(signer) ++ MultiSignature::Sr25519(sig) ++ Σextra ++ call_data; signer payload = call_data ++ Σextra ++ Σadditional_signed, blake2_256 only when >256 bytes. Layout is byte-identical to subxt / frame-decode. - signing_host::create_transaction handles Product and LegacyAccount (with a fail-closed slot-zero key-match check); the product-facing entrypoint's caller-scoping, chain-submit permission, and user-confirmation gates already precede it. - Extrinsic V5 (tx_ext_version != 0) returns the new AuthorityError::NotSupported -> HostCreateTransactionError::NotSupported: V5 general carries the signature inside a VerifySignature extension, which cannot come from pre-encoded parts. Tested: v4 layout + signature verification, the >256 hashing boundary, extension order preservation, and Product/LegacyAccount success plus v5/mismatch/no-session rejections. --- .../truapi-server/src/host_logic/extrinsic.rs | 200 +++++++++++++- rust/crates/truapi-server/src/runtime.rs | 15 +- .../truapi-server/src/runtime/authority.rs | 4 + .../truapi-server/src/runtime/signing_host.rs | 248 +++++++++++++++++- 4 files changed, 445 insertions(+), 22 deletions(-) diff --git a/rust/crates/truapi-server/src/host_logic/extrinsic.rs b/rust/crates/truapi-server/src/host_logic/extrinsic.rs index 9203eaa1..0847610c 100644 --- a/rust/crates/truapi-server/src/host_logic/extrinsic.rs +++ b/rust/crates/truapi-server/src/host_logic/extrinsic.rs @@ -1,12 +1,15 @@ -//! Offline, metadata-driven extrinsic construction shared by chain-facing -//! runtime services. +//! Offline extrinsic construction shared by chain-facing runtime services. //! -//! Built on subxt's offline client: the caller supplies fetched runtime -//! metadata, spec/transaction versions, and an explicit nonce/mortality -//! anchor; nothing here performs I/O. Bulletin preimage submission is the -//! first consumer; local `create_transaction` assembly is the planned second. +//! Two assembly modes, both offline (no I/O): one metadata-driven, one from +//! pre-encoded parts. +//! +//! - Bulletin preimage submission builds a call from fetched runtime metadata +//! via subxt's offline client (typed nonce/mortality params). +//! - Local `create_transaction` ([`build_signed_extrinsic_v4`]) assembles a +//! signed V4 extrinsic from caller-supplied, already-SCALE-encoded parts, so +//! it needs no metadata at all. -use parity_scale_codec::{Compact, Decode}; +use parity_scale_codec::{Compact, Decode, Encode}; use schnorrkel::{PublicKey, SecretKey}; use subxt::client::{OfflineClient, OfflineClientAtBlock}; use subxt::config::substrate::{ @@ -17,7 +20,8 @@ use subxt::ext::frame_metadata::RuntimeMetadataPrefixed; use subxt::metadata::{ArcMetadata, Metadata}; use subxt::tx::Signer; use subxt::tx::{TransactionInvalid, TransactionUnknown, TransactionValid}; -use subxt::utils::{AccountId32, H256, MultiSignature}; +use subxt::utils::{AccountId32, H256, MultiAddress, MultiSignature}; +use truapi::v01; use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; @@ -59,6 +63,15 @@ impl Sr25519Signer { 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, + } + } + /// Public key of the signing account. pub(crate) fn public_key(&self) -> [u8; 32] { self.public.to_bytes() @@ -87,6 +100,70 @@ impl Signer for Sr25519Signer { } } +/// 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() +} + /// Everything needed to build transactions offline for one chain at one /// runtime version. /// @@ -437,4 +514,111 @@ pub(crate) mod tests { assert_eq!(¶meters[1..3], &[0xaa, 0xbb]); assert_eq!(¶meters[3..], &[0xcc; 32]); } + + 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). + 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.public_key()); + 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/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 0172dfbf..95664262 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -970,9 +970,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 }, } } @@ -987,9 +987,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 }, })) } @@ -1004,6 +1004,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 } } diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index 54126aca..4cbdbc11 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -70,6 +70,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 }, diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 7e39dde1..67ebeb34 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( @@ -274,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. /// @@ -315,13 +369,16 @@ 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::product_account::{ derive_product_keypair, derive_root_keypair_from_entropy, }; use crate::test_support::{StubPlatform, test_spawner}; + use parity_scale_codec::{Compact, Decode}; use truapi::api::{Account, Entropy, Signing}; use truapi::versioned::account::{HostAccountGetError, HostAccountGetRequest}; use truapi::versioned::entropy::HostDeriveEntropyRequest; @@ -434,6 +491,181 @@ 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, + } + } + + /// Split a length-prefixed V4 signed extrinsic into + /// (account, signature, trailing bytes). + 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); + assert_eq!(input[0], 0x84); + assert_eq!(input[1], 0x00); + let account: [u8; 32] = input[2..34].try_into().unwrap(); + assert_eq!(input[34], 0x01); + let signature: [u8; 64] = input[35..99].try_into().unwrap(); + (account, signature, input[99..].to_vec()) + } + + #[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(); From 8a98b5b1a35cbb6de994afa785813809cb71178a Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 9 Jul 2026 15:30:50 +0200 Subject: [PATCH 5/9] refactor(server): simplify bulletin preimage config --- hosts/dotli | 2 +- js/packages/truapi-host/src/runtime.ts | 6 +- .../src/web/worker-provider.test.ts | 4 + .../truapi-codegen/src/rust/wasm_bridge.rs | 34 +----- .../truapi-codegen/src/ts/host_callbacks.rs | 71 +----------- rust/crates/truapi-platform/src/lib.rs | 40 ++----- rust/crates/truapi-platform/tests/bounds.rs | 1 + .../truapi-server/src/host_logic/bulletin.rs | 106 ++---------------- .../src/host_logic/sso/pairing.rs | 1 + rust/crates/truapi-server/src/runtime.rs | 48 +------- .../truapi-server/src/runtime/services.rs | 12 +- .../truapi-server/src/runtime/signing_host.rs | 1 + .../src/runtime/statement_store.rs | 2 +- rust/crates/truapi-server/src/test_support.rs | 1 + rust/crates/truapi-server/src/wasm.rs | 22 ++-- rust/crates/truapi-server/tests/common/mod.rs | 1 + .../tests/wasm_crypto_vectors.rs | 1 + 17 files changed, 51 insertions(+), 302 deletions(-) diff --git a/hosts/dotli b/hosts/dotli index 762370c3..df79ee14 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit 762370c3111ff2891b5f845435ce988005300fdf +Subproject commit df79ee147041a1b0db71e5773a9bc3aa3f8ac179 diff --git a/js/packages/truapi-host/src/runtime.ts b/js/packages/truapi-host/src/runtime.ts index 748d4e10..7e06329e 100644 --- a/js/packages/truapi-host/src/runtime.ts +++ b/js/packages/truapi-host/src/runtime.ts @@ -69,11 +69,9 @@ export interface ProductRuntimeConfig { genesisHash: string | Uint8Array; }; /** - * Bulletin-chain genesis hash used for in-core preimage submission. Omit - * when the host does not expose the Bulletin chain; preimage submission then - * fails before prompting the user. + * Bulletin-chain genesis hash used for in-core preimage submission. */ - bulletin?: { + bulletin: { genesisHash: string | Uint8Array; }; pairing: { 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..88e031a8 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", }, diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index 5ca2b9c1..64745d50 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -20,11 +20,6 @@ pub fn generate_wasm_bridge( let trait_names = platform_trait_names(definition); validate_errors(&traits, &ctx)?; let mut out = String::new(); - let signer_import = if traits_use_callback_signer(&traits) { - "bulletin_allowance_signer_to_js, " - } else { - "" - }; writedoc!( out, r#" @@ -41,7 +36,7 @@ pub fn generate_wasm_bridge( use wasm_bindgen::JsValue; use super::{{ - WasmPlatform, {signer_import}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, @@ -533,9 +528,6 @@ fn js_arg_expr(name: &str, ty: &TypeRef, ctx: &BridgeCtx<'_>) -> Result 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()" @@ -857,32 +849,10 @@ 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)) -} - -/// Whether any composed trait method takes a callback signer parameter, which -/// determines whether the generated bridge references -/// `bulletin_allowance_signer_to_js`. -fn traits_use_callback_signer(traits: &[&PlatformTrait]) -> bool { - traits.iter().any(|trait_def| { - trait_def.methods.iter().any(|method| { - method - .params - .iter() - .any(|param| is_callback_signer_type(¶m.type_ref)) - }) - }) -} - fn is_callback_special_type_name(name: &str) -> bool { - is_callback_byte_type_name(name) || is_callback_signer_type_name(name) + is_callback_byte_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..090f226c 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -125,11 +125,6 @@ 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() @@ -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() { @@ -734,9 +698,6 @@ fn raw_param_ts( ) -> 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(), @@ -762,9 +723,6 @@ fn raw_ok_ts( ) -> 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(), @@ -1557,9 +1515,6 @@ fn ts_type(ty: &TypeRef) -> Result { 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 { @@ -1601,30 +1556,8 @@ 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; - }} - "# - } + is_callback_byte_type_name(name) } fn render_jsdoc(indent: &str, docs: Option<&str>) -> String { diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index fbdb1a0e..5ef02b10 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -48,10 +48,7 @@ pub struct PairingHostConfig { /// 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. - /// - /// `None` when the host does not expose the Bulletin chain; preimage - /// submission then fails before prompting the user. - pub bulletin_chain_genesis_hash: Option<[u8; 32]>, + 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 @@ -71,10 +68,7 @@ pub struct SigningHostConfig { /// 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. - /// - /// `None` when the host does not expose the Bulletin chain; preimage - /// submission then fails before prompting the user. - pub bulletin_chain_genesis_hash: Option<[u8; 32]>, + pub bulletin_chain_genesis_hash: [u8; 32], } /// Product identity attached to one product-facing TrUAPI connection. @@ -145,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)?; @@ -156,18 +151,11 @@ impl PairingHostConfig { let config = Self { host: HostRuntimeConfig::new(host_info, platform_info)?, people_chain_genesis_hash, - bulletin_chain_genesis_hash: None, + bulletin_chain_genesis_hash, pairing_deeplink_scheme, }; Ok(config) } - - /// Set the Bulletin-chain genesis hash used for in-core preimage - /// submission. - pub fn with_bulletin_chain_genesis_hash(mut self, genesis_hash: [u8; 32]) -> Self { - self.bulletin_chain_genesis_hash = Some(genesis_hash); - self - } } impl SigningHostConfig { @@ -177,20 +165,14 @@ 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: None, + bulletin_chain_genesis_hash, }) } - - /// Set the Bulletin-chain genesis hash used for in-core preimage - /// submission. - pub fn with_bulletin_chain_genesis_hash(mut self, genesis_hash: [u8; 32]) -> Self { - self.bulletin_chain_genesis_hash = Some(genesis_hash); - self - } } impl ProductContext { @@ -639,7 +621,7 @@ pub trait ThemeHost: Send + Sync { /// /// The core is the sole holder: the secret never crosses the host boundary. /// Zeroized on drop, and its `Debug` redacts the material. -#[derive(Clone, zeroize::Zeroize, zeroize::ZeroizeOnDrop)] +#[derive(Clone, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop)] pub struct BulletinAllowanceKey { secret: [u8; 64], } @@ -661,14 +643,6 @@ impl BulletinAllowanceKey { } } -impl PartialEq for BulletinAllowanceKey { - fn eq(&self, other: &Self) -> bool { - self.secret == other.secret - } -} - -impl Eq for BulletinAllowanceKey {} - impl core::fmt::Debug for BulletinAllowanceKey { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("BulletinAllowanceKey") 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/src/host_logic/bulletin.rs b/rust/crates/truapi-server/src/host_logic/bulletin.rs index d1c3df83..3ec79fc5 100644 --- a/rust/crates/truapi-server/src/host_logic/bulletin.rs +++ b/rust/crates/truapi-server/src/host_logic/bulletin.rs @@ -16,12 +16,6 @@ use truapi_platform::BulletinAllowanceKey; use crate::host_logic::extrinsic::{OfflineChainState, Sr25519Signer}; -/// Audited pallet index of `TransactionStorage` on the Bulletin chain. A -/// legitimate runtime renumber must bump this reviewed constant; provider -/// metadata pointing the name elsewhere is rejected. -const TRANSACTION_STORAGE_PALLET_INDEX: u8 = 40; -/// Audited call index of `TransactionStorage.store`. -const STORE_CALL_INDEX: u8 = 0; const STORE_PALLET_NAME: &str = "TransactionStorage"; const STORE_CALL_NAME: &str = "store"; @@ -79,23 +73,12 @@ pub(crate) fn build_signed_store_extrinsic( state.metadata.extrinsic().supported_versions() )); } - require_pinned_store_call(state)?; let signer = allowance_signer(allowance)?; let account = signer.public_key(); let client = state.client_at(anchor.number)?; - let canonical_call = canonical_store_call(&data); let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(data)); - let call_data = client - .tx() - .call_data(&payload) - .map_err(|err| format!("store call encoding failed: {err}"))?; - if call_data != canonical_call { - return Err("metadata-encoded store call diverges from the canonical encoding".to_string()); - } - drop(canonical_call); - let params = DefaultExtrinsicParamsBuilder::::new() .nonce(nonce) .mortal_from_unchecked(MORTAL_PERIOD_BLOCKS, anchor.number, H256(anchor.hash)) @@ -123,45 +106,6 @@ fn allowance_signer(allowance: &BulletinAllowanceKey) -> Result Result<(), String> { - let pallet = state - .metadata - .pallet_by_name(STORE_PALLET_NAME) - .ok_or_else(|| "bulletin metadata has no TransactionStorage pallet".to_string())?; - if pallet.call_index() != TRANSACTION_STORAGE_PALLET_INDEX { - return Err(format!( - "TransactionStorage pallet index {} does not match the audited index {}", - pallet.call_index(), - TRANSACTION_STORAGE_PALLET_INDEX - )); - } - let call = pallet - .call_variant_by_name(STORE_CALL_NAME) - .ok_or_else(|| "bulletin metadata has no TransactionStorage.store call".to_string())?; - if call.index != STORE_CALL_INDEX { - return Err(format!( - "TransactionStorage.store call index {} does not match the audited index {}", - call.index, STORE_CALL_INDEX - )); - } - Ok(()) -} - -/// The canonical `store` call bytes: audited indices followed by the -/// SCALE-encoded preimage (`Compact(len) ++ bytes`). Independent of any -/// provider-supplied metadata. -fn canonical_store_call(data: &[u8]) -> Vec { - let mut call = Vec::with_capacity(2 + 5 + data.len()); - call.push(TRANSACTION_STORAGE_PALLET_INDEX); - call.push(STORE_CALL_INDEX); - Compact(data.len() as u32).encode_to(&mut call); - call.extend_from_slice(data); - call -} - /// `store { data: Vec }` call arguments with a byte-level fast path. /// /// scale-encode has no `u8` specialization, so encoding the preimage as a @@ -335,10 +279,16 @@ mod tests { ); let (account, signature, tail) = split_v4_signed(&signed.extrinsic); assert_eq!(account, signed.account); - assert!(tail.ends_with(&canonical_store_call(&data))); + let client = state.client_at(anchor_fixture().number).unwrap(); + let payload = StaticPayload::new( + STORE_PALLET_NAME, + STORE_CALL_NAME, + StoreCallData(data.clone()), + ); + 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 client = state.client_at(anchor_fixture().number).unwrap(); let params = DefaultExtrinsicParamsBuilder::::new() .nonce(7) .mortal_from_unchecked( @@ -411,30 +361,6 @@ mod tests { ); } - #[test] - fn rejects_relocated_store_call() { - let mut metadata = bulletin_metadata_v14(); - for pallet in &mut metadata.pallets { - if pallet.name == "TransactionStorage" { - pallet.index = 41; - } - } - let state = state_with_metadata(metadata_from_v14(metadata)); - - let error = build_signed_store_extrinsic( - &state, - &anchor_fixture(), - &allowance_fixture(), - 0, - vec![1, 2, 3], - ) - .unwrap_err(); - assert!( - error.contains("does not match the audited index"), - "{error}" - ); - } - #[test] fn rejects_mutated_store_argument_type() { // Point the store call's `data` field at a non-u8-sequence type: the @@ -554,22 +480,6 @@ mod tests { ); } - #[test] - fn canonical_call_matches_metadata_encoding() { - let state = bulletin_chain_state(); - let client = state.client_at(1).unwrap(); - let data = vec![9u8; 300]; - let payload = StaticPayload::new( - STORE_PALLET_NAME, - STORE_CALL_NAME, - StoreCallData(data.clone()), - ); - assert_eq!( - client.tx().call_data(&payload).unwrap(), - canonical_store_call(&data) - ); - } - #[test] fn builds_large_preimage_without_pathological_cost() { // The store call data must not encode per byte (scale-encode has no u8 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..e827e506 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -486,6 +486,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/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 95664262..3bb3cc4a 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -258,19 +258,18 @@ impl ProductRuntimeHost { }, truapi_platform::PlatformInfo::default(), [0; 32], + [0xbb; 32], "polkadotapp".to_string(), ) .expect("compat runtime config is valid") } - /// Compat host with a Bulletin genesis configured, so preimage submission - /// reaches the permission/confirmation gates instead of the pre-prompt - /// unconfigured check. + /// 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().with_bulletin_chain_genesis_hash([0xbb; 32]), + Self::compat_host_config(), ProductContext::new("unknown.dot".to_string()) .expect("compat product context is valid"), spawner, @@ -1819,13 +1818,7 @@ impl Preimage for ProductRuntimeHost { let Some(session) = self.authority.current_session() else { return Err(preimage_submit_error("No active session".to_string())); }; - // Fail before any user-facing prompt when the host cannot submit - // bulletin transactions at all (no bulletin genesis configured). - let Some(bulletin) = self.services.bulletin.as_ref() else { - return Err(preimage_submit_error( - "bulletin chain unavailable on this host".to_string(), - )); - }; + let bulletin = &self.services.bulletin; self.require_remote_permission( v01::RemotePermission::PreimageSubmit, RemotePreimageSubmitError::V1(v01::PreimageSubmitError::Unknown { @@ -2860,39 +2853,6 @@ mod tests { } } - #[test] - fn preimage_submit_unconfigured_bulletin_fails_before_prompts() { - // Default compat host has no bulletin genesis, so submission must fail - // before any permission prompt or user confirmation, and before any - // chain traffic. - let platform = Arc::new(StubPlatform { - remote_permission_denied: true, - ..Default::default() - }); - let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); - host.test_session_state().set_session(session_info()); - let cx = CallContext::new(); - let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); - - let err = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap_err(); - - match err { - CallError::Domain(RemotePreimageSubmitError::V1( - v01::PreimageSubmitError::Unknown { reason }, - )) => assert_eq!(reason, "bulletin chain unavailable on this host"), - other => panic!("expected bulletin-unavailable error, got {other:?}"), - } - // The pre-prompt gate wins over the (denied) permission check, and no - // chain request was sent. - assert!( - platform - .sent_rpc - .lock() - .expect("rpc list mutex poisoned") - .is_empty() - ); - } - #[test] fn preimage_submit_requires_remote_permission_before_backend_call() { let platform = Arc::new(StubPlatform { diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 293bf264..0bef5cd9 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -24,9 +24,8 @@ pub(crate) struct RuntimeServices { pub(crate) platform: Arc, pub(crate) chain: ChainRuntime, pub(crate) statement_store: StatementStoreRpc, - /// In-core Bulletin submission, `None` when no bulletin genesis was - /// configured by the host. - pub(crate) bulletin: Option, + /// 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, @@ -36,12 +35,12 @@ pub(crate) struct RuntimeServices { impl RuntimeServices { /// Build role-neutral runtime services from the platform, the People-chain - /// genesis hash used by statement-store backed protocols, and the optional + /// 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: Option<[u8; 32]>, + bulletin_chain_genesis_hash: [u8; 32], spawner: Spawner, ) -> Arc { let chain_provider = Arc::new(HostChainProvider { @@ -50,8 +49,7 @@ 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 = bulletin_chain_genesis_hash - .map(|genesis_hash| BulletinRpc::new(chain.clone(), genesis_hash)); + let bulletin = BulletinRpc::new(chain.clone(), bulletin_chain_genesis_hash); Arc::new(Self { platform, chain, diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 67ebeb34..a2761b42 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -403,6 +403,7 @@ mod tests { }, PlatformInfo::default(), [0; 32], + [0xbb; 32], ) .expect("signing host config is valid"); let services = RuntimeServices::new( diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index 85d163a7..a5fa4c91 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], None, 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"); diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index e8621c3d..de983607 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -193,6 +193,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"), diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index 1c7bd896..195ea379 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -414,9 +414,9 @@ fn pairing_host_config_from_js(value: &JsValue) -> Result Result config.with_bulletin_chain_genesis_hash(genesis), - None => config, - }) + .map_err(runtime_config_validation_to_js) } fn product_context_from_js(value: &JsValue) -> Result { @@ -472,6 +467,7 @@ fn runtime_config_field_to_js(field: &str) -> &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/tests/common/mod.rs b/rust/crates/truapi-server/tests/common/mod.rs index a5548b2f..33a5b8cb 100644 --- a/rust/crates/truapi-server/tests/common/mod.rs +++ b/rust/crates/truapi-server/tests/common/mod.rs @@ -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"), 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") From 60b43eab1971f7e5bda19cccfcb6eac3f2fbfaca Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 9 Jul 2026 18:00:30 +0200 Subject: [PATCH 6/9] update --- .../src/host-callbacks-adapter.test.ts | 8 - .../src/web/worker-provider.test.ts | 71 ------- .../truapi-codegen/src/rust/wasm_bridge.rs | 17 +- .../truapi-codegen/src/ts/host_callbacks.rs | 34 ++- .../tests/golden/host-callbacks-adapter.ts | 3 - .../truapi-server/src/host_logic/bulletin.rs | 130 +++++------- .../truapi-server/src/host_logic/extrinsic.rs | 39 ++-- rust/crates/truapi-server/src/runtime.rs | 42 ++-- .../truapi-server/src/runtime/bulletin_rpc.rs | 197 +++++++++++++----- .../truapi-server/src/runtime/signing_host.rs | 16 +- 10 files changed, 252 insertions(+), 305 deletions(-) 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/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts index 88e031a8..7a0cc086 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -525,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/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index 64745d50..a0e3a954 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -525,9 +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 ctx.is_api_codec(ty) || ctx.is_local_codec(ty) { return Ok(format!( "Uint8Array::from({name}.encode().as_slice()).into()" @@ -826,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 { @@ -844,15 +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_special_type_name(name: &str) -> bool { - is_callback_byte_type_name(name) -} - -fn is_callback_byte_type_name(name: &str) -> bool { - name == "BulletinAllowanceKey" -} diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index 090f226c..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::>(); @@ -128,7 +128,7 @@ fn emit_host_callbacks( 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)?, @@ -697,7 +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 codec_types.contains(name) => "Uint8Array".to_string(), TypeRef::Named { name, .. } if local_codec_types.contains(name) => "Uint8Array".to_string(), TypeRef::Named { name, .. } => name.clone(), @@ -722,7 +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 codec_types.contains(name) => "Uint8Array".to_string(), TypeRef::Named { name, .. } if local_codec_types.contains(name) => "Uint8Array".to_string(), TypeRef::Named { name, .. } => name.clone(), @@ -804,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) => { @@ -1031,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 { @@ -1431,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 { @@ -1455,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 @@ -1512,9 +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 args.is_empty() { Ok(name.clone()) } else { @@ -1552,14 +1550,6 @@ fn ts_type(ty: &TypeRef) -> Result { } } -fn is_callback_byte_type_name(name: &str) -> bool { - name == "BulletinAllowanceKey" -} - -fn is_callback_special_type_name(name: &str) -> bool { - is_callback_byte_type_name(name) -} - 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-server/src/host_logic/bulletin.rs b/rust/crates/truapi-server/src/host_logic/bulletin.rs index 3ec79fc5..80ce974d 100644 --- a/rust/crates/truapi-server/src/host_logic/bulletin.rs +++ b/rust/crates/truapi-server/src/host_logic/bulletin.rs @@ -5,9 +5,10 @@ //! takes raw preimage bytes plus a [`BulletinAllowanceKey`], never //! caller-supplied call data. -use parity_scale_codec::{Compact, Encode}; +use parity_scale_codec::{Compact, Decode, Encode}; use subxt::config::DefaultExtrinsicParamsBuilder; use subxt::config::substrate::SubstrateConfig; +use subxt::ext::frame_decode::storage::encode_storage_key_prefix; use subxt::ext::scale_encode::{self, EncodeAsFields, FieldIter, TypeResolver}; use subxt::ext::scale_type_resolver::{Primitive, visitor}; use subxt::tx::StaticPayload; @@ -16,8 +17,8 @@ use truapi_platform::BulletinAllowanceKey; use crate::host_logic::extrinsic::{OfflineChainState, Sr25519Signer}; -const STORE_PALLET_NAME: &str = "TransactionStorage"; -const STORE_CALL_NAME: &str = "store"; +pub(crate) const STORE_PALLET_NAME: &str = "TransactionStorage"; +pub(crate) const STORE_CALL_NAME: &str = "store"; /// Mortality window for store transactions. Must stay <= 4096 so the era /// phase quantization is a no-op and the anchor block is the era birth block @@ -32,10 +33,7 @@ pub(crate) fn preimage_key(value: &[u8]) -> [u8; 32] { /// Storage key of the plain `System.Events` value. pub(crate) fn system_events_storage_key() -> Vec { - let mut key = Vec::with_capacity(32); - key.extend_from_slice(&sp_crypto_hashing::twox_128(b"System")); - key.extend_from_slice(&sp_crypto_hashing::twox_128(b"Events")); - key + encode_storage_key_prefix("System", "Events").to_vec() } /// Finalized block the transaction's mortality is anchored at. @@ -65,7 +63,7 @@ pub(crate) fn build_signed_store_extrinsic( anchor: &MortalityAnchor, allowance: &BulletinAllowanceKey, nonce: u64, - data: Vec, + data: &[u8], ) -> Result { if !state.metadata.extrinsic().supported_versions().contains(&4) { return Err(format!( @@ -113,9 +111,9 @@ fn allowance_signer(allowance: &BulletinAllowanceKey) -> Result); +struct StoreCallData<'a>(&'a [u8]); -impl EncodeAsFields for StoreCallData { +impl EncodeAsFields for StoreCallData<'_> { fn encode_as_fields_to( &self, fields: &mut dyn FieldIter<'_, R::TypeId>, @@ -133,7 +131,7 @@ impl EncodeAsFields for StoreCallData { require_u8_sequence(types, field.id)?; Compact(self.0.len() as u32).encode_to(out); - out.extend_from_slice(&self.0); + out.extend_from_slice(self.0); Ok(()) } } @@ -166,12 +164,21 @@ fn require_u8_sequence( Ok(()) } +/// Return `true` when raw `store.data` field bytes hash to `key`. +pub(crate) fn store_data_field_matches_key(field_bytes: &[u8], key: &[u8; 32]) -> bool { + let mut input = field_bytes; + let Ok(Compact(len)) = Compact::::decode(&mut input) else { + return false; + }; + input.len() == len as usize && preimage_key(input) == *key +} + #[cfg(test)] mod tests { use super::*; - use crate::host_logic::extrinsic::tests::bulletin_chain_state; + use crate::host_logic::extrinsic::tests::{bulletin_chain_state, split_v4}; use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; - use parity_scale_codec::{Compact, Decode}; + use parity_scale_codec::Decode; use schnorrkel::{PublicKey, Signature}; use subxt::ext::frame_metadata::{RuntimeMetadata, RuntimeMetadataPrefixed, v14}; use subxt::metadata::{ArcMetadata, Metadata}; @@ -230,20 +237,6 @@ mod tests { .clone() } - /// Split a length-prefixed v4 signed extrinsic into (account, signature, - /// trailing bytes after the signature). - fn split_v4_signed(extrinsic: &[u8]) -> ([u8; 32], [u8; 64], Vec) { - let mut input = extrinsic; - let length = Compact::::decode(&mut input).unwrap().0 as usize; - assert_eq!(input.len(), length); - assert_eq!(input[0], 0x84, "expected a v4 signed extrinsic"); - assert_eq!(input[1], 0x00, "expected a MultiAddress::Id address"); - let account: [u8; 32] = input[2..34].try_into().unwrap(); - assert_eq!(input[34], 0x01, "expected a MultiSignature::Sr25519"); - let signature: [u8; 64] = input[35..99].try_into().unwrap(); - (account, signature, input[99..].to_vec()) - } - #[test] fn preimage_key_is_blake2b_256() { assert_eq!( @@ -264,27 +257,18 @@ mod tests { fn builds_and_signs_store_extrinsic_against_fixture() { let state = bulletin_chain_state(); let data = b"hello bulletin".to_vec(); - let signed = build_signed_store_extrinsic( - &state, - &anchor_fixture(), - &allowance_fixture(), - 7, - data.clone(), - ) - .unwrap(); + let signed = + build_signed_store_extrinsic(&state, &anchor_fixture(), &allowance_fixture(), 7, &data) + .unwrap(); assert_eq!( signed.extrinsic_hash, sp_crypto_hashing::blake2_256(&signed.extrinsic) ); - let (account, signature, tail) = split_v4_signed(&signed.extrinsic); + let (account, signature, tail) = split_v4(&signed.extrinsic); assert_eq!(account, signed.account); let client = state.client_at(anchor_fixture().number).unwrap(); - let payload = StaticPayload::new( - STORE_PALLET_NAME, - STORE_CALL_NAME, - StoreCallData(data.clone()), - ); + 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)); @@ -297,7 +281,7 @@ mod tests { H256(anchor_fixture().hash), ) .build(); - let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(data)); + let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(&data)); let signer_payload = client .tx() .create_v4_signable_offline(&payload, params) @@ -324,10 +308,10 @@ mod tests { &anchor_fixture(), &allowance_fixture(), 0, - data.clone(), + &data, ) .unwrap(); - let (account, signature, _) = split_v4_signed(&signed.extrinsic); + let (account, signature, _) = split_v4(&signed.extrinsic); let mutated_state = OfflineChainState { genesis_hash: [0xcc; 32], @@ -341,7 +325,7 @@ mod tests { H256(anchor_fixture().hash), ) .build(); - let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(data)); + let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(&data)); let mutated_payload = client .tx() .create_v4_signable_offline(&payload, params) @@ -399,7 +383,7 @@ mod tests { &anchor_fixture(), &allowance_fixture(), 0, - vec![1, 2, 3], + &[1, 2, 3], ) .unwrap_err(); assert!(error.contains("not a"), "{error}"); @@ -413,14 +397,9 @@ mod tests { metadata.extrinsic.signed_extensions.push(fake); let state = state_with_metadata(metadata_from_v14(metadata)); - let error = build_signed_store_extrinsic( - &state, - &anchor_fixture(), - &allowance_fixture(), - 0, - vec![1], - ) - .unwrap_err(); + let error = + build_signed_store_extrinsic(&state, &anchor_fixture(), &allowance_fixture(), 0, &[1]) + .unwrap_err(); assert!(error.contains("FakeImplicitExt"), "{error}"); } @@ -432,14 +411,9 @@ mod tests { metadata.extrinsic.signed_extensions.push(fake); let state = state_with_metadata(metadata_from_v14(metadata)); - let error = build_signed_store_extrinsic( - &state, - &anchor_fixture(), - &allowance_fixture(), - 0, - vec![1], - ) - .unwrap_err(); + let error = + build_signed_store_extrinsic(&state, &anchor_fixture(), &allowance_fixture(), 0, &[1]) + .unwrap_err(); assert!(error.contains("FakeValueExt"), "{error}"); } @@ -462,17 +436,12 @@ mod tests { &anchor_fixture(), &allowance_fixture(), 0, - vec![1], - ) - .unwrap(); - let with_fake = build_signed_store_extrinsic( - &state, - &anchor_fixture(), - &allowance_fixture(), - 0, - vec![1], + &[1], ) .unwrap(); + let with_fake = + build_signed_store_extrinsic(&state, &anchor_fixture(), &allowance_fixture(), 0, &[1]) + .unwrap(); assert_eq!( with_fake.extrinsic.len(), baseline.extrinsic.len() + 1, @@ -493,7 +462,7 @@ mod tests { &anchor_fixture(), &allowance_fixture(), 0, - data.clone(), + &data, ) .unwrap(); let elapsed = start.elapsed(); @@ -504,6 +473,21 @@ mod tests { ); } + #[test] + fn store_field_match_hashes_without_allocating_value() { + let value = b"hello bulletin"; + let key = preimage_key(value); + let mut field = Compact(value.len() as u32).encode(); + field.extend_from_slice(value); + + assert!(store_data_field_matches_key(&field, &key)); + assert!(!store_data_field_matches_key(&field, &[1; 32])); + assert!(!store_data_field_matches_key( + &field[..field.len() - 1], + &key + )); + } + #[test] fn rejects_secret_of_wrong_shape() { let error = build_signed_store_extrinsic( @@ -511,7 +495,7 @@ mod tests { &anchor_fixture(), &BulletinAllowanceKey::from_secret_bytes(vec![0xff; 64]).unwrap(), 0, - vec![1], + &[1], ) .unwrap_err(); assert!(error.contains("invalid bulletin allowance key"), "{error}"); diff --git a/rust/crates/truapi-server/src/host_logic/extrinsic.rs b/rust/crates/truapi-server/src/host_logic/extrinsic.rs index 0847610c..83c7f82c 100644 --- a/rust/crates/truapi-server/src/host_logic/extrinsic.rs +++ b/rust/crates/truapi-server/src/host_logic/extrinsic.rs @@ -19,7 +19,7 @@ use subxt::error::DispatchError; use subxt::ext::frame_metadata::RuntimeMetadataPrefixed; use subxt::metadata::{ArcMetadata, Metadata}; use subxt::tx::Signer; -use subxt::tx::{TransactionInvalid, TransactionUnknown, TransactionValid}; +use subxt::tx::{TransactionInvalid, TransactionUnknown, TransactionValid, ValidationResult}; use subxt::utils::{AccountId32, H256, MultiAddress, MultiSignature}; use truapi::v01; @@ -229,12 +229,11 @@ pub(crate) fn decode_header_block_number(header: &[u8]) -> Result { Ok(header.number) } -/// Decoded `TaggedTransactionQueue_validate_transaction` output. -#[derive(Debug, Clone, PartialEq)] -pub(crate) enum TransactionValidity { - Valid(TransactionValid), - Invalid(TransactionInvalid), - Unknown(TransactionUnknown), +/// Decode an `AccountNonceApi_account_nonce` runtime-call result. +pub(crate) fn decode_account_nonce(bytes: &[u8]) -> Result { + u32::decode(&mut &bytes[..]) + .map(u64::from) + .map_err(|err| format!("invalid account nonce response: {err}")) } /// SCALE-encode the `TaggedTransactionQueue_validate_transaction` parameters: @@ -251,22 +250,24 @@ pub(crate) fn validate_transaction_call_parameters(extrinsic: &[u8], block_hash: parameters } -/// Decode `TransactionValidity = Result` from a validate-transaction runtime call. +/// Decode the runtime's `TransactionValidity = Result` into subxt's [`ValidationResult`]. /// -/// Only the outer discriminants are hand-decoded; payloads reuse subxt's -/// public transaction-validity types so variant indices stay canonical. -pub(crate) fn decode_transaction_validity(bytes: &[u8]) -> Result { +/// subxt's own `ValidationResult::try_from_bytes` is `pub(crate)` and only +/// reachable through an online backend, so the outer discriminants are +/// hand-decoded here; the payloads reuse subxt's public transaction-validity +/// types so variant indices stay canonical. +pub(crate) fn decode_transaction_validity(bytes: &[u8]) -> Result { let map_err = |err: parity_scale_codec::Error| format!("invalid TransactionValidity: {err}"); match (bytes.first(), bytes.get(1)) { (Some(0), _) => TransactionValid::decode(&mut &bytes[1..]) - .map(TransactionValidity::Valid) + .map(ValidationResult::Valid) .map_err(map_err), (Some(1), Some(0)) => TransactionInvalid::decode(&mut &bytes[2..]) - .map(TransactionValidity::Invalid) + .map(ValidationResult::Invalid) .map_err(map_err), (Some(1), Some(1)) => TransactionUnknown::decode(&mut &bytes[2..]) - .map(TransactionValidity::Unknown) + .map(ValidationResult::Unknown) .map_err(map_err), _ => Err(format!( "invalid TransactionValidity discriminant: 0x{}", @@ -483,7 +484,7 @@ pub(crate) mod tests { bytes.extend((5u64, Vec::>::new(), vec![vec![1u8, 2]], 32u64, true).encode()); assert_eq!( decode_transaction_validity(&bytes).unwrap(), - TransactionValidity::Valid(TransactionValid { + ValidationResult::Valid(TransactionValid { priority: 5, requires: vec![], provides: vec![vec![1, 2]], @@ -495,13 +496,13 @@ pub(crate) mod tests { // Invalid::Payment is variant index 1. assert_eq!( decode_transaction_validity(&[1, 0, 1]).unwrap(), - TransactionValidity::Invalid(TransactionInvalid::Payment) + ValidationResult::Invalid(TransactionInvalid::Payment) ); // Unknown::CannotLookup is variant index 0. assert_eq!( decode_transaction_validity(&[1, 1, 0]).unwrap(), - TransactionValidity::Unknown(TransactionUnknown::CannotLookup) + ValidationResult::Unknown(TransactionUnknown::CannotLookup) ); assert!(decode_transaction_validity(&[9]).is_err()); @@ -532,7 +533,7 @@ pub(crate) mod tests { /// Split a length-prefixed V4 signed extrinsic into /// (account, signature, trailing-bytes-after-signature). - fn split_v4(extrinsic: &[u8]) -> ([u8; 32], [u8; 64], Vec) { + 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"); diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 3bb3cc4a..269e65e4 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -147,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(); @@ -1851,14 +1855,11 @@ impl Preimage for ProductRuntimeHost { .await .map_err(|err| preimage_submit_error(err.reason()))?; - match bulletin - .submit_preimage(cx, &allowance, value.clone()) + let key = match bulletin + .submit_preimage(cx, PREIMAGE_SUBMIT_BUDGET, &allowance, &value) .await { - Ok(key) => { - self.prime_preimage_cache(&value, &key); - Ok(RemotePreimageSubmitResponse::V1(key)) - } + 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. @@ -1873,29 +1874,26 @@ impl Preimage for ProductRuntimeHost { ) .await .map_err(|err| preimage_submit_error(err.reason()))?; - match bulletin - .submit_preimage(cx, &allowance, value.clone()) + bulletin + .submit_preimage(cx, PREIMAGE_SUBMIT_BUDGET, &allowance, &value) .await - { - Ok(key) => { - self.prime_preimage_cache(&value, &key); - Ok(RemotePreimageSubmitResponse::V1(key)) - } - Err(err) => Err(preimage_submit_error(err.reason())), - } + .map_err(|err| preimage_submit_error(err.reason()))? } - Err(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 { - /// Prime the in-core lookup cache with a just-submitted preimage so an - /// immediate product lookup hits before the content backend has it. - fn prime_preimage_cache(&self, value: &[u8], key: &[u8]) { + /// 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.to_vec()); + debug_assert_eq!(key_bytes, preimage_key(&value)); + self.services.cache_preimage(key_bytes, value); } } } diff --git a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs index ec9a69a7..5d4ba2dc 100644 --- a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs @@ -11,15 +11,15 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Mutex as StdMutex; use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(not(target_arch = "wasm32"))] -use std::time::Duration; +use std::time::{Duration, Instant}; #[cfg(target_arch = "wasm32")] -use web_time::Duration; +use web_time::{Duration, Instant}; use futures::stream::BoxStream; use futures::{FutureExt, StreamExt, pin_mut}; -use parity_scale_codec::{Decode, Encode}; +use parity_scale_codec::Encode; use subxt::metadata::ArcMetadata; -use subxt::tx::{TransactionInvalid, TransactionUnknown}; +use subxt::tx::{TransactionInvalid, TransactionUnknown, ValidationResult}; use tracing::{instrument, warn}; use truapi::CallContext; use truapi::v01::{ @@ -35,17 +35,20 @@ use crate::chain_runtime::{ ChainRuntime, wait_for_chain_head_call_output, wait_for_chain_head_storage_value, }; use crate::host_logic::bulletin::{ - MortalityAnchor, build_signed_store_extrinsic, preimage_key, system_events_storage_key, + MortalityAnchor, STORE_CALL_NAME, STORE_PALLET_NAME, build_signed_store_extrinsic, + preimage_key, store_data_field_matches_key, system_events_storage_key, }; use crate::host_logic::extrinsic::{ - DecodedDispatchError, ExtrinsicOutcome, OfflineChainState, TransactionValidity, - best_supported_metadata_version, decode_header_block_number, decode_runtime_metadata, + DecodedDispatchError, ExtrinsicOutcome, OfflineChainState, best_supported_metadata_version, + decode_account_nonce, decode_header_block_number, decode_runtime_metadata, decode_transaction_validity, extrinsic_outcome_from_events, validate_transaction_call_parameters, }; -/// Whole-submission budget, including best-block inclusion. -const SUBMIT_TIMEOUT: Duration = Duration::from_secs(180); +/// Retry once when a broadcast is not included after the watch budget. This +/// covers the post-allocation propagation window where dry-run can succeed +/// against one node while the authoring path silently drops the broadcast. +const SUBMIT_ATTEMPTS: usize = 2; /// Budget for the follow to deliver `Initialized`. const INITIALIZATION_TIMEOUT: Duration = Duration::from_secs(10); /// Budget for a fresh follow to report the current best block after @@ -77,6 +80,17 @@ struct SubmissionHead { runtime_spec: RuntimeSpec, } +/// The broadcast transaction the watch loop looks for, plus the chain state +/// needed to content-match its body against the preimage key. +struct SubmittedTransaction<'a> { + state: &'a OfflineChainState, + anchor: &'a MortalityAnchor, + extrinsic_hash: [u8; 32], + preimage_key: &'a [u8; 32], + our_nonce: u64, + account: &'a [u8; 32], +} + /// Typed submission failure driving the retry decision at the runtime call /// site. Wire mapping stays `v01::PreimageSubmitError::Unknown { reason }`. #[derive(Debug, Clone, PartialEq, Eq)] @@ -110,6 +124,10 @@ pub(crate) enum AllowanceRejectionPhase { } impl BulletinSubmitError { + fn is_retryable_watch_timeout(&self) -> bool { + matches!(self, Self::Timeout { phase } if *phase == "watch") + } + /// Structured reason string carried in the wire error. pub(crate) fn reason(&self) -> String { match self { @@ -171,8 +189,9 @@ impl BulletinRpc { pub(crate) async fn submit_preimage( &self, cx: &CallContext, + budget: Duration, allowance: &BulletinAllowanceKey, - value: Vec, + value: &[u8], ) -> Result, BulletinSubmitError> { // Serialize submissions, keeping the lock wait cancellable. let lock = self.submit_lock.lock().fuse(); @@ -183,28 +202,49 @@ impl BulletinRpc { _ = 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 follow (releasing pins), and the explicit // stop below covers any live broadcast on every exit path. - let flow = self.submit_flow(allowance, value).fuse(); - let timeout = futures_timer::Delay::new(SUBMIT_TIMEOUT).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), - }; - self.stop_active_broadcast().await; - result + 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), + }; + self.stop_active_broadcast().await; + match result { + Err(err) if attempt < SUBMIT_ATTEMPTS && err.is_retryable_watch_timeout() => { + warn!( + attempt, + reason = %err.reason(), + "Bulletin preimage broadcast not included; retrying" + ); + } + result => return result, + } + } } async fn submit_flow( &self, allowance: &BulletinAllowanceKey, - value: Vec, + value: &[u8], ) -> Result, BulletinSubmitError> { - let key = preimage_key(&value); + let key = preimage_key(value); self.enter_phase("connect"); let follow_id = format!( @@ -226,9 +266,12 @@ impl BulletinRpc { .await?; self.enter_phase("build"); + let account = + crate::host_logic::extrinsic::public_key_from_secret_bytes(allowance.as_secret_bytes()) + .map_err(|reason| BulletinSubmitError::InvalidTransaction { kind: reason })?; let anchor = self.mortality_anchor(&follow_id, &head.best_hash).await?; let nonce = self - .account_nonce(&follow_id, &mut follow, &head.best_hash, allowance) + .account_nonce(&follow_id, &mut follow, &head.best_hash, &account) .await?; let signed = build_signed_store_extrinsic(&state, &anchor, allowance, nonce, value) .map_err(|reason| BulletinSubmitError::InvalidTransaction { kind: reason })?; @@ -259,7 +302,18 @@ impl BulletinRpc { self.enter_phase("watch"); let (inclusion_block, extrinsic_index) = self - .watch_for_inclusion(&follow_id, &mut follow, extrinsic_hash, nonce, allowance) + .watch_for_inclusion( + &follow_id, + &mut follow, + SubmittedTransaction { + state: &state, + anchor: &anchor, + extrinsic_hash, + preimage_key: &key, + our_nonce: nonce, + account: &account, + }, + ) .await?; self.enter_phase("events"); @@ -378,11 +432,8 @@ impl BulletinRpc { follow_id: &str, follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, finalized_hash: &[u8], - allowance: &BulletinAllowanceKey, + account: &[u8; 32], ) -> Result { - let account = - crate::host_logic::extrinsic::public_key_from_secret_bytes(allowance.as_secret_bytes()) - .map_err(|reason| BulletinSubmitError::InvalidTransaction { kind: reason })?; let output = self .runtime_call( follow_id, @@ -392,11 +443,8 @@ impl BulletinRpc { account.to_vec(), ) .await?; - let nonce = - u32::decode(&mut &output[..]).map_err(|err| BulletinSubmitError::ChainUnavailable { - reason: format!("invalid account nonce response: {err}"), - })?; - Ok(nonce.into()) + decode_account_nonce(&output) + .map_err(|reason| BulletinSubmitError::ChainUnavailable { reason }) } /// Dry-run the signed extrinsic against the anchor block. Broadcast never @@ -422,26 +470,26 @@ impl BulletinRpc { let validity = decode_transaction_validity(&output) .map_err(|reason| BulletinSubmitError::ChainUnavailable { reason })?; match validity { - TransactionValidity::Valid(_) => Ok(()), - TransactionValidity::Invalid( + ValidationResult::Valid(_) => Ok(()), + ValidationResult::Invalid( TransactionInvalid::Payment | TransactionInvalid::Custom(_) | TransactionInvalid::BadSigner, ) => Err(BulletinSubmitError::AllowanceRejected { phase: AllowanceRejectionPhase::DryRun, }), - TransactionValidity::Invalid( - TransactionInvalid::Future | TransactionInvalid::Stale, - ) => Err(BulletinSubmitError::NonceRace), - TransactionValidity::Invalid(other) => Err(BulletinSubmitError::InvalidTransaction { + ValidationResult::Invalid(TransactionInvalid::Future | TransactionInvalid::Stale) => { + Err(BulletinSubmitError::NonceRace) + } + ValidationResult::Invalid(other) => Err(BulletinSubmitError::InvalidTransaction { kind: format!("{other:?}"), }), - TransactionValidity::Unknown(TransactionUnknown::CannotLookup) => { + ValidationResult::Unknown(TransactionUnknown::CannotLookup) => { Err(BulletinSubmitError::ChainUnavailable { reason: "transaction validity could not be looked up".to_string(), }) } - TransactionValidity::Unknown(other) => Err(BulletinSubmitError::InvalidTransaction { + ValidationResult::Unknown(other) => Err(BulletinSubmitError::InvalidTransaction { kind: format!("{other:?}"), }), } @@ -455,14 +503,16 @@ impl BulletinRpc { &self, follow_id: &str, follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, - extrinsic_hash: [u8; 32], - our_nonce: u64, - allowance: &BulletinAllowanceKey, + tx: SubmittedTransaction<'_>, ) -> Result<(Vec, u32), BulletinSubmitError> { - let account = - crate::host_logic::extrinsic::public_key_from_secret_bytes(allowance.as_secret_bytes()) - .expect("validated earlier in the submit flow"); - + let SubmittedTransaction { + state, + anchor, + extrinsic_hash, + preimage_key, + our_nonce, + account, + } = tx; let stopped = || BulletinSubmitError::BroadcastUnverified { reason: "chain follow stopped".to_string(), }; @@ -489,7 +539,7 @@ impl BulletinRpc { None => body_queue.push_front(block), } } else if let Some(block) = gate_queue.pop_front() { - match self.start_nonce_probe(follow_id, &block, &account).await? { + match self.start_nonce_probe(follow_id, &block, account).await? { Some(operation_id) => pending_nonce = Some((operation_id, block)), None => gate_queue.push_front(block), } @@ -530,12 +580,9 @@ impl BulletinRpc { .is_some_and(|(id, _)| *id == operation_id) => { let (_, block) = pending_nonce.take().expect("checked above"); - let nonce = u32::decode(&mut &output[..]).map_err(|err| { - BulletinSubmitError::BroadcastUnverified { - reason: format!("invalid nonce probe response: {err}"), - } - })?; - if u64::from(nonce) > our_nonce { + let nonce = decode_account_nonce(&output) + .map_err(|reason| BulletinSubmitError::BroadcastUnverified { reason })?; + if nonce > our_nonce { // The account advanced at or before `block`: check its // body and those of its unchecked ancestors. for hash in ancestors_to_check(&parents, &checked, block) { @@ -566,6 +613,18 @@ impl BulletinRpc { // callers prefer finalized inclusion. return Ok((block, index as u32)); } + match matching_store_extrinsic_index(state, anchor.number, value, preimage_key) + .await + { + Ok(Some(index)) => return Ok((block, index)), + Ok(None) => {} + Err(reason) => { + warn!( + reason = %reason, + "Bulletin store-call body match failed" + ); + } + } checked.insert(block.clone()); self.unpin(follow_id, vec![block]).await; } @@ -951,6 +1010,32 @@ fn valid_runtime( } } +async fn matching_store_extrinsic_index( + state: &OfflineChainState, + block_number: u64, + extrinsics: Vec>, + preimage_key: &[u8; 32], +) -> Result, String> { + let client = state.client_at(block_number)?; + let extrinsics = client.extrinsics().from_bytes(extrinsics).await; + for extrinsic in extrinsics.iter() { + let extrinsic = extrinsic.map_err(|err| format!("cannot decode block extrinsic: {err}"))?; + if extrinsic.pallet_name() != STORE_PALLET_NAME || extrinsic.call_name() != STORE_CALL_NAME + { + continue; + } + + let mut fields = extrinsic.iter_call_data_fields(); + let Some(field) = fields.next() else { + continue; + }; + if fields.next().is_none() && store_data_field_matches_key(field.bytes(), preimage_key) { + return Ok(Some(extrinsic.index() as u32)); + } + } + Ok(None) +} + /// Collect `start` and its not-yet-checked ancestors from a provider-supplied /// `parents` map, oldest lookups first. /// diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index a2761b42..14f3cb93 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -374,11 +374,11 @@ mod tests { }; use super::super::{ProductAuthority, ProductRuntimeHost, RuntimeServices, SigningHostRole}; use super::{BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, raw_payload_bytes}; + use crate::host_logic::extrinsic::tests::split_v4; use crate::host_logic::product_account::{ derive_product_keypair, derive_root_keypair_from_entropy, }; use crate::test_support::{StubPlatform, test_spawner}; - use parity_scale_codec::{Compact, Decode}; use truapi::api::{Account, Entropy, Signing}; use truapi::versioned::account::{HostAccountGetError, HostAccountGetRequest}; use truapi::versioned::entropy::HostDeriveEntropyRequest; @@ -513,20 +513,6 @@ mod tests { } } - /// Split a length-prefixed V4 signed extrinsic into - /// (account, signature, trailing bytes). - 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); - assert_eq!(input[0], 0x84); - assert_eq!(input[1], 0x00); - let account: [u8; 32] = input[2..34].try_into().unwrap(); - assert_eq!(input[34], 0x01); - let signature: [u8; 64] = input[35..99].try_into().unwrap(); - (account, signature, input[99..].to_vec()) - } - #[test] fn create_transaction_product_builds_verifiable_v4() { let (_services, activation) = signing_runtime(); From e731d5c52f338fc1624f47b6f9ac1cdf50f8f94e Mon Sep 17 00:00:00 2001 From: pgherveou Date: Sat, 11 Jul 2026 00:06:55 +0200 Subject: [PATCH 7/9] first pass --- Makefile | 8 +- playground/src/lib/auto-test.ts | 52 +- rust/crates/truapi-platform/src/lib.rs | 11 +- rust/crates/truapi-server/Cargo.toml | 2 +- .../crates/truapi-server/src/chain_runtime.rs | 396 +++--- .../truapi-server/src/host_logic/bulletin.rs | 190 +-- .../truapi-server/src/host_logic/extrinsic.rs | 357 +---- .../host_logic/statement_store/statement.rs | 15 +- .../truapi-server/src/runtime/bulletin_rpc.rs | 1177 ++++------------- .../truapi-server/src/runtime/identity.rs | 104 +- rust/crates/truapi/src/api/preimage.rs | 4 +- 11 files changed, 688 insertions(+), 1628 deletions(-) diff --git a/Makefile b/Makefile index 32ae3162..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 @@ -128,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/playground/src/lib/auto-test.ts b/playground/src/lib/auto-test.ts index 56ed0bbb..b32f19aa 100644 --- a/playground/src/lib/auto-test.ts +++ b/playground/src/lib/auto-test.ts @@ -53,6 +53,11 @@ type RunOneOpts = { onUpdate: (id: string, entry: TestEntry) => void; }; +type DiagnosisItem = { + serviceName: string; + method: MethodInfo; +}; + async function runOne({ serviceName, method, @@ -142,19 +147,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/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 5ef02b10..520d3fca 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -621,8 +621,9 @@ pub trait ThemeHost: Send + Sync { /// /// 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(Clone, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop, derive_more::Debug)] pub struct BulletinAllowanceKey { + #[debug("\"\"")] secret: [u8; 64], } @@ -643,14 +644,6 @@ impl BulletinAllowanceKey { } } -impl core::fmt::Debug for BulletinAllowanceKey { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("BulletinAllowanceKey") - .field("secret", &"") - .finish() - } -} - /// Invalid Bulletin allowance key material. #[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] pub enum BulletinAllowanceKeyError { diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index a1b4500d..60ebefa4 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -35,7 +35,7 @@ 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"] } parity-scale-codec = { version = "3", features = ["derive"] } diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 1147e5b2..94214a6e 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`] + [`ChainHeadBackend`] pair over the same transport; +//! internal services (Bulletin submission, identity lookups) use it instead +//! of hand-rolling chainHead orchestration. +//! //! 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 @@ -18,21 +23,20 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, 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::{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_rpcs::client::RpcClient; use subxt_rpcs::methods::chain_head as subxt_chain; use subxt_rpcs::{ChainHeadRpcMethods, Error as SubxtRpcError, RpcConfig}; @@ -97,6 +101,21 @@ 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 OnlineClientSetup = Shared>>; + +/// Cached Subxt client + backend pair built over one connection's transport. +#[derive(Clone)] +pub(crate) struct SubxtConnection { + /// Client whose chain config pins the host-configured genesis hash. + pub(crate) client: OnlineClient, + /// The chainHead backend under `client`, for raw-key reads that must not + /// trigger a metadata fetch. + pub(crate) backend: Arc>, +} + /// Classification of framework-level chain failures separate from JSON-RPC /// domain errors. Maps cleanly to [`truapi::CallError`] variants at the /// `ProductRuntimeHost` boundary. @@ -493,6 +512,34 @@ impl ChainRuntime { .map_err(|err| rpc_failure(method, err)) } + /// Cached, genesis-pinned Subxt client for the chain identified by + /// `genesis_hash`. + #[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) + } + + /// Cached chainHead backend for the chain identified by `genesis_hash`, + /// for raw-key storage reads that must not trigger a metadata fetch. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.chain_head_backend"))] + pub(crate) async fn chain_head_backend( + &self, + genesis_hash: &[u8], + ) -> Result>, RuntimeFailure> { + Ok(self.subxt_connection(genesis_hash).await?.backend) + } + + async fn subxt_connection( + &self, + genesis_hash: &[u8], + ) -> Result { + let connection = self.connection_for("online_client", genesis_hash).await?; + connection.online_client().await + } + #[instrument(skip_all, fields(runtime.method = "chain_runtime.connection_for", method = method))] async fn connection_for( &self, @@ -524,8 +571,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() @@ -623,20 +670,26 @@ struct ChainConnection { rpc_client: HostRpcClient, methods: ChainHeadRpcMethods, spawner: Spawner, + /// Host-configured genesis hash this connection was opened for; pins the + /// cached Subxt client's chain config. + genesis_hash: Vec, follows: Mutex>, follow_setups: Mutex>, + online_client_setup: Mutex>, } 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()), + online_client_setup: Mutex::new(None), }) } @@ -644,6 +697,70 @@ impl ChainConnection { self.rpc_client.is_closed() } + /// Lazily build (single-flight) and cache the Subxt client 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 instead of leaking one driver per call. + async fn online_client(self: &Arc) -> Result { + let setup = { + let mut slot = self.online_client_setup.lock().unwrap(); + if let Some(existing) = slot.clone() { + existing + } else { + let connection = self.clone(); + let setup: OnlineClientSetup = + async move { connection.build_online_client().await } + .boxed() + .shared(); + *slot = Some(setup.clone()); + setup + } + }; + let result = setup.await; + // On failure, drop the cached setup so a later submission can retry. + if result.is_err() { + *self.online_client_setup.lock().unwrap() = None; + } + result + } + + /// Body of the single-flight client 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_online_client(self: Arc) -> Result { + const METHOD: &str = "online_client"; + 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())); + (self.spawner)( + async move { + while let Some(result) = driver.next().await { + if let Err(error) = result { + tracing::debug!(target: "subxt", "chainHead backend error={error}"); + } + } + } + .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, backend }) + } + fn follow_with_runtime(&self, local_follow_id: &str) -> bool { self.follows .lock() @@ -1149,167 +1266,6 @@ pub(crate) fn encode_hex(value: &[u8]) -> String { format!("0x{}", hex::encode(value)) } -/// Wait for a usable best block hash from a `chainHead_v1_follow` stream. -pub(crate) async fn wait_for_chain_head_best_hash( - follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, - label: &'static str, - initialization_timeout: Duration, - best_hash_timeout: Duration, -) -> Result, String> { - let timeout = futures_timer::Delay::new(initialization_timeout).fuse(); - pin_mut!(timeout); - loop { - let next = follow.next().fuse(); - pin_mut!(next); - futures::select! { - item = next => match item { - Some(RemoteChainHeadFollowItem::Initialized { finalized_block_hashes, .. }) => { - let fallback = finalized_block_hashes.last().cloned(); - return wait_for_chain_head_best_hash_after_initialization( - follow, - label, - fallback, - best_hash_timeout, - ) - .await; - } - Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { - return Ok(best_block_hash); - } - Some(RemoteChainHeadFollowItem::Stop) | None => { - return Err(format!("{label} follow stopped before initialization")); - } - _ => {} - }, - () = timeout => return Err(format!("{label} follow initialization timed out")), - } - } -} - -async fn wait_for_chain_head_best_hash_after_initialization( - follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, - label: &'static str, - fallback: Option>, - timeout: Duration, -) -> 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); - futures::select! { - item = next => match item { - 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(|| { - format!("{label} follow best block timed out") - }); - }, - } - } -} - -/// 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(); - pin_mut!(timeout); - let mut value = None; - loop { - let next = follow.next().fuse(); - pin_mut!(next); - futures::select! { - item = next => match item { - Some(RemoteChainHeadFollowItem::OperationStorageItems { operation_id: item_operation_id, items }) - if item_operation_id == operation_id => - { - for item in items { - if item.key == key { - value = item.value; - } - } - } - Some(RemoteChainHeadFollowItem::OperationStorageDone { operation_id: item_operation_id }) - if item_operation_id == operation_id => - { - return Ok(value); - } - Some(RemoteChainHeadFollowItem::OperationInaccessible { operation_id: item_operation_id }) - if item_operation_id == operation_id => - { - return Ok(None); - } - Some(RemoteChainHeadFollowItem::OperationError { operation_id: item_operation_id, error }) - if item_operation_id == operation_id => - { - return Err(error); - } - Some(RemoteChainHeadFollowItem::Stop) | None => { - return Err(format!("{label} follow stopped during storage lookup")); - } - _ => {} - }, - () = timeout => return Err(format!("{label} storage lookup timed out")), - } - } -} - -/// Wait for one runtime-call operation's output from a `chainHead_v1_follow` -/// stream. -pub(crate) async fn wait_for_chain_head_call_output( - follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, - operation_id: &str, - label: &'static str, - timeout: Duration, -) -> Result, String> { - let timeout = futures_timer::Delay::new(timeout).fuse(); - pin_mut!(timeout); - loop { - let next = follow.next().fuse(); - pin_mut!(next); - futures::select! { - item = next => match item { - Some(RemoteChainHeadFollowItem::OperationCallDone { operation_id: item_operation_id, output }) - if item_operation_id == operation_id => - { - return Ok(output); - } - Some(RemoteChainHeadFollowItem::OperationInaccessible { operation_id: item_operation_id }) - if item_operation_id == operation_id => - { - return Err(format!("{label} runtime call was inaccessible")); - } - Some(RemoteChainHeadFollowItem::OperationError { operation_id: item_operation_id, error }) - if item_operation_id == operation_id => - { - return Err(error); - } - Some(RemoteChainHeadFollowItem::Stop) | None => { - return Err(format!("{label} follow stopped during runtime call")); - } - _ => {} - }, - () = timeout => return Err(format!("{label} runtime call timed out")), - } - } -} - #[cfg(test)] fn decode_hex(value: &str) -> Result, String> { hex::decode(value.strip_prefix("0x").unwrap_or(value)).map_err(|_| "invalid hex".to_string()) @@ -1320,7 +1276,7 @@ mod tests { use super::*; use async_trait::async_trait; use futures::channel::mpsc as fut_mpsc; - use futures::stream::{self, BoxStream}; + use futures::stream::BoxStream; use std::sync::atomic::{AtomicUsize, Ordering}; fn spawner_for_tests() -> Spawner { @@ -1334,57 +1290,6 @@ mod tests { } } - #[test] - fn chain_head_best_hash_prefers_best_block_after_initialization() { - let mut follow = stream::iter(vec![ - RemoteChainHeadFollowItem::Initialized { - finalized_block_hashes: vec![vec![0x01]], - finalized_block_runtime: None, - }, - RemoteChainHeadFollowItem::BestBlockChanged { - best_block_hash: vec![0x02], - }, - ]) - .boxed(); - - let hash = futures::executor::block_on(wait_for_chain_head_best_hash( - &mut follow, - "test chain", - Duration::from_secs(10), - Duration::from_secs(2), - )) - .expect("best hash should resolve"); - - assert_eq!(hash, vec![0x02]); - } - - #[test] - fn chain_head_best_hash_errors_on_stop_before_best_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, - }, - RemoteChainHeadFollowItem::Stop, - ]) - .boxed(); - - let err = futures::executor::block_on(wait_for_chain_head_best_hash( - &mut follow, - "test chain", - Duration::from_secs(10), - Duration::from_secs(2), - )) - .expect_err("follow stop should be terminal before best block"); - - assert_eq!(err, "test chain follow stopped before best block"); - } - #[derive(Default)] struct UnavailableChainProvider; @@ -1653,6 +1558,55 @@ mod tests { assert_eq!(provider.inner.connect_calls.load(Ordering::SeqCst), 1); } + /// The cached Subxt client 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 online_client_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("online_client_test", &genesis)) + .expect("connection"); + let first = futures::executor::block_on(connection.online_client()).expect("client"); + let second = futures::executor::block_on(connection.online_client()).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 client must reuse one backend follow"); + } + #[test] fn unknown_genesis_chain_spec_propagates_failure() { let provider = Arc::new(UnavailableChainProvider); diff --git a/rust/crates/truapi-server/src/host_logic/bulletin.rs b/rust/crates/truapi-server/src/host_logic/bulletin.rs index 80ce974d..2eefaf66 100644 --- a/rust/crates/truapi-server/src/host_logic/bulletin.rs +++ b/rust/crates/truapi-server/src/host_logic/bulletin.rs @@ -5,17 +5,17 @@ //! takes raw preimage bytes plus a [`BulletinAllowanceKey`], never //! caller-supplied call data. -use parity_scale_codec::{Compact, Decode, Encode}; +use parity_scale_codec::{Compact, Encode}; +use subxt::client::{ClientAtBlock, OfflineClientAtBlockT}; use subxt::config::DefaultExtrinsicParamsBuilder; use subxt::config::substrate::SubstrateConfig; -use subxt::ext::frame_decode::storage::encode_storage_key_prefix; use subxt::ext::scale_encode::{self, EncodeAsFields, FieldIter, TypeResolver}; use subxt::ext::scale_type_resolver::{Primitive, visitor}; -use subxt::tx::StaticPayload; +use subxt::tx::{StaticPayload, SubmittableTransaction}; use subxt::utils::H256; use truapi_platform::BulletinAllowanceKey; -use crate::host_logic::extrinsic::{OfflineChainState, Sr25519Signer}; +use crate::host_logic::extrinsic::Sr25519Signer; pub(crate) const STORE_PALLET_NAME: &str = "TransactionStorage"; pub(crate) const STORE_CALL_NAME: &str = "store"; @@ -31,12 +31,7 @@ pub(crate) fn preimage_key(value: &[u8]) -> [u8; 32] { sp_crypto_hashing::blake2_256(value) } -/// Storage key of the plain `System.Events` value. -pub(crate) fn system_events_storage_key() -> Vec { - encode_storage_key_prefix("System", "Events").to_vec() -} - -/// Finalized block the transaction's mortality is anchored at. +/// Block the transaction's mortality is anchored at. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct MortalityAnchor { /// Anchor block number (era birth block). @@ -45,61 +40,44 @@ pub(crate) struct MortalityAnchor { pub(crate) hash: [u8; 32], } -/// A signed `TransactionStorage.store` extrinsic ready to broadcast. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct SignedStoreExtrinsic { - /// Full length-prefixed extrinsic bytes. - pub(crate) extrinsic: Vec, - /// blake2b-256 of the extrinsic bytes, used for inclusion matching. - pub(crate) extrinsic_hash: [u8; 32], - /// Public key of the allowance account that signed. - pub(crate) account: [u8; 32], -} - -/// Build and sign a `TransactionStorage.store { data }` extrinsic with the -/// Bulletin allowance key. -pub(crate) fn build_signed_store_extrinsic( - state: &OfflineChainState, +/// Build and sign a `TransactionStorage.store { data }` transaction with the +/// Bulletin allowance signer against the client's block. The anchor must be +/// the block the client is at, so the mortal era and the dry-run block agree. +pub(crate) fn build_signed_store_transaction>( + client: &ClientAtBlock, anchor: &MortalityAnchor, - allowance: &BulletinAllowanceKey, + signer: &Sr25519Signer, nonce: u64, data: &[u8], -) -> Result { - if !state.metadata.extrinsic().supported_versions().contains(&4) { +) -> Result, String> { + if !client + .metadata_ref() + .extrinsic() + .supported_versions() + .contains(&4) + { return Err(format!( "bulletin runtime no longer supports v4 extrinsics (supported: {:?})", - state.metadata.extrinsic().supported_versions() + client.metadata_ref().extrinsic().supported_versions() )); } - let signer = allowance_signer(allowance)?; - let account = signer.public_key(); - let client = state.client_at(anchor.number)?; - 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(); - let extrinsic = client + 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}"))? - .into_encoded(); - - let extrinsic_hash = sp_crypto_hashing::blake2_256(&extrinsic); - Ok(SignedStoreExtrinsic { - extrinsic, - extrinsic_hash, - account, - }) + .sign(signer) + .map_err(|err| format!("store transaction signing failed: {err}")) } /// The only [`BulletinAllowanceKey`] -> signer conversion in the crate. The /// returned signer is a transient per-call value; callers must not store it. -fn allowance_signer(allowance: &BulletinAllowanceKey) -> Result { +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}")) } @@ -164,19 +142,10 @@ fn require_u8_sequence( Ok(()) } -/// Return `true` when raw `store.data` field bytes hash to `key`. -pub(crate) fn store_data_field_matches_key(field_bytes: &[u8], key: &[u8; 32]) -> bool { - let mut input = field_bytes; - let Ok(Compact(len)) = Compact::::decode(&mut input) else { - return false; - }; - input.len() == len as usize && preimage_key(input) == *key -} - #[cfg(test)] mod tests { use super::*; - use crate::host_logic::extrinsic::tests::{bulletin_chain_state, split_v4}; + 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}; @@ -192,6 +161,10 @@ mod tests { BulletinAllowanceKey::from_secret_bytes(secret).unwrap() } + fn signer_fixture() -> Sr25519Signer { + allowance_signer(&allowance_fixture()).unwrap() + } + fn anchor_fixture() -> MortalityAnchor { MortalityAnchor { number: 4200, @@ -245,29 +218,17 @@ mod tests { ); } - #[test] - fn system_events_storage_key_is_pinned() { - assert_eq!( - hex::encode(system_events_storage_key()), - "26aa394eea5630e07c48ae0c9558cef780d41e5e16056765bc8461851072c9d7" - ); - } - #[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_extrinsic(&state, &anchor_fixture(), &allowance_fixture(), 7, &data) + build_signed_store_transaction(&client, &anchor_fixture(), &signer_fixture(), 7, &data) .unwrap(); - assert_eq!( - signed.extrinsic_hash, - sp_crypto_hashing::blake2_256(&signed.extrinsic) - ); - let (account, signature, tail) = split_v4(&signed.extrinsic); - assert_eq!(account, signed.account); - let client = state.client_at(anchor_fixture().number).unwrap(); + let (account, signature, tail) = split_v4(signed.encoded()); + assert_eq!(account, signer_fixture().public_key()); 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)); @@ -303,15 +264,13 @@ mod tests { #[test] fn genesis_hash_binds_the_signature() { let data = b"pinned to one chain".to_vec(); - let signed = build_signed_store_extrinsic( - &bulletin_chain_state(), - &anchor_fixture(), - &allowance_fixture(), - 0, - &data, - ) - .unwrap(); - let (account, signature, _) = split_v4(&signed.extrinsic); + let client = bulletin_chain_state() + .client_at(anchor_fixture().number) + .unwrap(); + let signed = + build_signed_store_transaction(&client, &anchor_fixture(), &signer_fixture(), 0, &data) + .unwrap(); + let (account, signature, _) = split_v4(signed.encoded()); let mutated_state = OfflineChainState { genesis_hash: [0xcc; 32], @@ -378,10 +337,11 @@ mod tests { store.fields[0].ty = u32_type; let state = state_with_metadata(metadata_from_v14(metadata)); - let error = build_signed_store_extrinsic( - &state, + let client = state.client_at(anchor_fixture().number).unwrap(); + let error = build_signed_store_transaction( + &client, &anchor_fixture(), - &allowance_fixture(), + &signer_fixture(), 0, &[1, 2, 3], ) @@ -397,8 +357,9 @@ mod tests { 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_extrinsic(&state, &anchor_fixture(), &allowance_fixture(), 0, &[1]) + build_signed_store_transaction(&client, &anchor_fixture(), &signer_fixture(), 0, &[1]) .unwrap_err(); assert!(error.contains("FakeImplicitExt"), "{error}"); } @@ -411,8 +372,9 @@ mod tests { 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_extrinsic(&state, &anchor_fixture(), &allowance_fixture(), 0, &[1]) + build_signed_store_transaction(&client, &anchor_fixture(), &signer_fixture(), 0, &[1]) .unwrap_err(); assert!(error.contains("FakeValueExt"), "{error}"); } @@ -431,20 +393,24 @@ mod tests { metadata.extrinsic.signed_extensions.push(fake); let state = state_with_metadata(metadata_from_v14(metadata)); - let baseline = build_signed_store_extrinsic( - &bulletin_chain_state(), + let baseline_client = bulletin_chain_state() + .client_at(anchor_fixture().number) + .unwrap(); + let baseline = build_signed_store_transaction( + &baseline_client, &anchor_fixture(), - &allowance_fixture(), + &signer_fixture(), 0, &[1], ) .unwrap(); + let client = state.client_at(anchor_fixture().number).unwrap(); let with_fake = - build_signed_store_extrinsic(&state, &anchor_fixture(), &allowance_fixture(), 0, &[1]) + build_signed_store_transaction(&client, &anchor_fixture(), &signer_fixture(), 0, &[1]) .unwrap(); assert_eq!( - with_fake.extrinsic.len(), - baseline.extrinsic.len() + 1, + with_fake.encoded().len(), + baseline.encoded().len() + 1, "the Option-typed extra should contribute exactly one None byte" ); } @@ -456,48 +422,26 @@ mod tests { // 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_extrinsic( - &bulletin_chain_state(), - &anchor_fixture(), - &allowance_fixture(), - 0, - &data, - ) - .unwrap(); + let signed = + build_signed_store_transaction(&client, &anchor_fixture(), &signer_fixture(), 0, &data) + .unwrap(); let elapsed = start.elapsed(); - assert!(signed.extrinsic.len() > data.len()); + assert!(signed.encoded().len() > data.len()); assert!( elapsed < std::time::Duration::from_secs(5), "building an 8 MiB store extrinsic took {elapsed:?}" ); } - #[test] - fn store_field_match_hashes_without_allocating_value() { - let value = b"hello bulletin"; - let key = preimage_key(value); - let mut field = Compact(value.len() as u32).encode(); - field.extend_from_slice(value); - - assert!(store_data_field_matches_key(&field, &key)); - assert!(!store_data_field_matches_key(&field, &[1; 32])); - assert!(!store_data_field_matches_key( - &field[..field.len() - 1], - &key - )); - } - #[test] fn rejects_secret_of_wrong_shape() { - let error = build_signed_store_extrinsic( - &bulletin_chain_state(), - &anchor_fixture(), - &BulletinAllowanceKey::from_secret_bytes(vec![0xff; 64]).unwrap(), - 0, - &[1], - ) - .unwrap_err(); + 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/extrinsic.rs b/rust/crates/truapi-server/src/host_logic/extrinsic.rs index 83c7f82c..b8ee8631 100644 --- a/rust/crates/truapi-server/src/host_logic/extrinsic.rs +++ b/rust/crates/truapi-server/src/host_logic/extrinsic.rs @@ -1,26 +1,16 @@ -//! Offline extrinsic construction shared by chain-facing runtime services. +//! sr25519 transaction signing shared by chain-facing runtime services. //! -//! Two assembly modes, both offline (no I/O): one metadata-driven, one from -//! pre-encoded parts. -//! -//! - Bulletin preimage submission builds a call from fetched runtime metadata -//! via subxt's offline client (typed nonce/mortality params). -//! - Local `create_transaction` ([`build_signed_extrinsic_v4`]) assembles a -//! signed V4 extrinsic from caller-supplied, already-SCALE-encoded parts, so -//! it needs no metadata at all. +//! [`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::{Compact, Decode, Encode}; +use parity_scale_codec::Encode; use schnorrkel::{PublicKey, SecretKey}; -use subxt::client::{OfflineClient, OfflineClientAtBlock}; -use subxt::config::substrate::{ - SpecVersionForRange, SubstrateConfig, SubstrateConfigBuilder, SubstrateHeader, -}; -use subxt::error::DispatchError; -use subxt::ext::frame_metadata::RuntimeMetadataPrefixed; -use subxt::metadata::{ArcMetadata, Metadata}; +use subxt::config::substrate::SubstrateConfig; use subxt::tx::Signer; -use subxt::tx::{TransactionInvalid, TransactionUnknown, TransactionValid, ValidationResult}; -use subxt::utils::{AccountId32, H256, MultiAddress, MultiSignature}; +use subxt::utils::{AccountId32, MultiAddress, MultiSignature}; use truapi::v01; use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; @@ -40,18 +30,15 @@ pub(crate) fn sr25519_secret_from_bytes(secret: &[u8; 64]) -> Result Result<[u8; 32], String> { - Ok(sr25519_secret_from_bytes(secret)?.to_public().to_bytes()) -} - /// sr25519 [`Signer`] over a parsed schnorrkel key. /// /// Holds only the parsed key (schnorrkel zeroizes it on drop), never the raw -/// secret bytes. Deliberately no `Debug` derive: schnorrkel's `Debug` prints -/// raw scalar material. +/// secret bytes. +#[derive(derive_more::Debug)] pub(crate) struct Sr25519Signer { + #[debug("\"\"")] secret: SecretKey, + #[debug("{}", hex::encode(public.to_bytes()))] public: PublicKey, } @@ -78,15 +65,6 @@ impl Sr25519Signer { } } -impl core::fmt::Debug for Sr25519Signer { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("Sr25519Signer") - .field("public", &hex::encode(self.public_key())) - .field("secret", &"") - .finish() - } -} - impl Signer for Sr25519Signer { fn account_id(&self) -> AccountId32 { AccountId32(self.public.to_bytes()) @@ -164,196 +142,46 @@ pub(crate) fn build_signed_extrinsic_v4( inner.encode() } -/// Everything needed to build transactions offline for one chain at one -/// runtime version. -/// -/// `genesis_hash` MUST come from host configuration, never from the chain -/// provider: it binds signatures to the configured chain, which is what makes -/// every other provider-supplied input here fail-closed (a lie yields a -/// signature the real chain rejects, never one valid elsewhere). -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}")) - } -} - -/// Decode the output of the `Metadata_metadata_at_version` runtime call: -/// `Option` where the opaque wrapper is a compact-length -/// prefixed `RuntimeMetadataPrefixed`. -pub(crate) fn decode_runtime_metadata(bytes: &[u8]) -> Result { - let (_, prefixed) = , RuntimeMetadataPrefixed)>>::decode(&mut &bytes[..]) - .map_err(|err| format!("invalid Metadata_metadata_at_version response: {err}"))? - .ok_or_else(|| "runtime returned no metadata for the requested version".to_string())?; - Metadata::try_from(prefixed).map_err(|err| format!("unsupported runtime metadata: {err}")) -} - -/// Decode the supported metadata versions from `Metadata_metadata_versions`, -/// returning the highest version this crate can consume (v14-v16), skipping -/// the unstable `u32::MAX` marker. -pub(crate) fn best_supported_metadata_version(bytes: &[u8]) -> Result { - let versions = >::decode(&mut &bytes[..]) - .map_err(|err| format!("invalid Metadata_metadata_versions response: {err}"))?; - versions - .into_iter() - .filter(|version| (14..=16).contains(version)) - .max() - .ok_or_else(|| "runtime advertises no supported metadata version (14-16)".to_string()) -} - -/// Decode a SCALE block header and return its block number. -pub(crate) fn decode_header_block_number(header: &[u8]) -> Result { - let header = SubstrateHeader::::decode(&mut &header[..]) - .map_err(|err| format!("invalid block header: {err}"))?; - Ok(header.number) -} - -/// Decode an `AccountNonceApi_account_nonce` runtime-call result. -pub(crate) fn decode_account_nonce(bytes: &[u8]) -> Result { - u32::decode(&mut &bytes[..]) - .map(u64::from) - .map_err(|err| format!("invalid account nonce response: {err}")) -} - -/// SCALE-encode the `TaggedTransactionQueue_validate_transaction` parameters: -/// `TransactionSource::External` ++ opaque extrinsic ++ 32-byte block hash. -pub(crate) fn validate_transaction_call_parameters(extrinsic: &[u8], block_hash: &[u8]) -> Vec { - /// `sp_runtime::transaction_validity::TransactionSource::External`, the - /// source the pool assigns to transactions arriving over the network, - /// matching how a broadcast transaction is later validated. - const TRANSACTION_SOURCE_EXTERNAL: u8 = 2; - let mut parameters = Vec::with_capacity(1 + extrinsic.len() + block_hash.len()); - parameters.push(TRANSACTION_SOURCE_EXTERNAL); - parameters.extend_from_slice(extrinsic); - parameters.extend_from_slice(block_hash); - parameters -} - -/// Decode the runtime's `TransactionValidity = Result` into subxt's [`ValidationResult`]. -/// -/// subxt's own `ValidationResult::try_from_bytes` is `pub(crate)` and only -/// reachable through an online backend, so the outer discriminants are -/// hand-decoded here; the payloads reuse subxt's public transaction-validity -/// types so variant indices stay canonical. -pub(crate) fn decode_transaction_validity(bytes: &[u8]) -> Result { - let map_err = |err: parity_scale_codec::Error| format!("invalid TransactionValidity: {err}"); - match (bytes.first(), bytes.get(1)) { - (Some(0), _) => TransactionValid::decode(&mut &bytes[1..]) - .map(ValidationResult::Valid) - .map_err(map_err), - (Some(1), Some(0)) => TransactionInvalid::decode(&mut &bytes[2..]) - .map(ValidationResult::Invalid) - .map_err(map_err), - (Some(1), Some(1)) => TransactionUnknown::decode(&mut &bytes[2..]) - .map(ValidationResult::Unknown) - .map_err(map_err), - _ => Err(format!( - "invalid TransactionValidity discriminant: 0x{}", - hex::encode(bytes.iter().take(2).copied().collect::>()) - )), - } -} - -/// Dispatch error decoded from a `System.ExtrinsicFailed` event. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum DecodedDispatchError { - /// A pallet module error, resolved to pallet + error variant names. - Module { pallet: String, error: String }, - /// Any other dispatch error, rendered as text. - Other(String), -} - -impl core::fmt::Display for DecodedDispatchError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - Self::Module { pallet, error } => write!(f, "{pallet}.{error}"), - Self::Other(reason) => write!(f, "{reason}"), - } - } -} - -/// Outcome of one extrinsic according to a block's `System.Events`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ExtrinsicOutcome { - /// `System.ExtrinsicSuccess` was emitted for the extrinsic index. - Success, - /// `System.ExtrinsicFailed` was emitted for the extrinsic index. - Failed(DecodedDispatchError), - /// The events decoded but carried no outcome for the extrinsic index. - NotFound, -} - -/// Read the dispatch outcome for `extrinsic_index` out of raw `System.Events` -/// storage bytes. -pub(crate) fn extrinsic_outcome_from_events( - client: &OfflineClientAtBlock, - events_bytes: Vec, - extrinsic_index: u32, -) -> Result { - use subxt::events::Phase; - - let events = client.events().from_bytes(events_bytes); - for event in events.iter() { - let event = event.map_err(|err| format!("invalid System.Events entry: {err}"))?; - if event.phase() != Phase::ApplyExtrinsic(extrinsic_index) - || event.pallet_name() != "System" - { - continue; - } - match event.event_name() { - "ExtrinsicSuccess" => return Ok(ExtrinsicOutcome::Success), - "ExtrinsicFailed" => { - let error = decode_dispatch_error(event.field_bytes(), client.metadata()); - return Ok(ExtrinsicOutcome::Failed(error)); - } - _ => {} - } - } - Ok(ExtrinsicOutcome::NotFound) -} - -/// Decode the leading `DispatchError` out of an `ExtrinsicFailed` event's -/// field bytes (the trailing `DispatchInfo` is ignored by the decoder). -fn decode_dispatch_error(field_bytes: &[u8], metadata: ArcMetadata) -> DecodedDispatchError { - match DispatchError::decode_from(field_bytes, metadata) { - Ok(DispatchError::Module(module_error)) => match module_error.details() { - Ok(details) => DecodedDispatchError::Module { - pallet: details.pallet.name().to_string(), - error: details.variant.name.clone(), - }, - Err(_) => DecodedDispatchError::Other(module_error.details_string()), - }, - Ok(other) => DecodedDispatchError::Other(other.to_string()), - Err(err) => DecodedDispatchError::Other(format!("undecodable dispatch error: {err}")), - } -} - #[cfg(test)] pub(crate) mod tests { use super::*; - use parity_scale_codec::Encode; + 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). @@ -431,91 +259,6 @@ pub(crate) mod tests { ); } - #[test] - fn decodes_fixture_metadata_via_runtime_call_wrapper() { - // Re-wrap the raw fixture the way `Metadata_metadata_at_version` - // returns it: Option<(compact wrapper length, prefixed metadata)>. - let wrapped = Some(BULLETIN_METADATA_BYTES.to_vec()).encode(); - let metadata = decode_runtime_metadata(&wrapped).unwrap(); - assert_eq!( - metadata - .pallet_by_name("TransactionStorage") - .unwrap() - .call_index(), - 40 - ); - } - - #[test] - fn rejects_missing_metadata_version() { - let wrapped = Option::>::None.encode(); - assert!(decode_runtime_metadata(&wrapped).is_err()); - } - - #[test] - fn picks_best_supported_metadata_version() { - let versions = vec![13u32, 14, 15, u32::MAX].encode(); - assert_eq!(best_supported_metadata_version(&versions).unwrap(), 15); - let unsupported = vec![13u32, u32::MAX].encode(); - assert!(best_supported_metadata_version(&unsupported).is_err()); - } - - #[test] - fn decodes_header_block_number() { - let header = SubstrateHeader:: { - parent_hash: H256([1; 32]), - number: 1_234_567, - state_root: H256([2; 32]), - extrinsics_root: H256([3; 32]), - digest: Default::default(), - }; - assert_eq!( - decode_header_block_number(&header.encode()).unwrap(), - 1_234_567 - ); - assert!(decode_header_block_number(&[0x00, 0x01]).is_err()); - } - - #[test] - fn decodes_transaction_validity_variants() { - // The subxt validity types are Decode-only; build the SCALE bytes by - // hand from the sp-runtime layout. - let mut bytes = vec![0u8]; - bytes.extend((5u64, Vec::>::new(), vec![vec![1u8, 2]], 32u64, true).encode()); - assert_eq!( - decode_transaction_validity(&bytes).unwrap(), - ValidationResult::Valid(TransactionValid { - priority: 5, - requires: vec![], - provides: vec![vec![1, 2]], - longevity: 32, - propagate: true, - }) - ); - - // Invalid::Payment is variant index 1. - assert_eq!( - decode_transaction_validity(&[1, 0, 1]).unwrap(), - ValidationResult::Invalid(TransactionInvalid::Payment) - ); - - // Unknown::CannotLookup is variant index 0. - assert_eq!( - decode_transaction_validity(&[1, 1, 0]).unwrap(), - ValidationResult::Unknown(TransactionUnknown::CannotLookup) - ); - - assert!(decode_transaction_validity(&[9]).is_err()); - } - - #[test] - fn validate_transaction_parameters_use_external_source() { - let parameters = validate_transaction_call_parameters(&[0xaa, 0xbb], &[0xcc; 32]); - assert_eq!(parameters[0], 2); - assert_eq!(¶meters[1..3], &[0xaa, 0xbb]); - assert_eq!(¶meters[3..], &[0xcc; 32]); - } - fn test_signer() -> Sr25519Signer { let keypair = schnorrkel::MiniSecretKey::from_bytes(&[3; 32]) .unwrap() 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 b1a742c7..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,5 +1,5 @@ use parity_scale_codec::{Compact, Decode, Encode}; -use schnorrkel::{PublicKey, SecretKey, Signature}; +use schnorrkel::{PublicKey, Signature}; use truapi::v01; use super::StatementStoreParseError; @@ -176,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()); @@ -198,17 +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; - // the shared parser keeps the fallback. - sr25519_secret_from_bytes(&ss_secret).map_err(|reason| format!("invalid ss_secret: {reason}")) -} - /// 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/bulletin_rpc.rs b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs index 5d4ba2dc..513b979d 100644 --- a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs @@ -1,70 +1,56 @@ -//! In-core Bulletin preimage submission over the shared chainHead runtime. +//! In-core Bulletin preimage submission over the shared Subxt client. //! //! One submission at a time: build + sign the `TransactionStorage.store` -//! extrinsic offline, dry-run it via `TaggedTransactionQueue_validate_transaction` +//! 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), broadcast, then watch -//! best blocks for inclusion and read the dispatch outcome from -//! `System.Events`. +//! 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::collections::{HashMap, HashSet, VecDeque}; use std::sync::Mutex as StdMutex; -use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(not(target_arch = "wasm32"))] use std::time::{Duration, Instant}; #[cfg(target_arch = "wasm32")] use web_time::{Duration, Instant}; -use futures::stream::BoxStream; -use futures::{FutureExt, StreamExt, pin_mut}; -use parity_scale_codec::Encode; -use subxt::metadata::ArcMetadata; -use subxt::tx::{TransactionInvalid, TransactionUnknown, ValidationResult}; +use futures::{FutureExt, pin_mut}; +use subxt::client::{Block, Blocks, OnlineClientAtBlockImpl}; +use subxt::config::substrate::SubstrateConfig; +use subxt::error::{DispatchError, TransactionEventsError}; +use subxt::tx::{ + SubmittableTransaction, TransactionInBlock, TransactionInvalid, TransactionStatus, + TransactionUnknown, ValidationResult, +}; +use subxt::utils::AccountId32; use tracing::{instrument, warn}; use truapi::CallContext; -use truapi::v01::{ - OperationStartedResult, RemoteChainHeadBodyRequest, RemoteChainHeadCallRequest, - RemoteChainHeadFollowItem, RemoteChainHeadFollowRequest, RemoteChainHeadHeaderRequest, - RemoteChainHeadStorageRequest, RemoteChainHeadUnpinRequest, - RemoteChainTransactionBroadcastRequest, RemoteChainTransactionStopRequest, RuntimeSpec, - RuntimeType, StorageQueryItem, StorageQueryType, -}; use truapi_platform::BulletinAllowanceKey; -use crate::chain_runtime::{ - ChainRuntime, wait_for_chain_head_call_output, wait_for_chain_head_storage_value, -}; +use crate::chain_runtime::ChainRuntime; use crate::host_logic::bulletin::{ - MortalityAnchor, STORE_CALL_NAME, STORE_PALLET_NAME, build_signed_store_extrinsic, - preimage_key, store_data_field_matches_key, system_events_storage_key, -}; -use crate::host_logic::extrinsic::{ - DecodedDispatchError, ExtrinsicOutcome, OfflineChainState, best_supported_metadata_version, - decode_account_nonce, decode_header_block_number, decode_runtime_metadata, - decode_transaction_validity, extrinsic_outcome_from_events, - validate_transaction_call_parameters, + MortalityAnchor, STORE_PALLET_NAME, allowance_signer, build_signed_store_transaction, + preimage_key, }; +use crate::host_logic::extrinsic::Sr25519Signer; /// Retry once when a broadcast is not included after the watch budget. This /// covers the post-allocation propagation window where dry-run can succeed /// against one node while the authoring path silently drops the broadcast. const SUBMIT_ATTEMPTS: usize = 2; -/// Budget for the follow to deliver `Initialized`. +/// 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); -/// Budget for a fresh follow to report the current best block after -/// `Initialized`. Falling back to finalized is safe for cold starts; active -/// chains normally report best immediately. +/// 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); -/// Budget for one pre-broadcast runtime call or storage read. -const OPERATION_TIMEOUT: Duration = Duration::from_secs(20); -/// Delay before retrying an operation start that hit `LimitReached`, and -/// before retrying an inaccessible `System.Events` read. -const OPERATION_RETRY_DELAY: Duration = Duration::from_millis(300); -/// Attempts for reading `System.Events` at the inclusion block. -const EVENTS_READ_ATTEMPTS: usize = 3; - -/// Monotonic salt for per-submit bulletin follow ids. -static BULLETIN_FOLLOW_COUNTER: AtomicU64 = AtomicU64::new(1); /// `TransactionStorage` module errors that mean the allowance account itself /// was rejected (missing or exhausted authorization), i.e. the one condition @@ -75,27 +61,24 @@ const ALLOWANCE_REJECTED_MODULE_ERRORS: &[&str] = /// Where a submission failed, for phase-tagged timeout reasons. type Phase = &'static str; -struct SubmissionHead { - best_hash: Vec, - runtime_spec: RuntimeSpec, -} +/// 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; -/// The broadcast transaction the watch loop looks for, plus the chain state -/// needed to content-match its body against the preimage key. -struct SubmittedTransaction<'a> { - state: &'a OfflineChainState, - anchor: &'a MortalityAnchor, - extrinsic_hash: [u8; 32], - preimage_key: &'a [u8; 32], - our_nonce: u64, - account: &'a [u8; 32], +#[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, follow, metadata, nonce, or anchor plumbing failed. + /// 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 }, @@ -103,8 +86,6 @@ pub(crate) enum BulletinSubmitError { AllowanceRejected { phase: AllowanceRejectionPhase }, /// The dry-run saw a nonce race (`Future`/`Stale`); no refresh. NonceRace, - /// The server had no free broadcast slot; retryable, nothing was sent. - BroadcastSlotUnavailable, /// The submission budget elapsed; `phase` names the step reached. Timeout { phase: Phase }, /// The transaction was broadcast but inclusion could not be verified. @@ -143,7 +124,6 @@ impl BulletinSubmitError { format!("allowance rejected: {phase}") } Self::NonceRace => "nonce race: retry".to_string(), - Self::BroadcastSlotUnavailable => "broadcast slot unavailable: retry".to_string(), Self::Timeout { phase } => format!("timeout: {phase}, inclusion unverified"), Self::BroadcastUnverified { reason } => format!("inclusion unverified: {reason}"), Self::IncludedButFailed { pallet, error } => { @@ -158,13 +138,9 @@ impl BulletinSubmitError { pub(crate) struct BulletinRpc { chain: ChainRuntime, genesis_hash: [u8; 32], - /// Serializes submissions: one broadcast + one ephemeral follow at a - /// time, and no same-account nonce races between concurrent submits. + /// Serializes submissions: no same-account nonce races between + /// concurrent submits. submit_lock: futures::lock::Mutex<()>, - /// Decoded metadata for the current spec version. - metadata_cache: StdMutex>, - /// Live broadcast operation id, stopped on every exit path. - active_broadcast: StdMutex>, /// Last phase entered, for timeout reasons. phase: StdMutex, } @@ -176,8 +152,6 @@ impl BulletinRpc { chain, genesis_hash, submit_lock: futures::lock::Mutex::new(()), - metadata_cache: StdMutex::new(None), - active_broadcast: StdMutex::new(None), phase: StdMutex::new("connect"), } } @@ -205,8 +179,7 @@ impl BulletinRpc { // 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 follow (releasing pins), and the explicit - // stop below covers any live broadcast on every exit path. + // timeout/cancel drops its in-flight chain work. let started = Instant::now(); let mut attempt = 0; loop { @@ -225,7 +198,6 @@ impl BulletinRpc { () = timeout => Err(BulletinSubmitError::Timeout { phase: self.current_phase() }), _ = cancelled => Err(BulletinSubmitError::Cancelled), }; - self.stop_active_broadcast().await; match result { Err(err) if attempt < SUBMIT_ATTEMPTS && err.is_retryable_watch_timeout() => { warn!( @@ -247,237 +219,115 @@ impl BulletinRpc { let key = preimage_key(value); self.enter_phase("connect"); - let follow_id = format!( - "truapi:bulletin:{}", - BULLETIN_FOLLOW_COUNTER.fetch_add(1, Ordering::Relaxed) - ); - let mut follow = self.chain.remote_chain_head_follow( - follow_id.clone(), - RemoteChainHeadFollowRequest { - genesis_hash: self.genesis_hash.to_vec(), - with_runtime: true, - }, - ); - let head = wait_for_submission_head(&mut follow).await?; - - self.enter_phase("metadata"); - let state = self - .offline_chain_state(&follow_id, &mut follow, &head.best_hash, head.runtime_spec) - .await?; - - self.enter_phase("build"); - let account = - crate::host_logic::extrinsic::public_key_from_secret_bytes(allowance.as_secret_bytes()) - .map_err(|reason| BulletinSubmitError::InvalidTransaction { kind: reason })?; - let anchor = self.mortality_anchor(&follow_id, &head.best_hash).await?; - let nonce = self - .account_nonce(&follow_id, &mut follow, &head.best_hash, &account) - .await?; - let signed = build_signed_store_extrinsic(&state, &anchor, allowance, nonce, value) - .map_err(|reason| BulletinSubmitError::InvalidTransaction { kind: reason })?; - - self.enter_phase("dry-run"); - self.dry_run(&follow_id, &mut follow, &head.best_hash, &signed.extrinsic) - .await?; - - self.enter_phase("broadcast"); - let extrinsic_hash = signed.extrinsic_hash; - let broadcast = self + let client = self .chain - .remote_chain_transaction_broadcast(RemoteChainTransactionBroadcastRequest { - genesis_hash: self.genesis_hash.to_vec(), - transaction: signed.extrinsic, - }) + .online_client(&self.genesis_hash) .await .map_err(|failure| BulletinSubmitError::ChainUnavailable { reason: failure.reason(), })?; - let Some(operation_id) = broadcast.operation_id else { - return Err(BulletinSubmitError::BroadcastSlotUnavailable); - }; - *self - .active_broadcast - .lock() - .expect("broadcast slot poisoned") = Some(operation_id); + 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("watch"); - let (inclusion_block, extrinsic_index) = self - .watch_for_inclusion( - &follow_id, - &mut follow, - SubmittedTransaction { - state: &state, - anchor: &anchor, - extrinsic_hash, - preimage_key: &key, - our_nonce: nonce, - account: &account, - }, - ) + 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"); - self.require_dispatch_success( - &follow_id, - &mut follow, - &state, - &anchor, - &inclusion_block, - extrinsic_index, - ) - .await?; + require_dispatch_success(&in_block).await?; Ok(key.to_vec()) } - /// Fetch (or reuse) decoded metadata for the follow's runtime spec and - /// assemble the offline chain state. The genesis hash is deliberately the - /// configured one, never provider-echoed. - async fn offline_chain_state( - &self, - follow_id: &str, - follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, - finalized_hash: &[u8], - runtime_spec: RuntimeSpec, - ) -> Result { - let spec_version = runtime_spec.spec_version; - let transaction_version = runtime_spec.transaction_version.ok_or_else(|| { - BulletinSubmitError::ChainUnavailable { - reason: "runtime spec lacks a transaction version".to_string(), - } - })?; - - let cached = self - .metadata_cache - .lock() - .expect("metadata cache poisoned") - .clone(); - let metadata = match cached { - Some((cached_spec, metadata)) if cached_spec == spec_version => metadata, - _ => { - let versions = self - .runtime_call( - follow_id, - follow, - finalized_hash, - "Metadata_metadata_versions", - Vec::new(), - ) - .await?; - let version = best_supported_metadata_version(&versions) - .map_err(|reason| BulletinSubmitError::ChainUnavailable { reason })?; - let response = self - .runtime_call( - follow_id, - follow, - finalized_hash, - "Metadata_metadata_at_version", - version.encode(), - ) - .await?; - let metadata = ArcMetadata::from( - decode_runtime_metadata(&response) - .map_err(|reason| BulletinSubmitError::ChainUnavailable { reason })?, - ); - *self.metadata_cache.lock().expect("metadata cache poisoned") = - Some((spec_version, metadata.clone())); - metadata - } - }; - - Ok(OfflineChainState { - genesis_hash: self.genesis_hash, - spec_version, - transaction_version, - metadata, - }) - } - - /// Resolve the finalized anchor block's number for the mortal era. - async fn mortality_anchor( + /// 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, - follow_id: &str, - finalized_hash: &[u8], - ) -> Result { - let response = self - .chain - .remote_chain_head_header(RemoteChainHeadHeaderRequest { - genesis_hash: self.genesis_hash.to_vec(), - follow_subscription_id: follow_id.to_string(), - hash: finalized_hash.to_vec(), - }) - .await - .map_err(|failure| BulletinSubmitError::ChainUnavailable { - reason: failure.reason(), - })?; - let header = response - .header - .ok_or_else(|| BulletinSubmitError::ChainUnavailable { - reason: "anchor block header unavailable".to_string(), - })?; - let number = decode_header_block_number(&header) - .map_err(|reason| BulletinSubmitError::ChainUnavailable { reason })?; - let hash = - finalized_hash - .try_into() - .map_err(|_| BulletinSubmitError::ChainUnavailable { - reason: "anchor block hash is not 32 bytes".to_string(), + best_blocks: &mut Blocks, + head: Block, + signer: &Sr25519Signer, + value: &[u8], + ) -> Result { + let account = AccountId32(signer.public_key()); + 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 nonce = at_block + .tx() + .account_nonce(&account) + .await + .map_err(|error| BulletinSubmitError::ChainUnavailable { + reason: format!("account nonce unavailable: {error}"), })?; - Ok(MortalityAnchor { number, hash }) - } - - /// Read the allowance account's next nonce at the anchor block. - async fn account_nonce( - &self, - follow_id: &str, - follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, - finalized_hash: &[u8], - account: &[u8; 32], - ) -> Result { - let output = self - .runtime_call( - follow_id, - follow, - finalized_hash, - "AccountNonceApi_account_nonce", - account.to_vec(), - ) - .await?; - decode_account_nonce(&output) - .map_err(|reason| BulletinSubmitError::ChainUnavailable { reason }) + let anchor = MortalityAnchor { + number: block.number(), + hash: block.hash().0, + }; + let signed = build_signed_store_transaction(&at_block, &anchor, signer, nonce, value) + .map_err(|kind| BulletinSubmitError::InvalidTransaction { kind })?; + + 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, + }); + } + } + } } - /// Dry-run the signed extrinsic against the anchor block. Broadcast never - /// reports invalid transactions, so this is the only deterministic signal - /// for stale allowances, nonce races, and encoding errors. - async fn dry_run( - &self, - follow_id: &str, - follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, - finalized_hash: &[u8], - extrinsic: &[u8], - ) -> Result<(), BulletinSubmitError> { - let parameters = validate_transaction_call_parameters(extrinsic, finalized_hash); - let output = self - .runtime_call( - follow_id, - follow, - finalized_hash, - "TaggedTransactionQueue_validate_transaction", - parameters, - ) - .await?; - let validity = decode_transaction_validity(&output) - .map_err(|reason| BulletinSubmitError::ChainUnavailable { reason })?; + fn classify_dry_run_validity( + validity: ValidationResult, + ) -> Result { match validity { - ValidationResult::Valid(_) => Ok(()), + ValidationResult::Valid(_) => Ok(DryRunStatus::Valid), ValidationResult::Invalid( TransactionInvalid::Payment | TransactionInvalid::Custom(_) | TransactionInvalid::BadSigner, - ) => Err(BulletinSubmitError::AllowanceRejected { - phase: AllowanceRejectionPhase::DryRun, - }), + ) => Ok(DryRunStatus::AllowanceRejected), ValidationResult::Invalid(TransactionInvalid::Future | TransactionInvalid::Stale) => { Err(BulletinSubmitError::NonceRace) } @@ -495,386 +345,6 @@ impl BulletinRpc { } } - /// Watch best/finalized blocks until the broadcast extrinsic appears in a - /// body. Runs as one event loop over the follow stream so no block event - /// is lost while a chainHead operation is in flight; block bodies are - /// only fetched once the allowance account's nonce is seen to advance. - async fn watch_for_inclusion( - &self, - follow_id: &str, - follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, - tx: SubmittedTransaction<'_>, - ) -> Result<(Vec, u32), BulletinSubmitError> { - let SubmittedTransaction { - state, - anchor, - extrinsic_hash, - preimage_key, - our_nonce, - account, - } = tx; - let stopped = || BulletinSubmitError::BroadcastUnverified { - reason: "chain follow stopped".to_string(), - }; - - // Parent links learned from NewBlock events, used to walk from a - // nonce-advanced block back to already-checked ancestors without - // extra header fetches. - let mut parents: HashMap, Vec> = HashMap::new(); - // Blocks whose bodies were checked (or skipped as inaccessible). - let mut checked: HashSet> = HashSet::new(); - // Blocks waiting for a nonce gate check. - let mut gate_queue: VecDeque> = VecDeque::new(); - // Blocks that passed the gate and await a body check. - let mut body_queue: VecDeque> = VecDeque::new(); - let mut pending_nonce: Option<(String, Vec)> = None; - let mut pending_body: Option<(String, Vec)> = None; - - loop { - // Start at most one operation at a time, bodies first. - if pending_nonce.is_none() && pending_body.is_none() { - if let Some(block) = body_queue.pop_front() { - match self.start_body(follow_id, &block).await? { - Some(operation_id) => pending_body = Some((operation_id, block)), - None => body_queue.push_front(block), - } - } else if let Some(block) = gate_queue.pop_front() { - match self.start_nonce_probe(follow_id, &block, account).await? { - Some(operation_id) => pending_nonce = Some((operation_id, block)), - None => gate_queue.push_front(block), - } - } - } - - let item = follow.next().await.ok_or_else(stopped)?; - match item { - RemoteChainHeadFollowItem::Stop => return Err(stopped()), - RemoteChainHeadFollowItem::NewBlock { - block_hash, - parent_block_hash, - .. - } => { - parents.insert(block_hash, parent_block_hash); - } - RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash } => { - if !checked.contains(&best_block_hash) { - gate_queue.push_back(best_block_hash); - } - } - RemoteChainHeadFollowItem::Finalized { - finalized_block_hashes, - pruned_block_hashes, - } => { - for hash in finalized_block_hashes { - if !checked.contains(&hash) && !gate_queue.contains(&hash) { - gate_queue.push_back(hash); - } - } - self.unpin(follow_id, pruned_block_hashes).await; - } - RemoteChainHeadFollowItem::OperationCallDone { - operation_id, - output, - } if pending_nonce - .as_ref() - .is_some_and(|(id, _)| *id == operation_id) => - { - let (_, block) = pending_nonce.take().expect("checked above"); - let nonce = decode_account_nonce(&output) - .map_err(|reason| BulletinSubmitError::BroadcastUnverified { reason })?; - if nonce > our_nonce { - // The account advanced at or before `block`: check its - // body and those of its unchecked ancestors. - for hash in ancestors_to_check(&parents, &checked, block) { - if !body_queue.contains(&hash) { - body_queue.push_back(hash); - } - } - } else { - // This block does not contain our tx; release its pin. - checked.insert(block.clone()); - self.unpin(follow_id, vec![block]).await; - } - } - RemoteChainHeadFollowItem::OperationBodyDone { - operation_id, - value, - } if pending_body - .as_ref() - .is_some_and(|(id, _)| *id == operation_id) => - { - let (_, block) = pending_body.take().expect("checked above"); - let matched = value.iter().position(|extrinsic| { - sp_crypto_hashing::blake2_256(extrinsic) == extrinsic_hash - }); - if let Some(index) = matched { - // Match the prior PAPI host behavior: resolve on - // best-block inclusion. A future API option can let - // callers prefer finalized inclusion. - return Ok((block, index as u32)); - } - match matching_store_extrinsic_index(state, anchor.number, value, preimage_key) - .await - { - Ok(Some(index)) => return Ok((block, index)), - Ok(None) => {} - Err(reason) => { - warn!( - reason = %reason, - "Bulletin store-call body match failed" - ); - } - } - checked.insert(block.clone()); - self.unpin(follow_id, vec![block]).await; - } - RemoteChainHeadFollowItem::OperationInaccessible { operation_id } - | RemoteChainHeadFollowItem::OperationError { operation_id, .. } => { - if pending_nonce - .as_ref() - .is_some_and(|(id, _)| *id == operation_id) - { - let (_, block) = pending_nonce.take().expect("checked above"); - checked.insert(block); - } else if pending_body - .as_ref() - .is_some_and(|(id, _)| *id == operation_id) - { - let (_, block) = pending_body.take().expect("checked above"); - checked.insert(block); - } - } - _ => {} - } - } - } - - /// Read `System.Events` at the inclusion block and require an - /// `ExtrinsicSuccess` for our extrinsic index. - async fn require_dispatch_success( - &self, - follow_id: &str, - follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, - state: &OfflineChainState, - anchor: &MortalityAnchor, - inclusion_block: &[u8], - extrinsic_index: u32, - ) -> Result<(), BulletinSubmitError> { - let unverified = |reason: String| BulletinSubmitError::BroadcastUnverified { reason }; - - let key = system_events_storage_key(); - let mut events_bytes = None; - for _attempt in 0..EVENTS_READ_ATTEMPTS { - let response = self - .chain - .remote_chain_head_storage(RemoteChainHeadStorageRequest { - genesis_hash: self.genesis_hash.to_vec(), - follow_subscription_id: follow_id.to_string(), - hash: inclusion_block.to_vec(), - items: vec![StorageQueryItem { - key: key.clone(), - query_type: StorageQueryType::Value, - }], - child_trie: None, - }) - .await - .map_err(|failure| unverified(failure.reason()))?; - let operation_id = match response.operation { - OperationStartedResult::Started { operation_id } => operation_id, - OperationStartedResult::LimitReached => { - futures_timer::Delay::new(OPERATION_RETRY_DELAY).await; - continue; - } - }; - // The block executed our extrinsic, so System.Events is never - // absent there: a missing value means the read was inaccessible. - match wait_for_chain_head_storage_value( - follow, - &operation_id, - &key, - "Bulletin", - OPERATION_TIMEOUT, - ) - .await - .map_err(unverified)? - { - Some(bytes) => { - events_bytes = Some(bytes); - break; - } - None => futures_timer::Delay::new(OPERATION_RETRY_DELAY).await, - } - } - let Some(events_bytes) = events_bytes else { - return Err(unverified( - "included, dispatch outcome unavailable".to_string(), - )); - }; - - let client = state - .client_at(anchor.number) - .map_err(|reason| unverified(format!("events decoding unavailable: {reason}")))?; - match extrinsic_outcome_from_events(&client, events_bytes, extrinsic_index) - .map_err(unverified)? - { - ExtrinsicOutcome::Success => Ok(()), - ExtrinsicOutcome::Failed(DecodedDispatchError::Module { pallet, error }) - if pallet == "TransactionStorage" - && ALLOWANCE_REJECTED_MODULE_ERRORS.contains(&error.as_str()) => - { - Err(BulletinSubmitError::AllowanceRejected { - phase: AllowanceRejectionPhase::Dispatch, - }) - } - ExtrinsicOutcome::Failed(DecodedDispatchError::Module { pallet, error }) => { - Err(BulletinSubmitError::IncludedButFailed { pallet, error }) - } - ExtrinsicOutcome::Failed(DecodedDispatchError::Other(reason)) => { - Err(BulletinSubmitError::IncludedButFailed { - pallet: "unknown".to_string(), - error: reason, - }) - } - ExtrinsicOutcome::NotFound => Err(unverified( - "included, but the block reported no dispatch outcome".to_string(), - )), - } - } - - /// Start one runtime-call operation at `hash`, retrying `LimitReached` - /// once, and wait for its output. - async fn runtime_call( - &self, - follow_id: &str, - follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, - hash: &[u8], - function: &str, - call_parameters: Vec, - ) -> Result, BulletinSubmitError> { - let mut attempts = 0; - let operation_id = loop { - attempts += 1; - let response = self - .chain - .remote_chain_head_call(RemoteChainHeadCallRequest { - genesis_hash: self.genesis_hash.to_vec(), - follow_subscription_id: follow_id.to_string(), - hash: hash.to_vec(), - function: function.to_string(), - call_parameters: call_parameters.clone(), - }) - .await - .map_err(|failure| BulletinSubmitError::ChainUnavailable { - reason: failure.reason(), - })?; - match response.operation { - OperationStartedResult::Started { operation_id } => break operation_id, - OperationStartedResult::LimitReached if attempts < 2 => { - futures_timer::Delay::new(OPERATION_RETRY_DELAY).await; - } - OperationStartedResult::LimitReached => { - return Err(BulletinSubmitError::ChainUnavailable { - reason: format!("{function}: chainHead operation limit reached"), - }); - } - } - }; - wait_for_chain_head_call_output(follow, &operation_id, "Bulletin", OPERATION_TIMEOUT) - .await - .map_err(|reason| BulletinSubmitError::ChainUnavailable { reason }) - } - - /// Start a nonce probe at `block`; `Ok(None)` means the operation limit - /// was reached and the caller should retry on the next event. - async fn start_nonce_probe( - &self, - follow_id: &str, - block: &[u8], - account: &[u8; 32], - ) -> Result, BulletinSubmitError> { - let response = self - .chain - .remote_chain_head_call(RemoteChainHeadCallRequest { - genesis_hash: self.genesis_hash.to_vec(), - follow_subscription_id: follow_id.to_string(), - hash: block.to_vec(), - function: "AccountNonceApi_account_nonce".to_string(), - call_parameters: account.to_vec(), - }) - .await - .map_err(|failure| BulletinSubmitError::BroadcastUnverified { - reason: failure.reason(), - })?; - Ok(match response.operation { - OperationStartedResult::Started { operation_id } => Some(operation_id), - OperationStartedResult::LimitReached => None, - }) - } - - /// Start a body fetch at `block`; `Ok(None)` means the operation limit - /// was reached and the caller should retry on the next event. - async fn start_body( - &self, - follow_id: &str, - block: &[u8], - ) -> Result, BulletinSubmitError> { - let response = self - .chain - .remote_chain_head_body(RemoteChainHeadBodyRequest { - genesis_hash: self.genesis_hash.to_vec(), - follow_subscription_id: follow_id.to_string(), - hash: block.to_vec(), - }) - .await - .map_err(|failure| BulletinSubmitError::BroadcastUnverified { - reason: failure.reason(), - })?; - Ok(match response.operation { - OperationStartedResult::Started { operation_id } => Some(operation_id), - OperationStartedResult::LimitReached => None, - }) - } - - /// Release pins for blocks the watch no longer needs; failures only warn. - async fn unpin(&self, follow_id: &str, hashes: Vec>) { - if hashes.is_empty() { - return; - } - if let Err(failure) = self - .chain - .remote_chain_head_unpin(RemoteChainHeadUnpinRequest { - genesis_hash: self.genesis_hash.to_vec(), - follow_subscription_id: follow_id.to_string(), - hashes, - }) - .await - { - warn!(reason = %failure.reason(), "Bulletin block unpin failed"); - } - } - - /// Stop the live broadcast, if any. Called on every submit exit path. - async fn stop_active_broadcast(&self) { - let operation_id = self - .active_broadcast - .lock() - .expect("broadcast slot poisoned") - .take(); - let Some(operation_id) = operation_id else { - return; - }; - if let Err(failure) = self - .chain - .remote_chain_transaction_stop(RemoteChainTransactionStopRequest { - genesis_hash: self.genesis_hash.to_vec(), - operation_id, - }) - .await - { - warn!(reason = %failure.reason(), "Bulletin broadcast stop failed"); - } - } - fn enter_phase(&self, phase: Phase) { *self.phase.lock().expect("phase slot poisoned") = phase; } @@ -884,262 +354,203 @@ impl BulletinRpc { } } -async fn wait_for_submission_head( - follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, -) -> Result { - let timeout = futures_timer::Delay::new(INITIALIZATION_TIMEOUT).fuse(); - pin_mut!(timeout); +/// 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 { - let next = follow.next().fuse(); - pin_mut!(next); - futures::select! { - item = next => match item { - Some(RemoteChainHeadFollowItem::Initialized { - finalized_block_hashes, - finalized_block_runtime, - }) => { - let finalized_hash = finalized_block_hashes - .last() - .cloned() - .ok_or_else(|| BulletinSubmitError::ChainUnavailable { - reason: "Bulletin follow initialized without finalized blocks" - .to_string(), - })?; - let runtime_spec = valid_runtime( - finalized_block_runtime, - "Bulletin follow initialized without runtime metadata", - )?; - return wait_for_submission_best_after_initialization( - follow, - finalized_hash, - runtime_spec, - ) - .await; - } - Some(RemoteChainHeadFollowItem::Stop) | None => { - return Err(BulletinSubmitError::ChainUnavailable { - reason: "Bulletin follow stopped before initialization".to_string(), - }); - } - _ => {} - }, - () = timeout => { - return Err(BulletinSubmitError::ChainUnavailable { - reason: "Bulletin follow initialization timed out".to_string(), - }); - }, + 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 wait_for_submission_best_after_initialization( - follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, - finalized_hash: Vec, - finalized_runtime: RuntimeSpec, -) -> Result { - let timeout = futures_timer::Delay::new(BEST_BLOCK_TIMEOUT).fuse(); - pin_mut!(timeout); - let mut known_runtimes = HashMap::from([(finalized_hash.clone(), finalized_runtime.clone())]); - let mut candidate_hash = finalized_hash; - let mut candidate_runtime = finalized_runtime.clone(); - - loop { - let next = follow.next().fuse(); - pin_mut!(next); - futures::select! { - item = next => match item { - Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { - let runtime_spec = known_runtimes - .get(&best_block_hash) - .cloned() - .unwrap_or_else(|| candidate_runtime.clone()); - return Ok(SubmissionHead { - best_hash: best_block_hash, - runtime_spec, - }); - } - Some(RemoteChainHeadFollowItem::NewBlock { - block_hash, - parent_block_hash, - new_runtime, - }) => { - let runtime_spec = if let Some(runtime) = new_runtime { - valid_runtime( - Some(runtime), - "Bulletin follow reported a new block without runtime metadata", - )? - } else { - known_runtimes - .get(&parent_block_hash) - .cloned() - .unwrap_or_else(|| candidate_runtime.clone()) - }; - known_runtimes.insert(block_hash.clone(), runtime_spec.clone()); - candidate_hash = block_hash; - candidate_runtime = runtime_spec; - } - Some(RemoteChainHeadFollowItem::Stop) | None => { - return Err(BulletinSubmitError::ChainUnavailable { - reason: "Bulletin follow stopped before best block".to_string(), - }); - } - _ => {} - }, - () = timeout => { - return Ok(SubmissionHead { - best_hash: candidate_hash, - runtime_spec: candidate_runtime, - }); - }, - } +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 valid_runtime( - runtime: Option, - missing_reason: &'static str, -) -> Result { - match runtime { - Some(RuntimeType::Valid(spec)) => Ok(spec), - Some(RuntimeType::Invalid { error }) => Err(BulletinSubmitError::ChainUnavailable { - reason: format!("Bulletin follow reported an invalid runtime: {error}"), - }), - None => Err(BulletinSubmitError::ChainUnavailable { - reason: missing_reason.to_string(), - }), +/// 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(BulletinSubmitError::InvalidTransaction { kind: 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(), + )) } -async fn matching_store_extrinsic_index( - state: &OfflineChainState, - block_number: u64, - extrinsics: Vec>, - preimage_key: &[u8; 32], -) -> Result, String> { - let client = state.client_at(block_number)?; - let extrinsics = client.extrinsics().from_bytes(extrinsics).await; - for extrinsic in extrinsics.iter() { - let extrinsic = extrinsic.map_err(|err| format!("cannot decode block extrinsic: {err}"))?; - if extrinsic.pallet_name() != STORE_PALLET_NAME || extrinsic.call_name() != STORE_CALL_NAME - { - continue; - } - - let mut fields = extrinsic.iter_call_data_fields(); - let Some(field) = fields.next() else { - continue; - }; - if fields.next().is_none() && store_data_field_matches_key(field.bytes(), preimage_key) { - return Ok(Some(extrinsic.index() as u32)); +/// 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}" + ))), } - Ok(None) } -/// Collect `start` and its not-yet-checked ancestors from a provider-supplied -/// `parents` map, oldest lookups first. -/// -/// `parents` comes from untrusted `NewBlock` follow events, so the walk guards -/// against self-parent and cyclic links with a visited set: without it a -/// crafted `parent == block` link would spin forever, and since the enclosing -/// watch loop never `.await`s inside the walk, the whole worker would freeze -/// and hold the submit lock permanently. -fn ancestors_to_check( - parents: &HashMap, Vec>, - checked: &HashSet>, - start: Vec, -) -> Vec> { - let mut cursor = start.clone(); - let mut visited: HashSet> = HashSet::from([start.clone()]); - let mut walk = vec![start]; - while let Some(parent) = parents.get(&cursor) { - if checked.contains(parent) || !visited.insert(parent.clone()) { - break; +/// 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 } + } } - walk.push(parent.clone()); - cursor = parent.clone(); + Err(_) => BulletinSubmitError::IncludedButFailed { + pallet: "unknown".to_string(), + error: module_error.details_string(), + }, } - walk } #[cfg(test)] mod tests { use super::*; + use crate::host_logic::extrinsic::tests::bulletin_metadata; + use subxt::metadata::ArcMetadata; - fn h(byte: u8) -> Vec { - vec![byte] - } - - fn runtime_spec(spec_version: u32) -> RuntimeSpec { - RuntimeSpec { - spec_name: "test".to_string(), - impl_name: "test".to_string(), - spec_version, - impl_version: 1, - transaction_version: Some(1), - apis: Vec::new(), + #[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 submission_head_uses_best_block_runtime_update() { - let mut follow = futures::stream::iter(vec![ - RemoteChainHeadFollowItem::Initialized { - finalized_block_hashes: vec![h(1)], - finalized_block_runtime: Some(RuntimeType::Valid(runtime_spec(1))), - }, - RemoteChainHeadFollowItem::NewBlock { - block_hash: h(2), - parent_block_hash: h(1), - new_runtime: Some(RuntimeType::Valid(runtime_spec(2))), - }, - RemoteChainHeadFollowItem::BestBlockChanged { - best_block_hash: h(2), - }, - ]) - .boxed(); - - let head = - futures::executor::block_on(wait_for_submission_head(&mut follow)).expect("head"); + /// 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() + } - assert_eq!(head.best_hash, h(2)); - assert_eq!(head.runtime_spec.spec_version, 2); + /// 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 ancestors_walk_collects_unchecked_chain() { - // c -> b -> a, none checked: walk collects all three newest-first. - let parents = HashMap::from([(h(3), h(2)), (h(2), h(1))]); - let checked = HashSet::new(); + fn dispatch_errors_classify_allowance_rejections() { assert_eq!( - ancestors_to_check(&parents, &checked, h(3)), - vec![h(3), h(2), h(1)] + classify_dispatch_error(module_error("AuthorizationNotFound")), + BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::Dispatch + } ); - } - - #[test] - fn ancestors_walk_stops_at_checked_parent() { - let parents = HashMap::from([(h(3), h(2)), (h(2), h(1))]); - let checked = HashSet::from([h(2)]); - assert_eq!(ancestors_to_check(&parents, &checked, h(3)), vec![h(3)]); - } - #[test] - fn ancestors_walk_terminates_on_self_parent() { - // A crafted self-referential parent must not loop forever. - let parents = HashMap::from([(h(5), h(5))]); - let checked = HashSet::new(); - assert_eq!(ancestors_to_check(&parents, &checked, h(5)), vec![h(5)]); - } - - #[test] - fn ancestors_walk_terminates_on_cycle() { - // A -> B -> A cycle must terminate once it wraps around. - let parents = HashMap::from([(h(1), h(2)), (h(2), h(1))]); - let checked = HashSet::new(); + let other = non_allowance_error_name(); assert_eq!( - ancestors_to_check(&parents, &checked, h(1)), - vec![h(1), h(2)] + classify_dispatch_error(module_error(&other)), + BulletinSubmitError::IncludedButFailed { + pallet: STORE_PALLET_NAME.to_string(), + error: other + } ); } diff --git a/rust/crates/truapi-server/src/runtime/identity.rs b/rust/crates/truapi-server/src/runtime/identity.rs index 0c36a897..812790c8 100644 --- a/rust/crates/truapi-server/src/runtime/identity.rs +++ b/rust/crates/truapi-server/src/runtime/identity.rs @@ -5,19 +5,18 @@ 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, -}; +use crate::chain_runtime::ChainRuntime; 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 subxt::backend::Backend; use tracing::{debug, instrument, warn}; -use truapi::latest::{ - OperationStartedResult, RemoteChainHeadFollowRequest, RemoteChainHeadStorageRequest, - StorageQueryItem, StorageQueryType, -}; + +/// Budget for the whole People-chain lookup (finalized block + storage read). +const LOOKUP_TIMEOUT: Duration = Duration::from_secs(10); /// Fill in missing usernames by querying the people chain; returns the /// session unchanged when it already carries a username or no people chain @@ -101,63 +100,46 @@ 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}; - - /// Monotonic salt for local identity lookup follow ids, avoiding - /// collisions between concurrent People-chain identity lookups. - static IDENTITY_LOOKUP_COUNTER: AtomicU64 = AtomicU64::new(1); - - IDENTITY_LOOKUP_COUNTER.fetch_add(1, Ordering::Relaxed) + let lookup = fetch_consumer_record(chain, people_chain_genesis_hash, account_id).fuse(); + let timeout = futures_timer::Delay::new(LOOKUP_TIMEOUT).fuse(); + pin_mut!(lookup, timeout); + let value = futures::select! { + value = lookup => value?, + () = timeout => return Err("People-chain identity lookup timed out".to_string()), }; - let follow_id = format!("truapi:identity:{}:{}", lookup_id, hex::encode(account_id),); - let mut follow = chain.remote_chain_head_follow( - follow_id.clone(), - RemoteChainHeadFollowRequest { - genesis_hash: genesis_hash.clone(), - with_runtime: false, - }, - ); + match value { + Some(value) => decode_people_identity(&value).map(Some), + None => Ok(None), + } +} - let hash = wait_for_chain_head_best_hash( - &mut follow, - "People-chain", - Duration::from_secs(10), - Duration::from_secs(2), - ) - .await?; - let response = chain - .remote_chain_head_storage(RemoteChainHeadStorageRequest { - genesis_hash, - follow_subscription_id: follow_id, - hash, - items: vec![StorageQueryItem { - key: key.clone(), - query_type: StorageQueryType::Value, - }], - child_trie: None, - }) +/// Read the raw `Resources.Consumers` record for `account_id` at the latest +/// finalized block. 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>, String> { + let key = resources_consumers_storage_key(&account_id); + let backend = chain + .chain_head_backend(&people_chain_genesis_hash) .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 at = backend + .latest_finalized_block_ref() + .await + .map_err(|error| format!("People-chain finalized block unavailable: {error}"))?; + let mut values = backend + .storage_fetch_values(vec![key.clone()], at.hash()) + .await + .map_err(|error| format!("People-chain storage lookup failed: {error}"))?; + let mut value = None; + while let Some(item) = values.next().await { + let item = item.map_err(|error| format!("People-chain storage lookup failed: {error}"))?; + if item.key == key { + value = Some(item.value); } - }; - let Some(value) = wait_for_chain_head_storage_value( - &mut follow, - &operation_id, - &key, - "People-chain", - Duration::from_secs(10), - ) - .await? - else { - return Ok(None); - }; - decode_people_identity(&value).map(Some) + } + Ok(value) } diff --git a/rust/crates/truapi/src/api/preimage.rs b/rust/crates/truapi/src/api/preimage.rs index 296748e9..5e818de3 100644 --- a/rust/crates/truapi/src/api/preimage.rs +++ b/rust/crates/truapi/src/api/preimage.rs @@ -15,7 +15,7 @@ pub trait Preimage: Send + Sync { /// import { firstValueFrom, from } from "rxjs"; /// /// // Submit a preimage first so the lookup resolves to a value. - /// const value = crypto.getRandomValues(new Uint8Array(4)).toHex(); + /// const value = crypto.getRandomValues(new Uint8Array(4)).toHex() as `0x${string}`; /// const submitted = await truapi.preimage.submit(value); /// assert(submitted.isOk(), "submit failed:", submitted); /// @@ -36,7 +36,7 @@ pub trait Preimage: Send + Sync { /// Submit a preimage. Returns the preimage key (hash) on success. /// /// ```ts - /// const value = crypto.getRandomValues(new Uint8Array(4)).toHex(); + /// 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); From 28941c9db354b6e823be9e38fe9375b839e501db Mon Sep 17 00:00:00 2001 From: pgherveou Date: Sat, 11 Jul 2026 14:11:19 +0200 Subject: [PATCH 8/9] 2nd pass --- Cargo.lock | 6 +- playground/src/lib/auto-test.ts | 1 + playground/tests/e2e/dotli-diagnosis.ts | 14 + rust/crates/truapi-server/Cargo.toml | 18 +- .../crates/truapi-server/src/chain_runtime.rs | 506 +++++++++++++----- .../truapi-server/src/host_logic/bulletin.rs | 157 +++--- .../truapi-server/src/host_logic/entropy.rs | 8 +- .../truapi-server/src/host_logic/extrinsic.rs | 9 +- .../src/host_logic/product_account.rs | 22 +- .../truapi-server/src/host_logic/session.rs | 42 ++ .../src/host_logic/sso/pairing.rs | 6 +- rust/crates/truapi-server/src/runtime.rs | 30 +- .../truapi-server/src/runtime/authority.rs | 17 + .../truapi-server/src/runtime/bulletin_rpc.rs | 69 ++- .../truapi-server/src/runtime/identity.rs | 131 +++-- .../truapi-server/src/runtime/pairing_host.rs | 70 ++- .../truapi-server/src/runtime/services.rs | 4 +- .../src/runtime/statement_store.rs | 4 +- .../src/runtime/statement_store_rpc.rs | 6 + rust/crates/truapi-server/src/test_support.rs | 24 +- 20 files changed, 826 insertions(+), 318 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 12604539..f3c480cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3693,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", @@ -3707,8 +3705,6 @@ dependencies = [ "nanoid", "p256", "parity-scale-codec", - "pin-project", - "primitive-types", "scale-info", "schnorrkel", "send_wrapper 0.6.0", diff --git a/playground/src/lib/auto-test.ts b/playground/src/lib/auto-test.ts index b32f19aa..18c4a987 100644 --- a/playground/src/lib/auto-test.ts +++ b/playground/src/lib/auto-test.ts @@ -37,6 +37,7 @@ 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", LIVE_ALLOCATION_TIMEOUT_MS], 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-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 60ebefa4..67f36e1c 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -37,9 +37,8 @@ truapi-platform = { path = "../truapi-platform" } async-trait = "0.1" 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"] } @@ -68,24 +66,16 @@ 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" diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 94214a6e..8069be14 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -8,9 +8,9 @@ //! public v01 [`RemoteChainHeadFollowItem`] values. //! //! Each connection also lazily caches one genesis-pinned Subxt -//! [`OnlineClient`] + [`ChainHeadBackend`] pair over the same transport; -//! internal services (Bulletin submission, identity lookups) use it instead -//! of hand-rolling chainHead orchestration. +//! [`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 @@ -22,21 +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::{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}; @@ -104,16 +108,14 @@ type ConnectionSetup = Shared, Ru /// 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 OnlineClientSetup = Shared>>; +type SubxtConnectionSetup = Shared>>; -/// Cached Subxt client + backend pair built over one connection's transport. +/// 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, - /// The chainHead backend under `client`, for raw-key reads that must not - /// trigger a metadata fetch. - pub(crate) backend: Arc>, } /// Classification of framework-level chain failures separate from JSON-RPC @@ -156,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 } @@ -175,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()), } } } @@ -240,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()); @@ -263,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() } @@ -512,8 +515,9 @@ impl ChainRuntime { .map_err(|err| rpc_failure(method, err)) } - /// Cached, genesis-pinned Subxt client for the chain identified by - /// `genesis_hash`. + /// 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, @@ -522,22 +526,14 @@ impl ChainRuntime { Ok(self.subxt_connection(genesis_hash).await?.client) } - /// Cached chainHead backend for the chain identified by `genesis_hash`, - /// for raw-key storage reads that must not trigger a metadata fetch. - #[instrument(skip_all, fields(runtime.method = "chain_runtime.chain_head_backend"))] - pub(crate) async fn chain_head_backend( - &self, - genesis_hash: &[u8], - ) -> Result>, RuntimeFailure> { - Ok(self.subxt_connection(genesis_hash).await?.backend) - } - async fn subxt_connection( &self, genesis_hash: &[u8], ) -> Result { - let connection = self.connection_for("online_client", genesis_hash).await?; - connection.online_client().await + 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))] @@ -597,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) { @@ -658,24 +654,20 @@ 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 client's chain config. + /// cached Subxt bundle's chain config. genesis_hash: Vec, follows: Mutex>, follow_setups: Mutex>, - online_client_setup: 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 { @@ -689,7 +681,8 @@ impl ChainConnection { genesis_hash, follows: Mutex::new(HashMap::new()), follow_setups: Mutex::new(HashMap::new()), - online_client_setup: Mutex::new(None), + subxt_connection_setup: Mutex::new(None), + subxt_connection_generation: AtomicU64::new(0), }) } @@ -697,39 +690,58 @@ impl ChainConnection { self.rpc_client.is_closed() } - /// Lazily build (single-flight) and cache the Subxt client over this + /// 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 instead of leaking one driver per call. - async fn online_client(self: &Arc) -> Result { - let setup = { - let mut slot = self.online_client_setup.lock().unwrap(); + /// 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: OnlineClientSetup = - async move { connection.build_online_client().await } + let setup: SubxtConnectionSetup = + async move { connection.build_subxt_connection(generation).await } .boxed() .shared(); - *slot = Some(setup.clone()); - setup + *slot = Some((generation, setup.clone())); + (generation, setup) } }; let result = setup.await; - // On failure, drop the cached setup so a later submission can retry. + // On failure, drop the cached setup so a later call can retry. if result.is_err() { - *self.online_client_setup.lock().unwrap() = None; + self.invalidate_subxt_connection(generation); } result } - /// Body of the single-flight client setup: start the chainHead backend, + /// 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_online_client(self: Arc) -> Result { - const METHOD: &str = "online_client"; + 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, @@ -741,6 +753,9 @@ impl ChainConnection { })?; 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 { @@ -748,6 +763,12 @@ impl ChainConnection { 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(), ); @@ -758,7 +779,7 @@ impl ChainConnection { let client = OnlineClient::from_backend_with_config(config, backend.clone()) .await .map_err(|error| RuntimeFailure::host_failure(METHOD, error.to_string()))?; - Ok(SubxtConnection { client, backend }) + Ok(SubxtConnection { client }) } fn follow_with_runtime(&self, local_follow_id: &str) -> bool { @@ -784,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( @@ -922,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(); @@ -940,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; } } @@ -1000,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); } } @@ -1047,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, } @@ -1266,6 +1273,154 @@ pub(crate) fn encode_hex(value: &[u8]) -> String { format!("0x{}", hex::encode(value)) } +/// Wait for a usable best block hash from a `chainHead_v1_follow` stream. +pub(crate) async fn wait_for_chain_head_best_hash( + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + label: &'static str, + initialization_timeout: Duration, + best_hash_timeout: Duration, +) -> Result, String> { + let timeout = futures_timer::Delay::new(initialization_timeout).fuse(); + pin_mut!(timeout); + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::Initialized { finalized_block_hashes, .. }) => { + let fallback = finalized_block_hashes.last().cloned(); + return wait_for_chain_head_best_hash_after_initialization( + follow, + label, + fallback, + best_hash_timeout, + ) + .await; + } + Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { + return Ok(best_block_hash); + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return Err(format!("{label} follow stopped before initialization")); + } + _ => {} + }, + () = timeout => return Err(format!("{label} follow initialization timed out")), + } + } +} + +async fn wait_for_chain_head_best_hash_after_initialization( + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + label: &'static str, + fallback: Option>, + timeout: Duration, +) -> Result, String> { + let timeout = futures_timer::Delay::new(timeout).fuse(); + pin_mut!(timeout); + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { + return Ok(best_block_hash); + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return Err(format!("{label} follow stopped before best block")); + } + _ => {} + }, + () = timeout => { + return fallback.clone().ok_or_else(|| { + format!("{label} follow best block timed out") + }); + }, + } + } +} + +/// 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>, + lookup: ChainHeadStorageValueLookup<'_>, +) -> Result { + let timeout = futures_timer::Delay::new(lookup.timeout).fuse(); + pin_mut!(timeout); + let mut value = None; + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::OperationStorageItems { operation_id: item_operation_id, items }) + if item_operation_id == lookup.operation_id => + { + for item in items { + if item.key == lookup.key { + value = item.value; + } + } + } + Some(RemoteChainHeadFollowItem::OperationStorageDone { operation_id: item_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 => + { + 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 == lookup.operation_id => + { + return Ok(ChainHeadStorageValue::Inaccessible); + } + Some(RemoteChainHeadFollowItem::OperationError { operation_id: item_operation_id, error }) + if item_operation_id == lookup.operation_id => + { + return Err(error); + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return Err(format!("{} follow stopped during storage lookup", lookup.label)); + } + _ => {} + }, + () = timeout => return Err(format!("{} storage lookup timed out", lookup.label)), + } + } +} + #[cfg(test)] fn decode_hex(value: &str) -> Result, String> { hex::decode(value.strip_prefix("0x").unwrap_or(value)).map_err(|_| "invalid hex".to_string()) @@ -1276,7 +1431,7 @@ mod tests { use super::*; use async_trait::async_trait; use futures::channel::mpsc as fut_mpsc; - use futures::stream::BoxStream; + use futures::stream::{self, BoxStream}; use std::sync::atomic::{AtomicUsize, Ordering}; fn spawner_for_tests() -> Spawner { @@ -1290,6 +1445,84 @@ mod tests { } } + #[test] + fn chain_head_best_hash_prefers_best_block_after_initialization() { + let mut follow = stream::iter(vec![ + RemoteChainHeadFollowItem::Initialized { + finalized_block_hashes: vec![vec![0x01]], + finalized_block_runtime: None, + }, + RemoteChainHeadFollowItem::BestBlockChanged { + best_block_hash: vec![0x02], + }, + ]) + .boxed(); + + let hash = futures::executor::block_on(wait_for_chain_head_best_hash( + &mut follow, + "test chain", + Duration::from_secs(10), + Duration::from_secs(2), + )) + .expect("best hash should resolve"); + + 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![ + 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, + }, + RemoteChainHeadFollowItem::Stop, + ]) + .boxed(); + + let err = futures::executor::block_on(wait_for_chain_head_best_hash( + &mut follow, + "test chain", + Duration::from_secs(10), + Duration::from_secs(2), + )) + .expect_err("follow stop should be terminal before best block"); + + assert_eq!(err, "test chain follow stopped before best block"); + } + #[derive(Default)] struct UnavailableChainProvider; @@ -1558,20 +1791,20 @@ mod tests { assert_eq!(provider.inner.connect_calls.load(Ordering::SeqCst), 1); } - /// The cached Subxt client must pin the host-configured genesis hash + /// 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 online_client_pins_configured_genesis_and_is_cached() { + 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("online_client_test", &genesis)) + futures::executor::block_on(runtime.connection_for("subxt_connection_test", &genesis)) .expect("connection"); - let first = futures::executor::block_on(connection.online_client()).expect("client"); - let second = futures::executor::block_on(connection.online_client()).expect("client"); + 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 @@ -1604,7 +1837,40 @@ mod tests { .iter() .filter(|request| request.contains("chainHead_v1_follow")) .count(); - assert_eq!(follows, 1, "cached client must reuse one backend follow"); + 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] diff --git a/rust/crates/truapi-server/src/host_logic/bulletin.rs b/rust/crates/truapi-server/src/host_logic/bulletin.rs index 2eefaf66..cbdabae7 100644 --- a/rust/crates/truapi-server/src/host_logic/bulletin.rs +++ b/rust/crates/truapi-server/src/host_logic/bulletin.rs @@ -6,13 +6,13 @@ //! caller-supplied call data. use parity_scale_codec::{Compact, Encode}; -use subxt::client::{ClientAtBlock, OfflineClientAtBlockT}; +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 subxt::utils::H256; use truapi_platform::BulletinAllowanceKey; use crate::host_logic::extrinsic::Sr25519Signer; @@ -20,10 +20,7 @@ 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. Must stay <= 4096 so the era -/// phase quantization is a no-op and the anchor block is the era birth block -/// (larger periods make the encoded anchor hash wrong and the signature -/// fails as BadProof). +/// Mortality window for store transactions. const MORTAL_PERIOD_BLOCKS: u64 = 64; /// Preimage key: blake2b-256 of the raw preimage bytes. @@ -31,48 +28,21 @@ pub(crate) fn preimage_key(value: &[u8]) -> [u8; 32] { sp_crypto_hashing::blake2_256(value) } -/// Block the transaction's mortality is anchored at. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct MortalityAnchor { - /// Anchor block number (era birth block). - pub(crate) number: u64, - /// Anchor block hash (the `CheckMortality` implicit). - pub(crate) hash: [u8; 32], -} - /// Build and sign a `TransactionStorage.store { data }` transaction with the -/// Bulletin allowance signer against the client's block. The anchor must be -/// the block the client is at, so the mortal era and the dry-run block agree. -pub(crate) fn build_signed_store_transaction>( +/// 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, - anchor: &MortalityAnchor, signer: &Sr25519Signer, - nonce: u64, data: &[u8], -) -> Result, String> { - if !client - .metadata_ref() - .extrinsic() - .supported_versions() - .contains(&4) - { - return Err(format!( - "bulletin runtime no longer supports v4 extrinsics (supported: {:?})", - client.metadata_ref().extrinsic().supported_versions() - )); - } - +) -> Result, ExtrinsicError> { 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)) + .mortal(MORTAL_PERIOD_BLOCKS) .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}")) + let mut tx = client.tx(); + tx.create_signed(&payload, signer, params).await } /// The only [`BulletinAllowanceKey`] -> signer conversion in the crate. The @@ -149,8 +119,17 @@ mod tests { 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( @@ -165,13 +144,33 @@ mod tests { allowance_signer(&allowance_fixture()).unwrap() } - fn anchor_fixture() -> MortalityAnchor { - MortalityAnchor { + 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( @@ -223,12 +222,17 @@ mod tests { 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(&client, &anchor_fixture(), &signer_fixture(), 7, &data) - .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().public_key()); + 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)); @@ -267,9 +271,14 @@ mod tests { let client = bulletin_chain_state() .client_at(anchor_fixture().number) .unwrap(); - let signed = - build_signed_store_transaction(&client, &anchor_fixture(), &signer_fixture(), 0, &data) - .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 { @@ -338,7 +347,7 @@ mod tests { let state = state_with_metadata(metadata_from_v14(metadata)); let client = state.client_at(anchor_fixture().number).unwrap(); - let error = build_signed_store_transaction( + let error = build_signed_store_transaction_offline( &client, &anchor_fixture(), &signer_fixture(), @@ -358,9 +367,14 @@ mod tests { let state = state_with_metadata(metadata_from_v14(metadata)); let client = state.client_at(anchor_fixture().number).unwrap(); - let error = - build_signed_store_transaction(&client, &anchor_fixture(), &signer_fixture(), 0, &[1]) - .unwrap_err(); + let error = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 0, + &[1], + ) + .unwrap_err(); assert!(error.contains("FakeImplicitExt"), "{error}"); } @@ -373,9 +387,14 @@ mod tests { let state = state_with_metadata(metadata_from_v14(metadata)); let client = state.client_at(anchor_fixture().number).unwrap(); - let error = - build_signed_store_transaction(&client, &anchor_fixture(), &signer_fixture(), 0, &[1]) - .unwrap_err(); + let error = build_signed_store_transaction_offline( + &client, + &anchor_fixture(), + &signer_fixture(), + 0, + &[1], + ) + .unwrap_err(); assert!(error.contains("FakeValueExt"), "{error}"); } @@ -396,7 +415,7 @@ mod tests { let baseline_client = bulletin_chain_state() .client_at(anchor_fixture().number) .unwrap(); - let baseline = build_signed_store_transaction( + let baseline = build_signed_store_transaction_offline( &baseline_client, &anchor_fixture(), &signer_fixture(), @@ -405,9 +424,14 @@ mod tests { ) .unwrap(); let client = state.client_at(anchor_fixture().number).unwrap(); - let with_fake = - build_signed_store_transaction(&client, &anchor_fixture(), &signer_fixture(), 0, &[1]) - .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, @@ -426,9 +450,14 @@ mod tests { .client_at(anchor_fixture().number) .unwrap(); let start = std::time::Instant::now(); - let signed = - build_signed_store_transaction(&client, &anchor_fixture(), &signer_fixture(), 0, &data) - .unwrap(); + 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!( 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 index b8ee8631..713ed740 100644 --- a/rust/crates/truapi-server/src/host_logic/extrinsic.rs +++ b/rust/crates/truapi-server/src/host_logic/extrinsic.rs @@ -58,11 +58,6 @@ impl Sr25519Signer { public: keypair.public, } } - - /// Public key of the signing account. - pub(crate) fn public_key(&self) -> [u8; 32] { - self.public.to_bytes() - } } impl Signer for Sr25519Signer { @@ -247,7 +242,7 @@ pub(crate) mod tests { let MultiSignature::Sr25519(signature) = signer.sign(payload) else { panic!("expected sr25519 signature"); }; - let public = PublicKey::from_bytes(&signer.public_key()).unwrap(); + let public = PublicKey::from_bytes(&signer.account_id().0).unwrap(); assert!( public .verify_simple( @@ -305,7 +300,7 @@ pub(crate) mod tests { 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.public_key()); + assert_eq!(account, signer.account_id().0); assert_eq!(tail, expected_tail); // Signature verifies over call ++ Σextra ++ Σadditional (call first). 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 e827e506..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 diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 269e65e4..4debdd2a 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -917,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(), })) } diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index 4cbdbc11..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. @@ -228,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, diff --git a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs index 513b979d..c19de06b 100644 --- a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs @@ -17,26 +17,25 @@ 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, TransactionEventsError}; +use subxt::error::{DispatchError, ExtrinsicError, TransactionEventsError}; use subxt::tx::{ SubmittableTransaction, TransactionInBlock, TransactionInvalid, TransactionStatus, TransactionUnknown, ValidationResult, }; -use subxt::utils::AccountId32; use tracing::{instrument, warn}; use truapi::CallContext; use truapi_platform::BulletinAllowanceKey; use crate::chain_runtime::ChainRuntime; use crate::host_logic::bulletin::{ - MortalityAnchor, STORE_PALLET_NAME, allowance_signer, build_signed_store_transaction, - preimage_key, + STORE_PALLET_NAME, allowance_signer, build_signed_store_transaction, preimage_key, }; use crate::host_logic::extrinsic::Sr25519Signer; -/// Retry once when a broadcast is not included after the watch budget. This -/// covers the post-allocation propagation window where dry-run can succeed -/// against one node while the authoring path silently drops the broadcast. +/// 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 @@ -105,8 +104,9 @@ pub(crate) enum AllowanceRejectionPhase { } impl BulletinSubmitError { - fn is_retryable_watch_timeout(&self) -> bool { + 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. @@ -199,7 +199,10 @@ impl BulletinRpc { _ = cancelled => Err(BulletinSubmitError::Cancelled), }; match result { - Err(err) if attempt < SUBMIT_ATTEMPTS && err.is_retryable_watch_timeout() => { + Err(err) + if attempt < SUBMIT_ATTEMPTS + && err.is_retryable_submission_uncertain(self.current_phase()) => + { warn!( attempt, reason = %err.reason(), @@ -261,7 +264,6 @@ impl BulletinRpc { signer: &Sr25519Signer, value: &[u8], ) -> Result { - let account = AccountId32(signer.public_key()); let mut block = head; let mut allowance_rejections = 0; loop { @@ -273,19 +275,9 @@ impl BulletinRpc { .map_err(|error| BulletinSubmitError::ChainUnavailable { reason: format!("block {} unavailable: {error}", block.number()), })?; - let nonce = at_block - .tx() - .account_nonce(&account) + let signed = build_signed_store_transaction(&at_block, signer, value) .await - .map_err(|error| BulletinSubmitError::ChainUnavailable { - reason: format!("account nonce unavailable: {error}"), - })?; - let anchor = MortalityAnchor { - number: block.number(), - hash: block.hash().0, - }; - let signed = build_signed_store_transaction(&at_block, &anchor, signer, nonce, value) - .map_err(|kind| BulletinSubmitError::InvalidTransaction { kind })?; + .map_err(map_store_transaction_build_error)?; self.enter_phase("dry-run"); let validity = @@ -393,6 +385,17 @@ async fn next_best_block( } } +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( @@ -411,7 +414,9 @@ async fn watch_until_included( return Ok(block); } TransactionStatus::Invalid { message } => { - return Err(BulletinSubmitError::InvalidTransaction { kind: message }); + return Err(unverified(format!( + "transaction invalid after successful dry-run: {message}" + ))); } TransactionStatus::Dropped { message } => { return Err(unverified(format!("transaction dropped: {message}"))); @@ -506,6 +511,24 @@ mod tests { } } + #[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 { diff --git a/rust/crates/truapi-server/src/runtime/identity.rs b/rust/crates/truapi-server/src/runtime/identity.rs index 812790c8..36e7d9f4 100644 --- a/rust/crates/truapi-server/src/runtime/identity.rs +++ b/rust/crates/truapi-server/src/runtime/identity.rs @@ -1,22 +1,41 @@ //! 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; +use crate::chain_runtime::{ + 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 subxt::backend::Backend; use tracing::{debug, instrument, warn}; +use truapi::v01::{ + OperationStartedResult, RemoteChainHeadFollowRequest, RemoteChainHeadStorageRequest, + StorageQueryItem, StorageQueryType, +}; -/// Budget for the whole People-chain lookup (finalized block + storage read). +/// 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 @@ -100,46 +119,94 @@ async fn lookup_people_identity( people_chain_genesis_hash: [u8; 32], account_id: [u8; 32], ) -> Result, String> { - let lookup = fetch_consumer_record(chain, people_chain_genesis_hash, account_id).fuse(); let timeout = futures_timer::Delay::new(LOOKUP_TIMEOUT).fuse(); - pin_mut!(lookup, timeout); - let value = futures::select! { - value = lookup => value?, - () = timeout => return Err("People-chain identity lookup timed out".to_string()), - }; - match value { - Some(value) => decode_people_identity(&value).map(Some), - None => Ok(None), + 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 => {} + } + + 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()), + } } } -/// Read the raw `Resources.Consumers` record for `account_id` at the latest -/// finalized block. The key is built locally, so the read never needs the +/// 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>, String> { +) -> Result { + let genesis_hash = people_chain_genesis_hash.to_vec(); let key = resources_consumers_storage_key(&account_id); - let backend = chain - .chain_head_backend(&people_chain_genesis_hash) + 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 { + genesis_hash: genesis_hash.clone(), + with_runtime: false, + }, + ); + + let hash = wait_for_chain_head_best_hash( + &mut follow, + "People-chain", + LOOKUP_TIMEOUT, + BEST_BLOCK_TIMEOUT, + ) + .await?; + let response = chain + .remote_chain_head_storage(RemoteChainHeadStorageRequest { + genesis_hash: genesis_hash.clone(), + follow_subscription_id: follow_id.clone(), + hash, + items: vec![StorageQueryItem { + key: key.clone(), + query_type: StorageQueryType::Value, + }], + child_trie: None, + }) .await .map_err(|failure| failure.reason())?; - let at = backend - .latest_finalized_block_ref() - .await - .map_err(|error| format!("People-chain finalized block unavailable: {error}"))?; - let mut values = backend - .storage_fetch_values(vec![key.clone()], at.hash()) - .await - .map_err(|error| format!("People-chain storage lookup failed: {error}"))?; - let mut value = None; - while let Some(item) = values.next().await { - let item = item.map_err(|error| format!("People-chain storage lookup failed: {error}"))?; - if item.key == key { - value = Some(item.value); + let operation_id = match response.operation { + OperationStartedResult::Started { operation_id } => operation_id, + OperationStartedResult::LimitReached => { + return Err("People-chain storage lookup limit reached".to_string()); } - } - Ok(value) + }; + let value = wait_for_chain_head_storage_value( + &mut follow, + ChainHeadStorageValueLookup { + chain, + genesis_hash: &genesis_hash, + follow_subscription_id: &follow_id, + operation_id: &operation_id, + key: &key, + label: "People-chain", + timeout: LOOKUP_TIMEOUT, + }, + ) + .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 5e207cf1..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, @@ -800,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, diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 0bef5cd9..35c0292b 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -142,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/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index a5fa4c91..b55d34d4 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -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 de983607..62bd9baf 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -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,8 +112,12 @@ 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, + /// 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>, @@ -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, } @@ -1188,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( From f93ad404ea316330e0a12eab5d148781a56efc3c Mon Sep 17 00:00:00 2001 From: pgherveou Date: Sat, 11 Jul 2026 14:18:18 +0200 Subject: [PATCH 9/9] chore: update dotli submodule --- hosts/dotli | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hosts/dotli b/hosts/dotli index df79ee14..07519a4e 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit df79ee147041a1b0db71e5773a9bc3aa3f8ac179 +Subproject commit 07519a4e3c70921d5272cea622ad264e4c91bb5e